Skip to content

Commit 63f4c06

Browse files
committed
demo videos
1 parent bbeb7f3 commit 63f4c06

1 file changed

Lines changed: 334 additions & 1 deletion

File tree

experiments/geometric-entropy/index.html

Lines changed: 334 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
@@ -716,6 +736,8 @@ <h1>
716736
showWireframe: true,
717737
showTriangulation: false,
718738
showSolidFill: false,
739+
statsMode: 'histogram',
740+
matrixPerm: 'none',
719741
isDragging: false,
720742
params: {
721743
n: 50,
@@ -763,6 +785,9 @@ <h1>
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

Comments
 (0)