@@ -603,6 +603,26 @@ <h1>
603603 < input type ="checkbox " id ="chk-solid-fill " style ="width: auto " />
604604 </ label >
605605 </ div >
606+ < div class ="control-group ">
607+ < label > Stats Panel</ label >
608+ < select id ="stats-select ">
609+ < option value ="histogram "> Density Histogram</ option >
610+ < option value ="matrix "> Point-Pair Distance Matrix</ option >
611+ </ select >
612+ </ div >
613+ < div class ="control-group " id ="matrix-perm-group " style ="display: none ">
614+ < label > Matrix Ordering</ label >
615+ < select id ="matrix-perm-select ">
616+ < option value ="none "> None (Original)</ option >
617+ < option value ="greedy-nn "> Greedy Nearest-Neighbor</ option >
618+ < option value ="spectral "> Spectral (Fiedler)</ option >
619+ < option value ="density "> Density Sort</ option >
620+ < option value ="pca "> PCA Axis Sort</ option >
621+ </ select >
622+ < div style ="font-size: 0.6rem; color: var(--text-muted); margin-top: -4px ">
623+ Reorders rows/cols to cluster short distances near the diagonal.
624+ </ div >
625+ </ div >
606626 < div class ="control-group ">
607627 < label
608628 > Interaction Force
716736 showWireframe : true ,
717737 showTriangulation : false ,
718738 showSolidFill : false ,
739+ statsMode : 'histogram' ,
740+ matrixPerm : 'none' ,
719741 isDragging : false ,
720742 params : {
721743 n : 50 ,
763785 chkAutoRotate : document . getElementById ( 'chk-autorotate' ) ,
764786 chkTriangulation : document . getElementById ( 'chk-triangulation' ) ,
765787 chkSolidFill : document . getElementById ( 'chk-solid-fill' ) ,
788+ statsSelect : document . getElementById ( 'stats-select' ) ,
789+ matrixPermSelect : document . getElementById ( 'matrix-perm-select' ) ,
790+ matrixPermGroup : document . getElementById ( 'matrix-perm-group' ) ,
766791 forceInput : document . getElementById ( 'param-force' ) ,
767792 forceTextInput : document . getElementById ( 'input-force' ) ,
768793 btnToggle : document . getElementById ( 'btn-toggle' ) ,
@@ -1494,6 +1519,14 @@ <h1>
14941519 const chartW = w * 0.25 ;
14951520 const chartH = h * 0.6 ;
14961521
1522+ if ( state . statsMode === 'matrix' ) {
1523+ drawDistanceMatrix ( pointsArr , numPoints , chartX , chartY , chartW , chartH ) ;
1524+ } else {
1525+ drawDensityHistogram ( densities , chartX , chartY , chartW , chartH ) ;
1526+ }
1527+ }
1528+
1529+ function drawDensityHistogram ( densities , chartX , chartY , chartW , chartH ) {
14971530 // Axis
14981531 ctx . strokeStyle = '#2a2e36' ;
14991532 ctx . lineWidth = 2 ;
@@ -1517,7 +1550,7 @@ <h1>
15171550 const range = maxD - minD ;
15181551 densities . forEach ( ( d ) => {
15191552 const bin = Math . floor ( ( ( d - minD ) / range ) * bins ) ;
1520- hist [ bin ] ++ ;
1553+ hist [ Math . max ( 0 , Math . min ( bins - 1 , bin ) ) ] ++ ;
15211554 } ) ;
15221555 const maxCount = Math . max ( ...hist , 1 ) ;
15231556 const binW = chartW / bins ;
@@ -1540,6 +1573,297 @@ <h1>
15401573 ctx . lineWidth = 1 ;
15411574 ctx . stroke ( ) ;
15421575 }
1576+ /**
1577+ * Computes a permutation (ordering) of point indices for the distance
1578+ * matrix display. The goal of most modes is to place short distances
1579+ * close to the diagonal so block-structure becomes visible.
1580+ *
1581+ * Returns an array `order` of length N (capped to display count) where
1582+ * order[displayIndex] = originalPointIndex.
1583+ */
1584+ function computeMatrixPermutation ( pointsArr , numPoints , mode ) {
1585+ const maxDisplay = 64 ;
1586+ const N = Math . min ( numPoints , maxDisplay ) ;
1587+ // Default identity order.
1588+ const identity = Array . from ( { length : N } , ( _ , i ) => i ) ;
1589+ if ( ! mode || mode === 'none' || N <= 2 ) return identity ;
1590+ // Helper: pairwise distance between original indices a, b.
1591+ const dist = ( a , b ) => {
1592+ const dx = pointsArr [ a * 3 ] - pointsArr [ b * 3 ] ;
1593+ const dy = pointsArr [ a * 3 + 1 ] - pointsArr [ b * 3 + 1 ] ;
1594+ const dz = pointsArr [ a * 3 + 2 ] - pointsArr [ b * 3 + 2 ] ;
1595+ return Math . sqrt ( dx * dx + dy * dy + dz * dz ) ;
1596+ } ;
1597+ if ( mode === 'greedy-nn' ) {
1598+ // Greedy nearest-neighbor path: start at point 0, repeatedly append
1599+ // the closest unvisited point. Produces a 1D ordering where adjacent
1600+ // rows are spatially close -> short distances hug the diagonal.
1601+ const visited = new Array ( N ) . fill ( false ) ;
1602+ const order = [ ] ;
1603+ let current = 0 ;
1604+ visited [ 0 ] = true ;
1605+ order . push ( 0 ) ;
1606+ for ( let step = 1 ; step < N ; step ++ ) {
1607+ let best = - 1 ;
1608+ let bestD = Infinity ;
1609+ for ( let j = 0 ; j < N ; j ++ ) {
1610+ if ( visited [ j ] ) continue ;
1611+ const d = dist ( current , j ) ;
1612+ if ( d < bestD ) {
1613+ bestD = d ;
1614+ best = j ;
1615+ }
1616+ }
1617+ visited [ best ] = true ;
1618+ order . push ( best ) ;
1619+ current = best ;
1620+ }
1621+ return order ;
1622+ }
1623+ if ( mode === 'density' ) {
1624+ // Sort by the density metric (clustered points grouped together).
1625+ const densities = state . metrics . densities ;
1626+ if ( densities && densities . length >= N ) {
1627+ const order = identity . slice ( ) ;
1628+ order . sort ( ( a , b ) => densities [ a ] - densities [ b ] ) ;
1629+ return order ;
1630+ }
1631+ return identity ;
1632+ }
1633+ if ( mode === 'pca' ) {
1634+ // Project points onto their principal axis and sort along it.
1635+ // Compute mean.
1636+ let mx = 0 ,
1637+ my = 0 ,
1638+ mz = 0 ;
1639+ for ( let i = 0 ; i < N ; i ++ ) {
1640+ mx += pointsArr [ i * 3 ] ;
1641+ my += pointsArr [ i * 3 + 1 ] ;
1642+ mz += pointsArr [ i * 3 + 2 ] ;
1643+ }
1644+ mx /= N ;
1645+ my /= N ;
1646+ mz /= N ;
1647+ // Covariance matrix (3x3).
1648+ let cxx = 0 ,
1649+ cxy = 0 ,
1650+ cxz = 0 ,
1651+ cyy = 0 ,
1652+ cyz = 0 ,
1653+ czz = 0 ;
1654+ for ( let i = 0 ; i < N ; i ++ ) {
1655+ const dx = pointsArr [ i * 3 ] - mx ;
1656+ const dy = pointsArr [ i * 3 + 1 ] - my ;
1657+ const dz = pointsArr [ i * 3 + 2 ] - mz ;
1658+ cxx += dx * dx ;
1659+ cxy += dx * dy ;
1660+ cxz += dx * dz ;
1661+ cyy += dy * dy ;
1662+ cyz += dy * dz ;
1663+ czz += dz * dz ;
1664+ }
1665+ // Power-iteration to find the dominant eigenvector of covariance.
1666+ let v = [ 1 , 1 , 1 ] ;
1667+ for ( let it = 0 ; it < 30 ; it ++ ) {
1668+ const nx = cxx * v [ 0 ] + cxy * v [ 1 ] + cxz * v [ 2 ] ;
1669+ const ny = cxy * v [ 0 ] + cyy * v [ 1 ] + cyz * v [ 2 ] ;
1670+ const nz = cxz * v [ 0 ] + cyz * v [ 1 ] + czz * v [ 2 ] ;
1671+ const len = Math . sqrt ( nx * nx + ny * ny + nz * nz ) || 1 ;
1672+ v = [ nx / len , ny / len , nz / len ] ;
1673+ }
1674+ const proj = identity . map ( ( i ) => {
1675+ const dx = pointsArr [ i * 3 ] - mx ;
1676+ const dy = pointsArr [ i * 3 + 1 ] - my ;
1677+ const dz = pointsArr [ i * 3 + 2 ] - mz ;
1678+ return { i, p : dx * v [ 0 ] + dy * v [ 1 ] + dz * v [ 2 ] } ;
1679+ } ) ;
1680+ proj . sort ( ( a , b ) => a . p - b . p ) ;
1681+ return proj . map ( ( o ) => o . i ) ;
1682+ }
1683+ if ( mode === 'spectral' ) {
1684+ // Spectral ordering via the Fiedler vector of the graph Laplacian.
1685+ // Build affinity W = exp(-d^2 / sigma^2), degree D, Laplacian L = D - W.
1686+ // The second-smallest eigenvector of L (Fiedler vector) gives a
1687+ // 1D embedding; sorting by it clusters connected nodes.
1688+ // Estimate sigma as the mean nearest-neighbor distance.
1689+ let sigmaAccum = 0 ;
1690+ for ( let i = 0 ; i < N ; i ++ ) {
1691+ let best = Infinity ;
1692+ for ( let j = 0 ; j < N ; j ++ ) {
1693+ if ( i === j ) continue ;
1694+ const d = dist ( i , j ) ;
1695+ if ( d < best ) best = d ;
1696+ }
1697+ sigmaAccum += isFinite ( best ) ? best : 0 ;
1698+ }
1699+ const sigma = sigmaAccum / N || 1 ;
1700+ const inv2s2 = 1 / ( 2 * sigma * sigma ) ;
1701+ // Affinity and degree.
1702+ const W = [ ] ;
1703+ const deg = new Array ( N ) . fill ( 0 ) ;
1704+ for ( let i = 0 ; i < N ; i ++ ) {
1705+ W [ i ] = new Array ( N ) . fill ( 0 ) ;
1706+ }
1707+ for ( let i = 0 ; i < N ; i ++ ) {
1708+ for ( let j = i + 1 ; j < N ; j ++ ) {
1709+ const d = dist ( i , j ) ;
1710+ const w = Math . exp ( - d * d * inv2s2 ) ;
1711+ W [ i ] [ j ] = w ;
1712+ W [ j ] [ i ] = w ;
1713+ deg [ i ] += w ;
1714+ deg [ j ] += w ;
1715+ }
1716+ }
1717+ // Find the Fiedler vector via inverse power iteration on L, working in
1718+ // the subspace orthogonal to the constant (smallest eigenvalue) vector.
1719+ // We instead iterate on the matrix and deflate the all-ones direction.
1720+ const applyL = ( x ) => {
1721+ // y = L x = D x - W x
1722+ const y = new Array ( N ) . fill ( 0 ) ;
1723+ for ( let i = 0 ; i < N ; i ++ ) {
1724+ let s = deg [ i ] * x [ i ] ;
1725+ const Wi = W [ i ] ;
1726+ for ( let j = 0 ; j < N ; j ++ ) {
1727+ if ( j !== i ) s -= Wi [ j ] * x [ j ] ;
1728+ }
1729+ y [ i ] = s ;
1730+ }
1731+ return y ;
1732+ } ;
1733+ const deflate = ( x ) => {
1734+ // Remove component along the all-ones vector.
1735+ let mean = 0 ;
1736+ for ( let i = 0 ; i < N ; i ++ ) mean += x [ i ] ;
1737+ mean /= N ;
1738+ for ( let i = 0 ; i < N ; i ++ ) x [ i ] -= mean ;
1739+ // Normalize.
1740+ let norm = 0 ;
1741+ for ( let i = 0 ; i < N ; i ++ ) norm += x [ i ] * x [ i ] ;
1742+ norm = Math . sqrt ( norm ) || 1 ;
1743+ for ( let i = 0 ; i < N ; i ++ ) x [ i ] /= norm ;
1744+ } ;
1745+ // Largest eigenvalue estimate for shift (Gershgorin bound ~ 2*maxDeg).
1746+ let maxDeg = 0 ;
1747+ for ( let i = 0 ; i < N ; i ++ ) maxDeg = Math . max ( maxDeg , deg [ i ] ) ;
1748+ const shift = 2 * maxDeg + 1e-6 ;
1749+ // Iterate on (shift*I - L): its dominant eigenvector (after deflating
1750+ // the constant mode) corresponds to L's smallest non-trivial mode.
1751+ let x = identity . map ( ( i ) => Math . sin ( i + 1 ) ) ;
1752+ deflate ( x ) ;
1753+ for ( let it = 0 ; it < 100 ; it ++ ) {
1754+ const Lx = applyL ( x ) ;
1755+ const next = new Array ( N ) ;
1756+ for ( let i = 0 ; i < N ; i ++ ) next [ i ] = shift * x [ i ] - Lx [ i ] ;
1757+ deflate ( next ) ;
1758+ x = next ;
1759+ }
1760+ const fiedler = identity . map ( ( i ) => ( { i, v : x [ i ] } ) ) ;
1761+ fiedler . sort ( ( a , b ) => a . v - b . v ) ;
1762+ return fiedler . map ( ( o ) => o . i ) ;
1763+ }
1764+ return identity ;
1765+ }
1766+
1767+ function drawDistanceMatrix ( pointsArr , numPoints , chartX , chartY , chartW , chartH ) {
1768+ // Build the permutation order according to the selected mode.
1769+ const order = computeMatrixPermutation ( pointsArr , numPoints , state . matrixPerm ) ;
1770+
1771+ // Label
1772+ ctx . fillStyle = '#a0a0a0' ;
1773+ ctx . font = '10px JetBrains Mono' ;
1774+ ctx . fillText ( 'POINT-PAIR DISTANCE MATRIX' , chartX , chartY - chartH / 2 - 10 ) ;
1775+
1776+ // Cap the number of points displayed to keep cells visible
1777+ const maxDisplay = 64 ;
1778+ const N = Math . min ( numPoints , maxDisplay ) ;
1779+ const side = Math . min ( chartW , chartH ) ;
1780+ const cell = side / N ;
1781+ const originX = chartX ;
1782+ const originY = chartY - side / 2 ;
1783+ // Map display index -> original point index via the permutation.
1784+ const idxOf = ( k ) => order [ k ] ;
1785+
1786+ // First pass: compute min/max distance for color normalization
1787+ let minDist = Infinity ;
1788+ let maxDist = - Infinity ;
1789+ for ( let i = 0 ; i < N ; i ++ ) {
1790+ const oi = idxOf ( i ) ;
1791+ const xi = pointsArr [ oi * 3 ] ,
1792+ yi = pointsArr [ oi * 3 + 1 ] ,
1793+ zi = pointsArr [ oi * 3 + 2 ] ;
1794+ for ( let j = 0 ; j < N ; j ++ ) {
1795+ if ( i === j ) continue ;
1796+ const oj = idxOf ( j ) ;
1797+ const xj = pointsArr [ oj * 3 ] ,
1798+ yj = pointsArr [ oj * 3 + 1 ] ,
1799+ zj = pointsArr [ oj * 3 + 2 ] ;
1800+ const d = Math . sqrt ( ( xi - xj ) ** 2 + ( yi - yj ) ** 2 + ( zi - zj ) ** 2 ) ;
1801+ if ( d < minDist ) minDist = d ;
1802+ if ( d > maxDist ) maxDist = d ;
1803+ }
1804+ }
1805+ if ( ! isFinite ( minDist ) ) minDist = 0 ;
1806+ if ( ! isFinite ( maxDist ) ) maxDist = 1 ;
1807+ const range = maxDist - minDist + 1e-6 ;
1808+
1809+ // Second pass: draw cells colored by distance (Cyan = near, Magenta = far)
1810+ for ( let i = 0 ; i < N ; i ++ ) {
1811+ const oi = idxOf ( i ) ;
1812+ const xi = pointsArr [ oi * 3 ] ,
1813+ yi = pointsArr [ oi * 3 + 1 ] ,
1814+ zi = pointsArr [ oi * 3 + 2 ] ;
1815+ for ( let j = 0 ; j < N ; j ++ ) {
1816+ let t ;
1817+ if ( i === j ) {
1818+ t = 0 ;
1819+ } else {
1820+ const oj = idxOf ( j ) ;
1821+ const xj = pointsArr [ oj * 3 ] ,
1822+ yj = pointsArr [ oj * 3 + 1 ] ,
1823+ zj = pointsArr [ oj * 3 + 2 ] ;
1824+ const d = Math . sqrt ( ( xi - xj ) ** 2 + ( yi - yj ) ** 2 + ( zi - zj ) ** 2 ) ;
1825+ t = ( d - minDist ) / range ;
1826+ }
1827+ // Interpolate Cyan (near, t=0) -> Magenta (far, t=1)
1828+ const r = Math . floor ( 0 + t * 255 ) ;
1829+ const g = Math . floor ( 210 - t * 210 ) ;
1830+ const b = 255 ;
1831+ ctx . fillStyle = `rgb(${ r } , ${ g } , ${ b } )` ;
1832+ ctx . fillRect ( originX + j * cell , originY + i * cell , Math . ceil ( cell ) , Math . ceil ( cell ) ) ;
1833+ }
1834+ }
1835+
1836+ // Border
1837+ ctx . strokeStyle = '#2a2e36' ;
1838+ ctx . lineWidth = 1 ;
1839+ ctx . strokeRect ( originX , originY , N * cell , N * cell ) ;
1840+
1841+ // Note if truncated
1842+ if ( numPoints > maxDisplay ) {
1843+ ctx . fillStyle = '#6b7280' ;
1844+ ctx . font = '9px JetBrains Mono' ;
1845+ ctx . fillText (
1846+ `showing ${ maxDisplay } of ${ numPoints } points` ,
1847+ originX ,
1848+ originY + N * cell + 14
1849+ ) ;
1850+ }
1851+
1852+ // Legend
1853+ ctx . fillStyle = '#a0a0a0' ;
1854+ ctx . font = '9px JetBrains Mono' ;
1855+ ctx . fillText ( 'near' , originX , originY + side + 30 ) ;
1856+ ctx . fillText ( 'far' , originX + side - 18 , originY + side + 30 ) ;
1857+ const legendY = originY + side + 18 ;
1858+ const legendW = side ;
1859+ for ( let x = 0 ; x < legendW ; x ++ ) {
1860+ const t = x / legendW ;
1861+ const r = Math . floor ( 0 + t * 255 ) ;
1862+ const g = Math . floor ( 210 - t * 210 ) ;
1863+ ctx . fillStyle = `rgb(${ r } , ${ g } , 255)` ;
1864+ ctx . fillRect ( originX + x , legendY , 1 , 6 ) ;
1865+ }
1866+ }
15431867
15441868 function updateUI ( ) {
15451869 els . metricEntropy . textContent = state . metrics . entropy . toFixed ( 4 ) ;
@@ -1913,6 +2237,15 @@ <h1>
19132237 state . showSolidFill = e . target . checked ;
19142238 draw ( ) ;
19152239 } ) ;
2240+ els . statsSelect . addEventListener ( 'change' , ( e ) => {
2241+ state . statsMode = e . target . value ;
2242+ els . matrixPermGroup . style . display = state . statsMode === 'matrix' ? 'flex' : 'none' ;
2243+ draw ( ) ;
2244+ } ) ;
2245+ els . matrixPermSelect . addEventListener ( 'change' , ( e ) => {
2246+ state . matrixPerm = e . target . value ;
2247+ draw ( ) ;
2248+ } ) ;
19162249 els . forceInput . addEventListener ( 'input' , ( e ) => {
19172250 // Logarithmic scale: sign * (10^|val| - 1) * scale
19182251 const val = parseFloat ( e . target . value ) ;
0 commit comments