Custom Code Experiment •
Polytope
Source Code View HTML / CSS / JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Polytope</title>
<style>
:root {
--bg: #0b0f14;
--panel: rgba(17, 24, 39, 0.6);
--text: #e5e7eb;
--muted: #94a3b8;
--accent: #60a5fa;
--ring: rgba(96, 165, 250, .35);
--ok: #34d399;
--warn: #f59e0b;
--err: #f87171;
--topbar-h: 56px;
--side-w: clamp(280px, 28vw, 380px);
--radius: 16px;
}
html, body { height: 100%; }
body {
margin: 0; height: 100vh; overflow: hidden;
background: radial-gradient(1200px 800px at 15% 10%, #0f172a 0%, #0b0f14 45%, #05070a 100%);
color: var(--text); font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
.topbar {
height: var(--topbar-h); display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; backdrop-filter: blur(10px);
background: linear-gradient(180deg, rgba(10, 15, 25, .65), rgba(10, 15, 25, .35));
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.brand { display:flex; align-items:center; gap:10px; font-weight:600; letter-spacing:.2px; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 18px var(--accent); display:inline-block; }
.btn {
-webkit-appearance:none; appearance:none; cursor:pointer;
padding: 8px 12px; border-radius: 12px; color: var(--text);
border: 1px solid rgba(255,255,255,.1);
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
}
.layout {
display: grid; grid-template-columns: var(--side-w) 1fr; grid-template-rows: 1fr;
height: calc(100vh - var(--topbar-h));
}
.sidebar {
position: relative; height: 100%; z-index: 10; overflow: hidden;
border-right: 1px solid rgba(255,255,255,0.08);
background: linear-gradient(180deg, rgba(10, 15, 25, .55), rgba(10, 15, 25, .35));
backdrop-filter: blur(10px);
}
.panel-body { height: 100%; overflow: auto; padding: 14px 16px; }
.stage { position: relative; height: 100%; }
canvas { position: absolute; inset: 0; display: block; }
.row { display: grid; grid-template-columns: 110px 1fr auto; align-items: center; gap: 10px; margin: 8px 0; }
.row:first-child { margin-top: 0; }
label { color: var(--muted); }
select, input[type="range"], input[type="checkbox"] { width: 100%; }
select, input[type="range"] {
-webkit-appearance: none; appearance: none;
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
border: 1px solid rgba(255,255,255,.1); color: var(--text);
padding: 8px 10px; border-radius: 10px; outline: none;
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
}
input[type="range"] { padding: 0; height: 36px; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 0 4px var(--ring); border: none;
transition: all 0.1s ease;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 0 4px var(--ring); border: none;
transition: all 0.1s ease;
}
input[type="range"]:active::-webkit-slider-thumb {
box-shadow: 0 0 0 8px var(--ring);
}
.value { color: var(--muted); font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; }
.title { font-weight: 600; letter-spacing:.2px; margin-bottom: 8px; display:flex; align-items:center; gap:10px; }
.hint { color: var(--muted); margin-top: 8px; font-size: 12px; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(96,165,250,.12); color: #bfdbfe; border: 1px solid rgba(96,165,250,.3); }
.sep { height: 1px; background: rgba(255,255,255,.08); margin: 10px 0; }
.footer { display:flex; align-items:center; justify-content: space-between; gap:8px; margin-top: 6px; }
.small { font-size: 12px; color: var(--muted); }
.kbd { font-size: 11px; padding: 2px 6px; border-radius: 6px; border:1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.05); }
.toggle-row { display:flex; align-items:center; gap:10px; }
.toggle-row input { width: auto; }
.diag { margin-top: 8px; font-size: 12px; color: var(--muted); }
.pill { display:inline-block; padding:2px 8px; border-radius:999px; border:1px solid rgba(255,255,255,.12); }
.ok { color:#10b981; border-color: rgba(16,185,129,.35); background: rgba(16,185,129,.08); }
.warn { color:#f59e0b; border-color: rgba(245,158,11,.35); background: rgba(245,158,11,.08); }
.err { color:#ef4444; border-color: rgba(239,68,68,.35); background: rgba(239,68,68,.08); }
details summary { cursor: pointer; user-select: none; }
details pre { max-height: 160px; overflow: auto; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); padding: 8px; border-radius: 8px; }
.overlay { position: fixed; inset: 0; background: rgba(2,6,23,.45); backdrop-filter: blur(2px); opacity: 0; pointer-events: none; transition: opacity .2s ease; }
.overlay.show { opacity: 1; pointer-events: auto; }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.sidebar { position: fixed; inset: auto 0 0 0; height: auto; max-height: min(76vh, 560px); border-right: 0; border-top: 1px solid rgba(255,255,255,.08); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); box-shadow: 0 -20px 60px rgba(0,0,0,.45); transform: translateY(100%); transition: transform .25s ease; }
.sidebar[data-open="true"] { transform: translateY(0%); }
.panel-body { padding: 14px 16px 18px; }
.row { grid-template-columns: 1fr 1fr auto; }
.btn { padding: 8px 12px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><span class="dot"></span> Polytope Animator <span class="badge">Canvas</span></div>
<button id="toggleMenu" class="btn" aria-expanded="false" aria-controls="sidebar">Controls</button>
</header>
<div class="layout">
<aside class="sidebar" id="sidebar" role="region" aria-label="Controls" aria-hidden="false">
<div class="panel-body">
<div class="title">Render Settings</div>
<div class="row">
<label for="shape">Shape</label>
<select id="shape">
<option value="hypercube" selected>Hypercube (n-cube)</option>
<option value="simplex">Simplex (n-simplex)</option>
<option value="cross">Cross polytope (n-orthoplex)</option>
</select>
<span class="value" id="shapeInfo">n=4</span>
</div>
<div class="row">
<label for="dim">Dimension</label>
<input id="dim" type="range" min="3" max="6" step="1" value="4" />
<span class="value" id="dimVal">4D</span>
</div>
<div class="row">
<label for="speed">Spin speed</label>
<!-- Increased max speed to 0.8 with adjusted default -->
<input id="speed" type="range" min="0" max="0.8" step="0.001" value="0.15" />
<span class="value" id="speedVal">0.15</span>
</div>
<div class="row">
<label for="size">Size</label>
<input id="size" type="range" min="0.5" max="1.6" step="0.01" value="0.95" />
<span class="value" id="sizeVal">0.95</span>
</div>
<div class="row">
<label for="persp">Perspective</label>
<input id="persp" type="range" min="2" max="8" step="0.1" value="3.5" />
<span class="value" id="perspVal">3.5</span>
</div>
<div class="toggle-row">
<label for="showVerts">Show vertices</label>
<input id="showVerts" type="checkbox" checked />
<label for="autoSpin" style="margin-left:auto;">Auto-spin</label>
<input id="autoSpin" type="checkbox" checked />
</div>
<div class="sep"></div>
<details>
<summary class="small">Diagnostics & self‑tests</summary>
<div class="diag" id="diagStatus">Running…</div>
<pre id="diagLog" aria-live="polite"></pre>
</details>
<div class="footer small">
<div>Drag to rotate • Scroll/Pinch to zoom</div>
<div class="kbd">R</div> reset
</div>
</div>
</aside>
<section class="stage" id="stage">
<canvas id="polyCanvas" aria-label="Polytope animation"></canvas>
</section>
</div>
<div id="overlay" class="overlay" hidden></div>
<script>
(function() {
'use strict';
const canvas = document.getElementById('polyCanvas');
const stage = document.getElementById('stage');
const ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: false,
powerPreference: 'high-performance'
});
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
function resize() {
const rect = stage.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width));
const h = Math.max(1, Math.floor(rect.height));
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'lighter';
}
window.addEventListener('resize', resize, { passive:true });
const ro = new ResizeObserver(resize); ro.observe(stage);
resize();
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('overlay');
const btn = document.getElementById('toggleMenu');
function closeMenu() {
sidebar.removeAttribute('data-open');
overlay.classList.remove('show');
overlay.setAttribute('hidden', '');
btn.setAttribute('aria-expanded', 'false');
}
function openMenu() {
sidebar.setAttribute('data-open', 'true');
overlay.classList.add('show');
overlay.removeAttribute('hidden');
btn.setAttribute('aria-expanded', 'true');
}
btn.addEventListener('click', () => {
if (sidebar.hasAttribute('data-open')) closeMenu(); else openMenu();
});
overlay.addEventListener('click', closeMenu);
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); });
const elShape = document.getElementById('shape');
const elDim = document.getElementById('dim');
const elSpeed = document.getElementById('speed');
const elSize = document.getElementById('size');
const elPersp = document.getElementById('persp');
const elShowVerts = document.getElementById('showVerts');
const elAutoSpin = document.getElementById('autoSpin');
const shapeInfo = document.getElementById('shapeInfo');
const dimVal = document.getElementById('dimVal');
const speedVal = document.getElementById('speedVal');
const sizeVal = document.getElementById('sizeVal');
const perspVal = document.getElementById('perspVal');
const settings = {
shape: elShape.value,
n: parseInt(elDim.value, 10),
speed: parseFloat(elSpeed.value),
size: parseFloat(elSize.value),
perspective: parseFloat(elPersp.value),
showVerts: elShowVerts.checked,
autoSpin: elAutoSpin.checked
};
let lastTime = 0;
let accumulatedTime = 0;
const fixedTimeStep = 1 / 60;
elShape.addEventListener('change', () => {
settings.shape = elShape.value;
regenerate();
});
elDim.addEventListener('input', () => {
settings.n = parseInt(elDim.value, 10);
dimVal.textContent = settings.n + 'D';
regenerate();
});
elSpeed.addEventListener('input', () => {
settings.speed = parseFloat(elSpeed.value);
speedVal.textContent = settings.speed.toFixed(2);
});
elSize.addEventListener('input', () => {
settings.size = parseFloat(elSize.value);
sizeVal.textContent = settings.size.toFixed(2);
});
elPersp.addEventListener('input', () => {
settings.perspective = parseFloat(elPersp.value);
perspVal.textContent = settings.perspective.toFixed(1);
});
elShowVerts.addEventListener('change', () => {
settings.showVerts = elShowVerts.checked;
});
elAutoSpin.addEventListener('change', () => {
settings.autoSpin = elAutoSpin.checked;
});
window.addEventListener('keydown', (e) => {
if (e.key === 'r' || e.key === 'R') resetOrientation();
});
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function normalizeVertices(verts) {
let maxR = 0;
for (const v of verts) {
let r2 = 0; for (let i=0;i<v.length;i++) r2 += v[i]*v[i];
const r = Math.sqrt(r2); if (r > maxR) maxR = r;
}
if (maxR === 0) return;
const s = 1 / maxR;
for (const v of verts) for (let i=0;i<v.length;i++) v[i] *= s;
}
function hypercube(n) {
const V = 1 << n; const verts = new Array(V);
for (let i=0;i<V;i++) {
const v = new Array(n);
for (let b=0;b<n;b++) v[b] = ( (i>>b) & 1 ) ? 1 : -1;
verts[i] = v;
}
normalizeVertices(verts);
const edges = [];
for (let i=0;i<V;i++) {
for (let j=i+1;j<V;j++) {
let hd = 0;
for (let k=0;k<n;k++) if (verts[i][k] !== verts[j][k]) { hd++; if (hd>1) break; }
if (hd === 1) edges.push([i,j]);
}
}
return { verts, edges };
}
function simplex(n) {
const N = n + 1;
const temp = [];
for (let i=0;i<N;i++) {
const v = new Array(N).fill(-1/N);
v[i] = 1 - 1/N;
temp.push(v);
}
const verts = temp.map(v => v.slice(0, n));
normalizeVertices(verts);
const edges = [];
for (let i=0;i<N;i++) for (let j=i+1;j<N;j++) edges.push([i,j]);
return { verts, edges };
}
function crossPolytope(n) {
const verts = [];
for (let i=0;i<n;i++) {
const v1 = new Array(n).fill(0); v1[i] = 1; verts.push(v1);
const v2 = new Array(n).fill(0); v2[i] = -1; verts.push(v2);
}
normalizeVertices(verts);
const axisOf = (v) => { for (let i=0;i<n;i++) if (v[i] !== 0) return i; return -1; };
const edges = [];
for (let i=0;i<verts.length;i++) {
const ai = axisOf(verts[i]);
for (let j=i+1;j<verts.length;j++) {
const aj = axisOf(verts[j]);
if (ai === aj) continue;
edges.push([i,j]);
}
}
return { verts, edges };
}
let geom = { verts: [], edges: [] };
let pairs = [];
let angles = [];
let velocities = [];
let pairIndex = new Map();
let targetAngles = [];
let currentAngles = [];
function buildPairs(n) {
pairs = []; pairIndex.clear();
for (let i=0;i<n;i++) for (let j=i+1;j<n;j++) {
pairIndex.set(`${i}-${j}`, pairs.length);
pairs.push([i,j]);
}
angles = new Array(pairs.length).fill(0);
velocities = new Array(pairs.length).fill(0).map(() =>
((Math.random() * 0.3 + 0.05) * (Math.random() < 0.5 ? -1 : 1)) * 0.25
);
targetAngles = angles.slice();
currentAngles = angles.slice();
}
function resetOrientation() {
angles.fill(0);
targetAngles = angles.slice();
currentAngles = angles.slice();
}
function smoothStep(current, target, deltaTime, smoothing = 4) {
const t = 1 - Math.exp(-smoothing * deltaTime);
return current + (target - current) * t;
}
function rotatePoint(vec, deltaTime) {
if (settings.autoSpin) {
const s = settings.speed * 0.7;
for (let p = 0; p < angles.length; p++) {
targetAngles[p] += velocities[p] * s * deltaTime;
}
}
for (let p = 0; p < currentAngles.length; p++) {
currentAngles[p] = smoothStep(currentAngles[p], targetAngles[p], deltaTime);
}
const r = vec.slice();
for (let p = 0; p < pairs.length; p++) {
const [i, j] = pairs[p];
const a = currentAngles[p];
const si = Math.sin(a), co = Math.cos(a);
const xi = r[i], xj = r[j];
r[i] = xi * co - xj * si;
r[j] = xi * si + xj * co;
}
return r;
}
function projectNDTo2D(v) {
const f = settings.perspective;
let p = v.slice();
for (let k = p.length - 1; k > 2; k--) {
const w = p[k];
const denom = (f - w);
const s = f / (denom === 0 ? 1e-6 : denom);
for (let i = 0; i < k; i++) p[i] *= s;
p.length = k;
}
let z = 0;
if (p.length === 3) {
const w = p[2];
const denom = (f - w);
const s = f / (denom === 0 ? 1e-6 : denom);
p[0] *= s; p[1] *= s; z = w;
p.length = 2;
}
return { x: p[0], y: p[1], zDepth: z };
}
function regenerate() {
const n = clamp(settings.n, 2, 8);
let g;
if (settings.shape === 'hypercube') g = hypercube(n);
else if (settings.shape === 'simplex') g = simplex(n);
else g = crossPolytope(n);
geom = g;
buildPairs(n);
resetOrientation();
shapeInfo.textContent = `${geom.verts.length}v / ${geom.edges.length}e`;
}
regenerate();
let dragging = false;
let lastX = 0, lastY = 0;
let dragVelX = 0, dragVelY = 0;
let lastDragTime = 0;
canvas.addEventListener('pointerdown', (e) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
lastDragTime = performance.now();
dragVelX = dragVelY = 0;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointerup', (e) => {
dragging = false;
canvas.releasePointerCapture(e.pointerId);
});
canvas.addEventListener('pointerleave', () => dragging = false);
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const now = performance.now();
const dt = Math.min(0.1, (now - lastDragTime) / 1000);
lastDragTime = now;
const dx = (e.clientX - lastX);
const dy = (e.clientY - lastY);
lastX = e.clientX;
lastY = e.clientY;
const smoothFactor = 0.2;
dragVelX = dragVelX * (1 - smoothFactor) + dx * smoothFactor;
dragVelY = dragVelY * (1 - smoothFactor) + dy * smoothFactor;
const key01 = '0-1';
const key02 = '0-2';
const key23 = '2-3';
if (pairIndex.has(key01)) targetAngles[pairIndex.get(key01)] += dragVelX * 0.004;
if (pairIndex.has(key02)) targetAngles[pairIndex.get(key02)] += dragVelY * 0.004;
if (pairIndex.has(key23)) targetAngles[pairIndex.get(key23)] += (dragVelX - dragVelY) * 0.002;
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = Math.sign(e.deltaY) * 0.03;
const newSize = clamp(settings.size - delta, 0.3, 2.5);
const animateZoom = (current, target, steps = 10) => {
const step = (target - current) / steps;
let stepCount = 0;
const animateStep = () => {
settings.size += step;
if (++stepCount >= steps) {
settings.size = target;
} else {
requestAnimationFrame(animateStep);
}
elSize.value = settings.size.toString();
sizeVal.textContent = settings.size.toFixed(2);
};
animateStep();
};
animateZoom(settings.size, newSize, 5);
}, { passive: false });
function drawFrame(now) {
const dt = Math.min(0.05, (now - lastTime) / 1000);
lastTime = now;
accumulatedTime += dt;
while (accumulatedTime >= fixedTimeStep) {
accumulatedTime -= fixedTimeStep;
}
const w = canvas.clientWidth, h = canvas.clientHeight;
ctx.fillStyle = 'rgba(11, 15, 20, 0.08)';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(96, 165, 250, 0.03)';
ctx.lineWidth = 0.5;
const gridSize = 40;
const cx = w/2, cy = h/2;
ctx.beginPath();
ctx.moveTo(cx, 0);
ctx.lineTo(cx, h);
ctx.moveTo(0, cy);
ctx.lineTo(w, cy);
ctx.stroke();
const screen = new Array(geom.verts.length);
let minZ = Infinity, maxZ = -Infinity;
for (let i = 0; i < geom.verts.length; i++) {
const r = rotatePoint(geom.verts[i], dt);
const p = projectNDTo2D(r);
screen[i] = p;
if (p.zDepth < minZ) minZ = p.zDepth;
if (p.zDepth > maxZ) maxZ = p.zDepth;
}
const zSpan = Math.max(1e-6, maxZ - minZ);
const scale = Math.min(w, h) * 0.28 * settings.size;
const edges = geom.edges.slice().sort((a, b) => {
const za = (screen[a[0]].zDepth + screen[a[1]].zDepth) * 0.5;
const zb = (screen[b[0]].zDepth + screen[b[1]].zDepth) * 0.5;
return za - zb;
});
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const [i, j] of edges) {
const a = screen[i], b = screen[j];
const ax = cx + a.x * scale, ay = cy + a.y * scale;
const bx = cx + b.x * scale, by = cy + b.y * scale;
const zAvg = (a.zDepth + b.zDepth) * 0.5;
const t = (zAvg - minZ) / zSpan;
ctx.globalAlpha = 0.2 + 0.4 * (1 - t);
const hue = 200 + 60 * (1 - t);
ctx.strokeStyle = `hsl(${hue}deg 50% 75% / 0.9)`;
ctx.lineWidth = 0.8 + (1 - t) * 0.4;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
if (settings.showVerts) {
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < screen.length; i++) {
const p = screen[i];
const x = cx + p.x * scale, y = cy + p.y * scale;
const t = (p.zDepth - minZ) / zSpan;
ctx.globalAlpha = 0.3 + 0.4 * (1 - t);
const gradient = ctx.createRadialGradient(x, y, 0, x, y, 8);
gradient.addColorStop(0, `hsl(${220 + 40 * (1 - t)}deg 60% 85% / 0.8)`);
gradient.addColorStop(1, `hsl(${220 + 40 * (1 - t)}deg 30% 70% / 0)`);
ctx.fillStyle = gradient;
const r = 1.2 + (1 - t) * 1.2;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.6 + 0.4 * (1 - t);
ctx.fillStyle = `hsl(${220 + 40 * (1 - t)}deg 70% 90%)`;
ctx.beginPath();
ctx.arc(x, y, r * 0.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
const vignette = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.7);
vignette.addColorStop(0, 'rgba(11, 15, 20, 0)');
vignette.addColorStop(1, 'rgba(11, 15, 20, 0.3)');
ctx.fillStyle = vignette;
ctx.fillRect(0, 0, w, h);
requestAnimationFrame(drawFrame);
}
requestAnimationFrame((now) => {
lastTime = now;
drawFrame(now);
});
const diagStatus = document.getElementById('diagStatus');
const diagLog = document.getElementById('diagLog');
function expect(label, cond) { return { label, pass: !!cond }; }
function runSelfTests() {
const results = [];
for (let n=3; n<=6; n++) {
const g = hypercube(n);
const vExp = 2 ** n; const eExp = n * (2 ** (n-1));
results.push(expect(`Hypercube ${n}D vertices == ${vExp}`, g.verts.length === vExp));
results.push(expect(`Hypercube ${n}D edges == ${eExp}`, g.edges.length === eExp));
}
for (let n=3; n<=6; n++) {
const g = simplex(n);
const vExp = n + 1; const eExp = (n * (n + 1)) / 2;
results.push(expect(`Simplex ${n}D vertices == ${vExp}`, g.verts.length === vExp));
results.push(expect(`Simplex ${n}D edges == ${eExp}`, g.edges.length === eExp));
}
for (let n=3; n<=6; n++) {
const g = crossPolytope(n);
const vExp = 2 * n; const eExp = 2 * n * (n - 1);
results.push(expect(`Cross ${n}D vertices == ${vExp}`, g.verts.length === vExp));
results.push(expect(`Cross ${n}D edges == ${eExp}`, g.edges.length === eExp));
}
return results;
}
(function reportTests(){
const results = runSelfTests();
const passes = results.filter(r => r.pass).length; const total = results.length;
const cls = passes === total ? 'ok' : (passes > 0 ? 'warn' : 'err');
diagStatus.innerHTML = `<span class="pill ${cls}">${passes}/${total} tests passed</span>`;
const lines = results.map(r => `${r.pass ? '✔' : '✖'} ${r.label}`);
diagLog.textContent = lines.join('\n');
console.log('[polytope] Self-tests:', { passes, total, details: results });
})();
})();
</script>
</body>
</html>