triple-pendulum / index.html
pjt222's picture
Upload index.html with huggingface_hub
8e1cd06 verified
Raw
History Blame Contribute Delete
103 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Triple Pendulum 3D Chaos Viewer</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0a0f;
--bg-panel: rgba(12, 12, 20, 0.92);
--bg-panel-border: rgba(255, 255, 255, 0.12);
--bg-input: rgba(255, 255, 255, 0.08);
--bg-input-hover: rgba(255, 255, 255, 0.14);
--text-primary: #e8e8f0;
--text-muted: #b0b0c0;
--text-accent: #00f0ff;
--text-accent-strong: #66f6ff;
--color-positive: #66f6ff;
--color-negative: #ff7aa8;
--glow-cyan: 0 0 12px rgba(0, 240, 255, 0.4);
--font-mono: 'Share Tech Mono', monospace;
--font-display: 'Orbitron', sans-serif;
--transition-speed: 0.2s;
--tt-delay: 350ms;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-mono);
overflow: hidden;
width: 100vw;
height: 100vh;
}
/* CRT Scanline Overlay (toggleable via .scanlines on body) */
body.scanlines::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.04) 2px,
rgba(0, 0, 0, 0.04) 4px
);
}
@media (prefers-contrast: more) {
body.scanlines::after { display: none; }
}
#canvas-container {
position: absolute;
inset: 0;
}
canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
/* Controls Panel */
#controls {
position: absolute;
top: 12px;
left: 12px;
width: 280px;
max-height: calc(100vh - 24px);
background: var(--bg-panel);
border: 1px solid var(--bg-panel-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
font-size: 12px;
backdrop-filter: blur(12px);
z-index: 50;
}
@media (max-width: 480px) {
#controls {
width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
font-size: 13px;
}
}
#controls-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.06);
border: none;
border-bottom: 1px solid var(--bg-panel-border);
cursor: pointer;
user-select: none;
flex-shrink: 0;
width: 100%;
font-family: inherit;
color: inherit;
text-align: left;
}
#controls-header:hover,
#controls-header:focus-visible {
background: rgba(255, 255, 255, 0.12);
outline: none;
}
#controls-header:focus-visible {
box-shadow: inset 0 0 0 2px var(--text-accent);
}
#controls-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 11px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-accent);
text-shadow: var(--glow-cyan);
}
#collapse-icon {
font-size: 10px;
color: var(--text-muted);
transition: transform var(--transition-speed);
}
#controls.collapsed #collapse-icon {
transform: rotate(-90deg);
}
#controls-body {
overflow-y: auto;
padding: 12px 12px 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
#controls.collapsed #controls-body {
display: none;
}
/* Scrollbar */
#controls-body::-webkit-scrollbar {
width: 6px;
}
#controls-body::-webkit-scrollbar-track {
background: transparent;
}
#controls-body::-webkit-scrollbar-thumb {
background: rgba(0, 240, 255, 0.25);
border-radius: 3px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-primary);
margin: 0 0 2px;
}
.control-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 24px;
}
.control-row label {
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
}
.control-row .value-label {
color: var(--text-muted);
min-width: 44px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Range slider β€” touch target 24px high (visual track 4px) */
input[type="range"] {
flex: 1;
height: 24px;
appearance: none;
background: transparent;
outline: none;
cursor: pointer;
padding: 0;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background: rgba(255, 255, 255, 0.18);
border-radius: 2px;
}
input[type="range"]::-moz-range-track {
height: 4px;
background: rgba(255, 255, 255, 0.18);
border-radius: 2px;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-accent);
cursor: pointer;
margin-top: -7px;
box-shadow: 0 0 6px rgba(0, 240, 255, 0.5), 0 0 12px rgba(0, 240, 255, 0.2);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-accent);
cursor: pointer;
border: none;
box-shadow: 0 0 6px rgba(0, 240, 255, 0.5), 0 0 12px rgba(0, 240, 255, 0.2);
}
input[type="range"]:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--text-accent-strong);
outline-offset: 2px;
}
input[type="range"]:disabled {
opacity: 0.4;
cursor: not-allowed;
}
input[type="range"]:disabled::-webkit-slider-thumb {
background: var(--text-muted);
box-shadow: none;
}
/* Checkbox β€” 18Γ—18 visual, larger via .control-row min-height for touch */
input[type="checkbox"] {
appearance: none;
width: 18px;
height: 18px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 3px;
background: transparent;
cursor: pointer;
position: relative;
flex-shrink: 0;
transition: border-color var(--transition-speed), background var(--transition-speed);
}
input[type="checkbox"]:hover {
border-color: var(--text-accent);
}
input[type="checkbox"]:focus-visible {
outline: 2px solid var(--text-accent-strong);
outline-offset: 2px;
}
input[type="checkbox"]:checked {
background: var(--text-accent);
border-color: var(--text-accent);
box-shadow: 0 0 4px rgba(0, 240, 255, 0.4);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 6px;
height: 11px;
border: 2px solid var(--bg-primary);
border-top: none;
border-left: none;
transform: rotate(45deg);
}
/* Select β€” 28px tall hit area */
select {
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--bg-panel-border);
border-radius: 4px;
padding: 6px 8px;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
width: 100%;
min-height: 28px;
transition: border-color var(--transition-speed), background var(--transition-speed);
}
select:hover { border-color: rgba(0, 240, 255, 0.4); background: var(--bg-input-hover); }
select:focus-visible { border-color: var(--text-accent); outline: none; box-shadow: 0 0 0 2px rgba(0, 240, 255, 0.3); }
select:disabled { opacity: 0.5; cursor: not-allowed; }
select option { background: #0a0a1a; color: var(--text-primary); }
select optgroup { color: var(--text-accent); font-style: normal; font-weight: 700; }
/* Buttons β€” generic .btn covers all action buttons */
.btn {
flex: 1;
background: var(--bg-input);
border: 1px solid var(--bg-panel-border);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 10px;
min-height: 28px;
cursor: pointer;
transition: border-color var(--transition-speed), color var(--transition-speed), background var(--transition-speed);
}
.btn:hover {
border-color: rgba(0, 240, 255, 0.5);
background: var(--bg-input-hover);
color: var(--text-accent);
}
.btn:focus-visible {
outline: 2px solid var(--text-accent-strong);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn--compact { padding: 4px 10px; font-size: 11px; }
.btn--toggle[aria-pressed="true"] {
background: rgba(0, 240, 255, 0.18);
border-color: var(--text-accent);
color: var(--text-accent);
box-shadow: inset 0 0 0 1px rgba(0, 240, 255, 0.4);
}
.btn-row {
display: flex;
gap: 6px;
align-items: stretch;
}
.btn-row > .btn,
.btn-row > select {
min-height: 30px;
}
.divider {
border: none;
border-top: 1px solid var(--bg-panel-border);
margin: 0;
}
/* Colormap legend */
#colormap-legend {
margin-top: 6px;
}
#legend-canvas {
width: 100%;
height: 14px;
border-radius: 2px;
display: block;
}
.legend-ticks {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-muted);
margin-top: 3px;
font-variant-numeric: tabular-nums;
}
.legend-ticks span {
flex: 1;
text-align: center;
}
.legend-ticks span:first-child { text-align: left; }
.legend-ticks span:last-child { text-align: right; }
/* Slice axis block */
.slice-axis {
display: flex;
flex-direction: column;
gap: 4px;
}
.slice-axis-header {
display: flex;
align-items: center;
gap: 8px;
}
.slice-axis-header label {
color: var(--text-primary);
flex: 1;
}
.slice-axis-header .value-label {
color: var(--text-muted);
min-width: 44px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.slice-axis input[type="range"] {
margin-left: 26px;
}
/* Time-filter warning */
.filter-warning {
font-size: 10px;
color: var(--color-negative);
margin-top: 2px;
min-height: 12px;
}
/* Stats */
#stats {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--bg-panel-border);
border-radius: 4px;
padding: 8px 10px;
line-height: 1.55;
color: var(--text-primary);
}
#stats span {
color: var(--text-accent);
font-weight: 600;
text-shadow: 0 0 6px rgba(0, 240, 255, 0.3);
}
/* Octant button labels: + cyan, - magenta */
.octant-sign-pos { color: var(--color-positive); font-weight: 700; }
.octant-sign-neg { color: var(--color-negative); font-weight: 700; }
/* Loading overlay */
#loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-primary);
z-index: 100;
gap: 16px;
transition: opacity 0.4s;
}
#loading.hidden {
opacity: 0;
pointer-events: none;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0, 240, 255, 0.15);
border-top-color: var(--text-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
box-shadow: 0 0 12px rgba(0, 240, 255, 0.2);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#loading-text {
color: var(--text-muted);
font-size: 13px;
}
/* Info badge (demo mode) */
#demo-badge {
position: absolute;
bottom: 12px;
right: 12px;
background: rgba(0, 240, 255, 0.1);
border: 1px solid rgba(0, 240, 255, 0.3);
border-radius: 4px;
padding: 5px 10px;
font-size: 11px;
color: var(--text-accent);
text-shadow: 0 0 6px rgba(0, 240, 255, 0.3);
display: none;
z-index: 50;
}
/* Floating tooltip portal (positioned via JS, lives on body) */
#tooltip-portal {
position: fixed;
pointer-events: none;
padding: 6px 9px;
background: rgba(10, 10, 20, 0.97);
border: 1px solid rgba(0, 240, 255, 0.45);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.35;
max-width: 240px;
white-space: normal;
opacity: 0;
transition: opacity 0.18s ease;
z-index: 9000;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.18);
}
#tooltip-portal.visible {
opacity: 1;
}
/* Visually-hidden utility β€” accessible to screen readers but not visible */
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
</style>
</head>
<body class="scanlines">
<div id="canvas-container"></div>
<div id="loading">
<div class="spinner"></div>
<div id="loading-text">Loading simulation data...</div>
</div>
<div id="controls">
<button type="button" id="controls-header" aria-expanded="true" aria-controls="controls-body">
<span id="controls-title">Triple Pendulum</span>
<span id="collapse-icon" aria-hidden="true">&#9660;</span>
</button>
<div id="controls-body">
<!-- Stats -->
<div id="stats" aria-live="polite">Loading...</div>
<!-- Reset section -->
<div class="section">
<div class="section-label">Reset</div>
<div class="btn-row">
<button type="button" class="btn" id="reset-view" data-tooltip="Reset camera position and zoom">View</button>
<button type="button" class="btn" id="reset-filters" data-tooltip="Restore default time, slice, and octant filters">Filters</button>
</div>
</div>
<!-- Save section -->
<div class="section">
<div class="section-label">Save</div>
<div class="btn-row" data-tooltip="Save current view as a 1:1 square image with embedded settings">
<button type="button" class="btn" id="export-png">PNG</button>
<button type="button" class="btn" id="export-svg">SVG</button>
<label class="visually-hidden" for="export-size">Output size</label>
<select id="export-size" style="flex:0 0 80px" data-tooltip="Output dimensions (square pixels)">
<option value="1024">1024</option>
<option value="2048" selected>2048</option>
<option value="4096">4096</option>
</select>
</div>
</div>
<!-- Dataset -->
<div class="section">
<div class="section-label">Dataset</div>
<div class="control-row" data-tooltip="Grid geometry: Cube (uniform Cartesian) or Sphere (Fibonacci spiral shells)">
<label for="realm-select">Realm</label>
<select id="realm-select" style="flex:1">
<option value="cube" selected>Cube</option>
<option value="sphere">Sphere</option>
</select>
</div>
<div class="control-row" data-tooltip="Simulation engine: GPU (CuPy CUDA, all resolutions) or CPU (Numba, up to 200)">
<label for="backend-select">Backend</label>
<select id="backend-select" style="flex:1">
<option value="cpu">CPU</option>
<option value="gpu" selected>GPU</option>
</select>
</div>
<div class="control-row" data-tooltip="Grid points per axis. Higher = more detail and memory">
<label for="resolution-select">Resolution</label>
<select id="resolution-select" style="flex:1"></select>
</div>
</div>
<!-- Colormap -->
<div class="section">
<div class="section-label">Colormap</div>
<div class="control-row" data-tooltip="Color mapping for flip-time values. Invert reverses the direction">
<label class="visually-hidden" for="colormap-select">Colormap</label>
<select id="colormap-select" style="flex:1">
<option value="cividis" selected>Cividis (CVD-safe)</option>
<option value="viridis">Viridis (CVD-safe)</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="magma">Magma</option>
<option value="inferno">Inferno</option>
<option value="plasma">Plasma</option>
</select>
<button type="button" class="btn btn--toggle btn--compact" id="invert-btn" style="flex:0 0 auto" aria-pressed="false" aria-label="Invert colormap" data-tooltip="Invert colormap direction">Inv</button>
</div>
<div id="colormap-legend">
<canvas id="legend-canvas" width="256" height="14" aria-label="Colormap gradient"></canvas>
<div class="legend-ticks" id="legend-ticks">
<span>0 s</span>
<span>β€”</span>
<span>tMax</span>
</div>
</div>
</div>
<!-- Appearance -->
<div class="section">
<div class="section-label">Appearance</div>
<div class="control-row" data-tooltip="Rendered point scale (log-scaled for fine control at small sizes)">
<label for="point-size">Point size</label>
<input type="range" id="point-size" min="0" max="1" step="0.005" value="0.20" />
<span class="value-label" id="point-size-val">1.0</span>
</div>
<div class="control-row" data-tooltip="Alpha per point (log-scaled). Low values reveal internal structure via additive blending">
<label for="opacity">Opacity</label>
<input type="range" id="opacity" min="0" max="1" step="0.005" value="0.35" />
<span class="value-label" id="opacity-val">0.050</span>
</div>
</div>
<!-- Display -->
<div class="section">
<div class="section-label">Display</div>
<div class="control-row" data-tooltip="Toggle the X/Y/Z axis indicator lines">
<label for="show-axes">Show axes</label>
<input type="checkbox" id="show-axes" checked />
</div>
<div class="control-row" data-tooltip="Show wireframe grid outline (cube or sphere boundary)">
<label for="show-grid">Show grid</label>
<input type="checkbox" id="show-grid" />
</div>
<div class="control-row" data-tooltip="Continuously rotate the camera around the center">
<label for="auto-rotate">Auto-rotate</label>
<input type="checkbox" id="auto-rotate" />
</div>
<div class="control-row" data-tooltip="Speed of automatic camera rotation (only active when auto-rotate is on)">
<label for="rotate-speed">Rot. speed</label>
<input type="range" id="rotate-speed" min="0.1" max="5" step="0.1" value="1" disabled />
<span class="value-label" id="rotate-speed-val">1.0</span>
</div>
<div class="control-row" data-tooltip="Toggle CRT scanline overlay effect">
<label for="scanlines-toggle">Scanlines</label>
<input type="checkbox" id="scanlines-toggle" checked />
</div>
</div>
<!-- Flip time filter -->
<div class="section">
<div class="section-label">Flip time filter</div>
<div class="control-row" data-tooltip="Hide points that flipped before this time">
<label for="time-min">Min</label>
<input type="range" id="time-min" min="0" max="1" step="0.01" value="0" />
<span class="value-label" id="time-min-val">0.0 s</span>
</div>
<div class="control-row" data-tooltip="Hide points that flipped after this time">
<label for="time-max">Max</label>
<input type="range" id="time-max" min="0" max="1" step="0.01" value="1" />
<span class="value-label" id="time-max-val">-- s</span>
</div>
<div class="filter-warning" id="time-warning" role="status" aria-live="polite"></div>
<div class="control-row" data-tooltip="Show pendulums that never flipped within simulation time">
<label for="show-stable">Show stable</label>
<input type="checkbox" id="show-stable" />
</div>
</div>
<!-- Slice controls (cube realm) -->
<div class="section" id="cube-slice-controls">
<div class="section-label">Slice controls</div>
<div class="control-row" data-tooltip="Half-width of the slice slab in degrees. At 170 the full cube is visible">
<label for="slice-thickness">Thickness</label>
<input type="range" id="slice-thickness" min="1" max="170" step="1" value="5" />
<span class="value-label" id="slice-thickness-val">5&deg;</span>
</div>
<div class="slice-axis">
<div class="slice-axis-header" data-tooltip="Fix first pendulum angle to see a 2D cross-section">
<input type="checkbox" id="slice-x-en" />
<label for="slice-x-en">&#952;&#8321; slice</label>
<span class="value-label" id="slice-x-val">0&deg;</span>
</div>
<input type="range" id="slice-x" min="-170" max="170" step="1" value="0" aria-label="Theta 1 slice angle" />
</div>
<div class="slice-axis">
<div class="slice-axis-header" data-tooltip="Fix second pendulum angle to see a 2D cross-section">
<input type="checkbox" id="slice-y-en" />
<label for="slice-y-en">&#952;&#8322; slice</label>
<span class="value-label" id="slice-y-val">0&deg;</span>
</div>
<input type="range" id="slice-y" min="-170" max="170" step="1" value="0" aria-label="Theta 2 slice angle" />
</div>
<div class="slice-axis">
<div class="slice-axis-header" data-tooltip="Fix third pendulum angle to see a 2D cross-section">
<input type="checkbox" id="slice-z-en" />
<label for="slice-z-en">&#952;&#8323; slice</label>
<span class="value-label" id="slice-z-val">0&deg;</span>
</div>
<input type="range" id="slice-z" min="-170" max="170" step="1" value="0" aria-label="Theta 3 slice angle" />
</div>
</div>
<!-- Shell slice controls (sphere realm) -->
<div class="section" id="sphere-slice-controls" style="display:none">
<div class="section-label">Shell slice</div>
<div class="slice-axis">
<div class="slice-axis-header" data-tooltip="Enable radial shell filter for sphere realm">
<input type="checkbox" id="shell-slice-en" />
<label for="shell-slice-en">r shell</label>
<span class="value-label" id="shell-radius-val">85&deg;</span>
</div>
<input type="range" id="shell-radius" min="0" max="170" step="1" value="85" aria-label="Shell radius" />
</div>
<div class="control-row" data-tooltip="Half-width of the shell band. At 170 the full sphere is visible">
<label for="shell-thickness">Thickness</label>
<input type="range" id="shell-thickness" min="1" max="170" step="1" value="10" />
<span class="value-label" id="shell-thickness-val">10&deg;</span>
</div>
</div>
<!-- Octant controls -->
<div class="section">
<div class="section-label" data-tooltip="Sign-combination octants of (sign θ₁, sign ΞΈβ‚‚, sign θ₃)">Octant controls</div>
<div class="btn-row">
<button type="button" class="btn btn--compact" id="octants-show-all">Show all</button>
<button type="button" class="btn btn--compact" id="octants-hide-all">Hide all</button>
</div>
<div id="octant-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:6px 12px"></div>
</div>
</div><!-- end controls-body -->
</div><!-- end controls -->
<div id="demo-badge">DEMO MODE β€” random data</div>
<div id="tooltip-portal" role="tooltip"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.1/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ─── State ────────────────────────────────────────────────────────────────────
const state = {
gridSize: 0,
thetaRange: [-170, 170],
flipTimes: null, // Float32Array, length totalPoints, NaN = never flipped
tMax: 0,
pointCount: 0,
currentColormap: 'cividis',
invertColormap: false,
// Realm
realm: 'cube', // 'cube' or 'sphere'
gridParams: null, // sphere metadata (resolution, r_max, num_shells, ...)
// Three.js objects
scene: null,
camera: null,
renderer: null,
controls: null,
points: null,
geometry: null,
realmOverlay: null, // sphere wireframe overlay
// Per-point cache (computed once from raw data)
positions: null, // Float32Array totalPoints * 3 (sphere only; cube uses shader)
isStable: null, // Float32Array totalPoints (0.0 or 1.0, for GPU attribute)
// Render-on-demand
needsRender: true, // flag to request a frame redraw
};
// ─── Stats Web Worker ────────────────────────────────────────────────────────
// Inline Web Worker for countVisiblePoints β€” runs off main thread to avoid jank.
const statsWorkerBlob = new Blob([`
"use strict";
let flipTimes = null;
let isStable = null;
let positions = null; // Only used for sphere grids
let gridSize = 0;
let isCubeGrid = false;
self.onmessage = function(e) {
const msg = e.data;
if (msg.type === 'init') {
flipTimes = new Float32Array(msg.flipTimes);
isStable = new Float32Array(msg.isStable);
positions = msg.positions ? new Float32Array(msg.positions) : null;
gridSize = msg.gridSize || 0;
isCubeGrid = msg.isCubeGrid || false;
return;
}
if (msg.type === 'count') {
if (!flipTimes) return; // not yet initialized
const { totalPoints, isSphere, timeMin, timeMax, showStable,
thetaMin, thetaSpan, sliceEn, sliceCtr, thickness,
shellSliceEn, shellRadius, shellThick, rMax, maskArr } = msg;
let showing = 0;
let totalFlipped = 0;
const gs = gridSize;
const gsm1 = gs > 1 ? gs - 1 : 1;
for (let idx = 0; idx < totalPoints; idx++) {
const stable = isStable[idx] > 0.5;
const ft = flipTimes[idx];
if (!stable) totalFlipped++;
let visible = true;
// Compute position: cube from index, sphere from positions array
let nx, ny, nz;
if (isCubeGrid) {
const iz = (idx / (gs * gs)) | 0;
const rem = idx - iz * gs * gs;
const iy = (rem / gs) | 0;
const ix = rem - iy * gs;
nx = ix / gsm1 * 2 - 1;
ny = iy / gsm1 * 2 - 1;
nz = iz / gsm1 * 2 - 1;
} else {
nx = positions ? positions[idx * 3] : 0;
ny = positions ? positions[idx * 3 + 1] : 0;
nz = positions ? positions[idx * 3 + 2] : 0;
}
const octantIndex = (nx >= 0 ? 0 : 4) + (ny >= 0 ? 0 : 2) + (nz >= 0 ? 0 : 1);
if (maskArr[octantIndex] < 0.5) visible = false;
// Spatial
if (visible && isSphere) {
if (shellSliceEn) {
const r = Math.sqrt(nx * nx + ny * ny + nz * nz) * rMax;
if (Math.abs(r - shellRadius) > shellThick) visible = false;
}
} else if (visible && !isSphere) {
const t1 = thetaMin + (nx + 1) / 2 * thetaSpan;
const t2 = thetaMin + (ny + 1) / 2 * thetaSpan;
const t3 = thetaMin + (nz + 1) / 2 * thetaSpan;
if (sliceEn[0] > 0.5 && Math.abs(t1 - sliceCtr[0]) > thickness) visible = false;
if (visible && sliceEn[1] > 0.5 && Math.abs(t2 - sliceCtr[1]) > thickness) visible = false;
if (visible && sliceEn[2] > 0.5 && Math.abs(t3 - sliceCtr[2]) > thickness) visible = false;
}
// Time + stability
if (visible) {
if (stable) {
if (!showStable) visible = false;
} else {
if (ft < timeMin || ft > timeMax) visible = false;
}
}
if (visible) showing++;
}
self.postMessage({ showing, total: totalPoints, totalFlipped });
}
};
`], { type: 'application/javascript' });
const statsWorkerUrl = URL.createObjectURL(statsWorkerBlob);
const statsWorker = new Worker(statsWorkerUrl);
// ─── Colormaps ───────────────────────────────────────────────────────────────
const COLORMAPS = {
cyberpunk: [
[0.000, [0.040, 0.000, 0.080]],
[0.150, [0.000, 0.120, 0.300]],
[0.350, [0.000, 0.940, 1.000]],
[0.550, [1.000, 0.330, 0.870]],
[0.750, [1.000, 0.867, 0.000]],
[1.000, [1.000, 1.000, 1.000]],
],
magma: [
[0.000, [0.001, 0.000, 0.014]],
[0.130, [0.172, 0.005, 0.200]],
[0.280, [0.460, 0.040, 0.310]],
[0.450, [0.750, 0.100, 0.140]],
[0.620, [0.950, 0.380, 0.060]],
[0.780, [0.990, 0.660, 0.200]],
[0.900, [1.000, 0.880, 0.500]],
[1.000, [1.000, 1.000, 1.000]],
],
viridis: [
[0.000, [0.267, 0.004, 0.329]],
[0.125, [0.282, 0.141, 0.458]],
[0.250, [0.254, 0.265, 0.530]],
[0.375, [0.206, 0.372, 0.553]],
[0.500, [0.128, 0.566, 0.551]],
[0.625, [0.120, 0.620, 0.536]],
[0.750, [0.210, 0.718, 0.472]],
[0.875, [0.565, 0.811, 0.314]],
[1.000, [0.993, 0.906, 0.144]],
],
inferno: [
[0.000, [0.001, 0.000, 0.014]],
[0.140, [0.190, 0.015, 0.260]],
[0.300, [0.500, 0.030, 0.340]],
[0.460, [0.760, 0.150, 0.170]],
[0.620, [0.940, 0.370, 0.020]],
[0.780, [0.990, 0.650, 0.040]],
[0.900, [0.990, 0.890, 0.300]],
[1.000, [0.988, 0.998, 0.645]],
],
plasma: [
[0.000, [0.050, 0.030, 0.530]],
[0.140, [0.290, 0.010, 0.610]],
[0.300, [0.520, 0.030, 0.560]],
[0.460, [0.720, 0.120, 0.430]],
[0.620, [0.900, 0.270, 0.250]],
[0.780, [0.990, 0.490, 0.080]],
[0.900, [0.990, 0.720, 0.030]],
[1.000, [0.940, 0.975, 0.131]],
],
cividis: [
[0.000, [0.000, 0.133, 0.302]],
[0.167, [0.153, 0.208, 0.380]],
[0.333, [0.337, 0.318, 0.380]],
[0.500, [0.478, 0.443, 0.380]],
[0.667, [0.616, 0.576, 0.349]],
[0.833, [0.769, 0.722, 0.290]],
[1.000, [0.996, 0.878, 0.216]],
],
};
function sampleColormap(t, mapName) {
t = Math.max(0, Math.min(1, t));
if (state.invertColormap) t = 1 - t;
const stops = COLORMAPS[mapName] || COLORMAPS.cyberpunk;
let lo = stops[0], hi = stops[stops.length - 1];
for (let i = 1; i < stops.length; i++) {
if (t <= stops[i][0]) {
hi = stops[i];
lo = stops[i - 1];
break;
}
}
const span = hi[0] - lo[0];
const f = span > 0 ? (t - lo[0]) / span : 0;
return [
lo[1][0] + f * (hi[1][0] - lo[1][0]),
lo[1][1] + f * (hi[1][1] - lo[1][1]),
lo[1][2] + f * (hi[1][2] - lo[1][2]),
];
}
function generateColormapTexture(mapName) {
const width = 256;
const data = new Uint8Array(width * 4);
const savedInvert = state.invertColormap;
state.invertColormap = false; // texture is always non-inverted; shader handles inversion
for (let i = 0; i < width; i++) {
const t = i / (width - 1);
const [r, g, b] = sampleColormap(t, mapName);
data[i * 4 + 0] = Math.round(r * 255);
data[i * 4 + 1] = Math.round(g * 255);
data[i * 4 + 2] = Math.round(b * 255);
data[i * 4 + 3] = 255;
}
state.invertColormap = savedInvert;
const texture = new THREE.DataTexture(data, width, 1, THREE.RGBAFormat);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.needsUpdate = true;
return texture;
}
function drawLegend() {
const canvas = document.getElementById('legend-canvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
for (let x = 0; x < width; x++) {
const tNorm = x / (width - 1);
const t = state.invertColormap ? 1 - tNorm : tNorm;
const rgb = sampleColormap(t, state.currentColormap);
ctx.fillStyle = `rgb(${Math.round(rgb[0]*255)},${Math.round(rgb[1]*255)},${Math.round(rgb[2]*255)})`;
ctx.fillRect(x, 0, 1, height);
}
// Update numeric ticks (0, mid, tMax)
const ticks = document.getElementById('legend-ticks');
if (ticks) {
const tMax = state.tMax;
const fmt = (v) => tMax > 0 ? v.toFixed(1) + ' s' : 'β€”';
ticks.children[0].textContent = tMax > 0 ? '0.0 s' : 'fast';
ticks.children[1].textContent = tMax > 0 ? fmt(tMax / 2) : 'mid';
ticks.children[2].textContent = tMax > 0 ? fmt(tMax) + ' / stable' : 'stable';
}
}
// ─── Data loading ─────────────────────────────────────────────────────────────
// Base URL prefix for simulation data files.
// Local dev: files are in data/ subdirectory relative to viewer.
// HF Spaces: data lives in a separate HF dataset repo.
const DATA_BASE_URL = window.location.hostname.endsWith('.hf.space')
? 'https://huggingface.co/datasets/pjt222/triple-pendulum-data/resolve/main/'
: 'data/';
// Dataset manifest: realm -> backend -> [{resolution, file, label, totalPoints?}]
// All datasets use binary format (.bin + .meta.json) for fast loading.
// Resolution grid: 10-100 by 10, 125-200 by 25, 300-500 by 100.
// GPU: all 17 resolutions. CPU: 10-200 (14 resolutions).
const DATASETS = {
cube: {
cpu: [
{ resolution: 200, file: 'simulation_200_cpu.bin', label: '200\u00B3 (8.0M pts)' },
{ resolution: 175, file: 'simulation_175_cpu.bin', label: '175\u00B3 (5.4M pts)' },
{ resolution: 150, file: 'simulation_150_cpu.bin', label: '150\u00B3 (3.4M pts)' },
{ resolution: 125, file: 'simulation_125_cpu.bin', label: '125\u00B3 (2.0M pts)' },
{ resolution: 100, file: 'simulation_100_cpu.bin', label: '100\u00B3 (1.0M pts)' },
{ resolution: 90, file: 'simulation_90_cpu.bin', label: '90\u00B3 (729K pts)' },
{ resolution: 80, file: 'simulation_80_cpu.bin', label: '80\u00B3 (512K pts)' },
{ resolution: 70, file: 'simulation_70_cpu.bin', label: '70\u00B3 (343K pts)' },
{ resolution: 60, file: 'simulation_60_cpu.bin', label: '60\u00B3 (216K pts)' },
{ resolution: 50, file: 'simulation_50_cpu.bin', label: '50\u00B3 (125K pts)' },
{ resolution: 40, file: 'simulation_40_cpu.bin', label: '40\u00B3 (64K pts)' },
{ resolution: 30, file: 'simulation_30_cpu.bin', label: '30\u00B3 (27K pts)' },
{ resolution: 20, file: 'simulation_20_cpu.bin', label: '20\u00B3 (8K pts)' },
{ resolution: 10, file: 'simulation_10_cpu.bin', label: '10\u00B3 (1K pts)' },
],
gpu: [
{ resolution: 500, file: 'simulation_500_gpu.bin', label: '500\u00B3 (125M pts)' },
{ resolution: 400, file: 'simulation_400_gpu.bin', label: '400\u00B3 (64M pts)' },
{ resolution: 300, file: 'simulation_300_gpu.bin', label: '300\u00B3 (27M pts)' },
{ resolution: 200, file: 'simulation_200_gpu.bin', label: '200\u00B3 (8.0M pts)' },
{ resolution: 175, file: 'simulation_175_gpu.bin', label: '175\u00B3 (5.4M pts)' },
{ resolution: 150, file: 'simulation_150_gpu.bin', label: '150\u00B3 (3.4M pts)' },
{ resolution: 125, file: 'simulation_125_gpu.bin', label: '125\u00B3 (2.0M pts)' },
{ resolution: 100, file: 'simulation_100_gpu.bin', label: '100\u00B3 (1.0M pts)' },
{ resolution: 90, file: 'simulation_90_gpu.bin', label: '90\u00B3 (729K pts)' },
{ resolution: 80, file: 'simulation_80_gpu.bin', label: '80\u00B3 (512K pts)' },
{ resolution: 70, file: 'simulation_70_gpu.bin', label: '70\u00B3 (343K pts)' },
{ resolution: 60, file: 'simulation_60_gpu.bin', label: '60\u00B3 (216K pts)' },
{ resolution: 50, file: 'simulation_50_gpu.bin', label: '50\u00B3 (125K pts)' },
{ resolution: 40, file: 'simulation_40_gpu.bin', label: '40\u00B3 (64K pts)' },
{ resolution: 30, file: 'simulation_30_gpu.bin', label: '30\u00B3 (27K pts)' },
{ resolution: 20, file: 'simulation_20_gpu.bin', label: '20\u00B3 (8K pts)' },
{ resolution: 10, file: 'simulation_10_gpu.bin', label: '10\u00B3 (1K pts)' },
],
},
sphere: {
cpu: [
{ resolution: 200, file: 'simulation_200_sphere_cpu.bin', label: 'Sphere 200 (~4.2M pts)', totalPoints: 4220273 },
{ resolution: 175, file: 'simulation_175_sphere_cpu.bin', label: 'Sphere 175 (~2.8M pts)', totalPoints: 2806162 },
{ resolution: 150, file: 'simulation_150_sphere_cpu.bin', label: 'Sphere 150 (~1.8M pts)', totalPoints: 1784864 },
{ resolution: 125, file: 'simulation_125_sphere_cpu.bin', label: 'Sphere 125 (~1.0M pts)', totalPoints: 1022654 },
{ resolution: 100, file: 'simulation_100_sphere_cpu.bin', label: 'Sphere 100 (~531K pts)', totalPoints: 531479 },
{ resolution: 90, file: 'simulation_90_sphere_cpu.bin', label: 'Sphere 90 (~388K pts)', totalPoints: 388074 },
{ resolution: 80, file: 'simulation_80_sphere_cpu.bin', label: 'Sphere 80 (~273K pts)', totalPoints: 273126 },
{ resolution: 70, file: 'simulation_70_sphere_cpu.bin', label: 'Sphere 70 (~183K pts)', totalPoints: 183465 },
{ resolution: 60, file: 'simulation_60_sphere_cpu.bin', label: 'Sphere 60 (~116K pts)', totalPoints: 115945 },
{ resolution: 50, file: 'simulation_50_sphere_cpu.bin', label: 'Sphere 50 (~67K pts)', totalPoints: 67427 },
{ resolution: 40, file: 'simulation_40_sphere_cpu.bin', label: 'Sphere 40 (~35K pts)', totalPoints: 34775 },
{ resolution: 30, file: 'simulation_30_sphere_cpu.bin', label: 'Sphere 30 (~15K pts)', totalPoints: 14855 },
{ resolution: 20, file: 'simulation_20_sphere_cpu.bin', label: 'Sphere 20 (~5K pts)', totalPoints: 4507 },
{ resolution: 10, file: 'simulation_10_sphere_cpu.bin', label: 'Sphere 10 (~524 pts)', totalPoints: 524 },
],
gpu: [
{ resolution: 500, file: 'simulation_500_sphere_gpu.bin', label: 'Sphere 500 (~65.6M pts)', totalPoints: 65646310 },
{ resolution: 400, file: 'simulation_400_sphere_gpu.bin', label: 'Sphere 400 (~33.6M pts)', totalPoints: 33636032 },
{ resolution: 300, file: 'simulation_300_sphere_gpu.bin', label: 'Sphere 300 (~14.2M pts)', totalPoints: 14207963 },
{ resolution: 200, file: 'simulation_200_sphere_gpu.bin', label: 'Sphere 200 (~4.2M pts)', totalPoints: 4220273 },
{ resolution: 175, file: 'simulation_175_sphere_gpu.bin', label: 'Sphere 175 (~2.8M pts)', totalPoints: 2806162 },
{ resolution: 150, file: 'simulation_150_sphere_gpu.bin', label: 'Sphere 150 (~1.8M pts)', totalPoints: 1784864 },
{ resolution: 125, file: 'simulation_125_sphere_gpu.bin', label: 'Sphere 125 (~1.0M pts)', totalPoints: 1022654 },
{ resolution: 100, file: 'simulation_100_sphere_gpu.bin', label: 'Sphere 100 (~531K pts)', totalPoints: 531479 },
{ resolution: 90, file: 'simulation_90_sphere_gpu.bin', label: 'Sphere 90 (~388K pts)', totalPoints: 388074 },
{ resolution: 80, file: 'simulation_80_sphere_gpu.bin', label: 'Sphere 80 (~273K pts)', totalPoints: 273126 },
{ resolution: 70, file: 'simulation_70_sphere_gpu.bin', label: 'Sphere 70 (~183K pts)', totalPoints: 183465 },
{ resolution: 60, file: 'simulation_60_sphere_gpu.bin', label: 'Sphere 60 (~116K pts)', totalPoints: 115945 },
{ resolution: 50, file: 'simulation_50_sphere_gpu.bin', label: 'Sphere 50 (~67K pts)', totalPoints: 67427 },
{ resolution: 40, file: 'simulation_40_sphere_gpu.bin', label: 'Sphere 40 (~35K pts)', totalPoints: 34775 },
{ resolution: 30, file: 'simulation_30_sphere_gpu.bin', label: 'Sphere 30 (~15K pts)', totalPoints: 14855 },
{ resolution: 20, file: 'simulation_20_sphere_gpu.bin', label: 'Sphere 20 (~5K pts)', totalPoints: 4507 },
{ resolution: 10, file: 'simulation_10_sphere_gpu.bin', label: 'Sphere 10 (~524 pts)', totalPoints: 524 },
],
},
};
function getDatasetEntries(realm, backend) {
return (DATASETS[realm] || {})[backend] || [];
}
function populateResolutionSelect(realm, backend, defaultResolution) {
const select = document.getElementById('resolution-select');
const entries = getDatasetEntries(realm, backend);
select.innerHTML = '';
// Order entries low β†’ high resolution for grouping
const ordered = entries
.map((e, i) => ({ e, i }))
.sort((a, b) => a.e.resolution - b.e.resolution);
const groups = [
{ label: 'Coarse (≀ 100)', max: 100 },
{ label: 'Medium (125–200)', max: 200 },
{ label: 'Fine (300+)', max: Infinity },
];
const groupEls = groups.map(g => {
const og = document.createElement('optgroup');
og.label = g.label;
return og;
});
let defaultIdx = ordered.length ? ordered[ordered.length - 1].i : 0;
for (const { e, i } of ordered) {
const option = document.createElement('option');
option.value = i;
option.textContent = e.label;
const groupIdx = groups.findIndex(g => e.resolution <= g.max);
groupEls[groupIdx].appendChild(option);
if (defaultResolution && e.resolution === defaultResolution) defaultIdx = i;
}
for (const og of groupEls) {
if (og.children.length > 0) select.appendChild(og);
}
select.value = defaultIdx;
}
function getSelectedEntry() {
const realm = document.getElementById('realm-select').value;
const backend = document.getElementById('backend-select').value;
const idx = parseInt(document.getElementById('resolution-select').value);
const entries = getDatasetEntries(realm, backend);
return entries[idx] || null;
}
function getSelectedFile() {
const entry = getSelectedEntry();
return entry ? DATA_BASE_URL + entry.file : null;
}
// Max total points the browser can handle (geometry buffers + colors + filters).
// 400^3 = 64M points needs ~2.5GB of ArrayBuffers β€” near the limit for most browsers.
const MAX_BROWSER_POINTS = 70_000_000; // ~400^3
async function loadData() {
const dataPath = getSelectedFile();
const entry = getSelectedEntry();
if (!dataPath) {
console.warn('No dataset file available, using demo mode');
document.getElementById('demo-badge').style.display = 'block';
return generateDemoData(20);
}
// Check if resolution exceeds browser memory limits
const resolution = entry?.resolution || 0;
const totalPoints = entry?.totalPoints || resolution ** 3;
if (totalPoints > MAX_BROWSER_POINTS) {
console.warn(`Resolution ${resolution}^3 = ${(totalPoints/1e6).toFixed(0)}M points exceeds browser limit`);
document.getElementById('demo-badge').style.display = 'block';
document.getElementById('demo-badge').textContent =
`${resolution}\u00B3 too large for browser (${(totalPoints/1e6).toFixed(0)}M pts). Try \u2264400\u00B3.`;
return generateDemoData(20);
}
const isBinary = dataPath.endsWith('.bin');
try {
const loadingText = document.getElementById('loading-text');
if (resolution >= 200) {
loadingText.textContent += ' (large file, please wait...)';
}
if (isBinary) {
try {
return await loadBinaryData(dataPath);
} catch (binErr) {
// Fall back to JSON if binary not available (e.g. local dev).
console.warn('Binary load failed, trying JSON fallback:', binErr.message);
const jsonPath = dataPath.replace(/\.bin$/, '.json');
const res = await fetch(jsonPath);
if (!res.ok) throw new Error(`JSON fallback HTTP ${res.status}`);
const json = await res.json();
return parseSimData(json);
}
}
const res = await fetch(dataPath);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return parseSimData(json);
} catch (err) {
console.warn('Could not load simulation data, using demo mode:', err.message);
document.getElementById('demo-badge').style.display = 'block';
document.getElementById('loading-text').textContent = 'Generating demo data...';
return generateDemoData(20);
}
}
async function loadBinaryData(binPath) {
// Fetch the raw binary data and the metadata sidecar in parallel.
// Try gzip-compressed version first for smaller transfer size.
const metaPath = binPath + '.meta.json';
const [binRes, metaRes] = await Promise.all([
fetchBinary(binPath),
fetch(metaPath),
]);
if (!binRes.ok) throw new Error(`Binary HTTP ${binRes.status}`);
if (!metaRes.ok) throw new Error(`Meta HTTP ${metaRes.status}`);
const [arrayBuffer, meta] = await Promise.all([
binRes.arrayBuffer(),
metaRes.json(),
]);
// Wrap the ArrayBuffer directly as Float32Array β€” zero parsing.
const flipTimes = new Float32Array(arrayBuffer);
const gridType = meta.grid_type || 'cube';
const gridSize = meta.grid_size;
const thetaRange = meta.theta_range || [-170, 170];
const totalPoints = flipTimes.length;
// Use tMax from metadata if available (avoids O(n) scan).
// Fall back to scanning the data if not present in metadata.
let tMax = meta.metadata?.flip_time_max ?? meta.flip_time_max ?? 0;
if (tMax <= 0) {
for (let i = 0; i < totalPoints; i++) {
const v = flipTimes[i];
if (isFinite(v) && v > tMax) tMax = v;
}
}
return {
gridType,
gridSize,
gridParams: meta.grid_params || null,
thetaRange,
flipTimes,
tMax,
explicitPositions: null, // Binary format omits positions; viewer reconstructs.
};
}
// Fetch binary data with optional gzip decompression.
// Tries .bin.gz first (if DecompressionStream is available), falls back to .bin.
async function fetchBinary(binPath) {
if (typeof DecompressionStream !== 'undefined') {
try {
const gzRes = await fetch(binPath + '.gz');
if (gzRes.ok) {
const ds = new DecompressionStream('gzip');
const decompressedStream = gzRes.body.pipeThrough(ds);
return new Response(decompressedStream);
}
} catch (_) {
// .gz not available or decompression failed, fall back
}
}
return fetch(binPath);
}
function parseSimData(json) {
const gridType = json.grid_type || 'cube';
const gridSize = json.grid_size;
const thetaMin = json.theta_min !== undefined ? json.theta_min : (json.theta_range || [-170, 170])[0];
const thetaMax = json.theta_max !== undefined ? json.theta_max : (json.theta_range || [-170, 170])[1];
const thetaRange = [thetaMin, thetaMax];
// Determine total points: sphere uses total_points or flip_times length; cube uses grid_size^3.
const totalPoints = (gridType === 'sphere')
? (json.total_points || json.flip_times.length)
: gridSize ** 3;
const flipTimes = new Float32Array(totalPoints);
flipTimes.fill(NaN);
// Use precomputed tMax from metadata if available (avoids O(n) scan).
let tMax = json.metadata?.flip_time_max ?? 0;
if (json.flip_times) {
const raw = json.flip_times;
const count = Math.min(raw.length, totalPoints);
const needScan = (tMax <= 0);
for (let i = 0; i < count; i++) {
const v = raw[i];
flipTimes[i] = (v === null || v === undefined || !isFinite(v)) ? NaN : v;
if (needScan && isFinite(flipTimes[i]) && flipTimes[i] > tMax) tMax = flipTimes[i];
}
} else if (json.points) {
// Format B: sparse array of {theta: [t1,t2,t3], flip_time: f} (cube only)
const step = (thetaMax - thetaMin) / (gridSize - 1);
const needScan = (tMax <= 0);
for (const pt of json.points) {
const i0 = Math.round((pt.theta[0] - thetaMin) / step);
const i1 = Math.round((pt.theta[1] - thetaMin) / step);
const i2 = Math.round((pt.theta[2] - thetaMin) / step);
const idx = i0 * gridSize * gridSize + i1 * gridSize + i2;
if (idx >= 0 && idx < totalPoints) {
flipTimes[idx] = pt.flip_time;
if (needScan && pt.flip_time > tMax) tMax = pt.flip_time;
}
}
}
// Parse explicit positions (sphere realm).
let explicitPositions = null;
if (json.positions) {
const rawPos = json.positions;
explicitPositions = new Float32Array(totalPoints * 3);
for (let i = 0; i < totalPoints * 3; i++) {
explicitPositions[i] = rawPos[i];
}
}
return {
gridType,
gridSize,
gridParams: json.grid_params || null,
thetaRange,
flipTimes,
tMax,
explicitPositions,
};
}
function generateDemoData(gridSize) {
const n3 = gridSize ** 3;
const flipTimes = new Float32Array(n3);
const tMax = 15;
for (let i = 0; i < n3; i++) {
flipTimes[i] = Math.random() < 0.20 ? NaN : Math.random() * tMax;
}
return { gridSize, thetaRange: [-170, 170], flipTimes, tMax };
}
// ─── Three.js setup ───────────────────────────────────────────────────────────
function initThree() {
const container = document.getElementById('canvas-container');
state.scene = new THREE.Scene();
state.scene.background = new THREE.Color(0x0a0a0f);
state.camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.001, 100);
state.camera.position.set(2.2, 1.6, 2.2);
state.renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance', preserveDrawingBuffer: true });
state.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
state.renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(state.renderer.domElement);
state.controls = new OrbitControls(state.camera, state.renderer.domElement);
state.controls.enableDamping = true;
state.controls.dampingFactor = 0.08;
state.controls.minDistance = 0.5;
state.controls.maxDistance = 10;
// Render on demand: flag a redraw when the user interacts with the scene.
state.controls.addEventListener('change', () => { state.needsRender = true; });
// Subtle axis helper
state.axesHelper = new THREE.AxesHelper(1.2);
state.axesHelper.material.opacity = 0.35;
state.axesHelper.material.transparent = true;
state.axesHelper.visible = document.getElementById('show-axes').checked;
state.scene.add(state.axesHelper);
window.addEventListener('resize', onResize);
}
function onResize() {
const container = document.getElementById('canvas-container');
const w = container.clientWidth;
const h = container.clientHeight;
state.camera.aspect = w / h;
state.camera.updateProjectionMatrix();
state.renderer.setSize(w, h);
if (state.points) {
const pixelRatio = state.renderer.getPixelRatio();
const canvasHeight = state.renderer.domElement.height;
state.points.material.uniforms.sizeScale.value = 0.5 * canvasHeight * pixelRatio;
}
state.needsRender = true;
}
// ─── GLSL Shaders ─────────────────────────────────────────────────────────────
const VERT_SHADER = `
attribute float flipTime;
attribute float aIsStable;
uniform float pointScale;
uniform float sizeScale;
uniform float tMax;
// Filter: time
uniform vec2 timeRange;
uniform float showStable;
// Filter: cube slices
uniform vec3 sliceEnabled;
uniform vec3 sliceCenter;
uniform float sliceThickness;
uniform float thetaMin;
uniform float thetaSpan;
// Filter: sphere shell
uniform float isSphere;
uniform float shellSliceEnabled;
uniform float shellRadius;
uniform float shellThickness;
uniform float rMax;
// Cube position-from-index: position attribute holds vertex index (itemSize 1).
// When useCubeIndex > 0.5, 3D position is computed from position.x + gridSizeF.
uniform float useCubeIndex;
uniform float gridSizeF;
// Filter: octants (two vec4 to avoid dynamic array indexing in WebGL 1)
uniform vec4 octantMask0;
uniform vec4 octantMask1;
varying float vNormalizedTime;
varying float vIsStable;
varying float vVisible;
void main() {
float visible = 1.0;
float ft = flipTime;
float stable = aIsStable;
// Compute position: for cube grids, derive from flat index to save memory.
vec3 pos = position;
if (useCubeIndex > 0.5) {
float idx = position.x;
float gs = gridSizeF;
float iz = floor(idx / (gs * gs));
float rem = idx - iz * gs * gs;
float iy = floor(rem / gs);
float ix = rem - iy * gs;
pos = vec3(ix, iy, iz) / (gs - 1.0) * 2.0 - 1.0;
}
// Octant filtering
float sx = step(0.0, pos.x);
float sy = step(0.0, pos.y);
float sz = step(0.0, pos.z);
float oIdx = 4.0 * (1.0 - sx) + 2.0 * (1.0 - sy) + (1.0 - sz);
float omask = 1.0;
omask = mix(omask, octantMask0.x, step(abs(oIdx - 0.0), 0.5));
omask = mix(omask, octantMask0.y, step(abs(oIdx - 1.0), 0.5));
omask = mix(omask, octantMask0.z, step(abs(oIdx - 2.0), 0.5));
omask = mix(omask, octantMask0.w, step(abs(oIdx - 3.0), 0.5));
omask = mix(omask, octantMask1.x, step(abs(oIdx - 4.0), 0.5));
omask = mix(omask, octantMask1.y, step(abs(oIdx - 5.0), 0.5));
omask = mix(omask, octantMask1.z, step(abs(oIdx - 6.0), 0.5));
omask = mix(omask, octantMask1.w, step(abs(oIdx - 7.0), 0.5));
visible *= omask;
// Spatial filtering
// Sphere: shell slice
float r = length(pos) * rMax;
float shellVis = 1.0 - shellSliceEnabled + shellSliceEnabled * step(abs(r - shellRadius), shellThickness);
// Cube: axis slices
vec3 theta = vec3(thetaMin) + (pos + 1.0) / 2.0 * thetaSpan;
vec3 sliceDist = abs(theta - sliceCenter);
vec3 sliceVis = vec3(1.0) - sliceEnabled + sliceEnabled * step(sliceDist, vec3(sliceThickness));
float cubeVis = sliceVis.x * sliceVis.y * sliceVis.z;
visible *= mix(cubeVis, shellVis, isSphere);
// Time + stability filtering
float stableVis = stable * showStable + (1.0 - stable);
float inRange = step(timeRange.x, ft) * step(ft, timeRange.y);
float timeVis = stable + (1.0 - stable) * inRange;
visible *= stableVis * timeVis;
// Varyings
vNormalizedTime = tMax > 0.0 ? ft / tMax : 0.0;
vIsStable = stable;
vVisible = visible;
if (visible < 0.5) {
gl_Position = vec4(9999.0, 9999.0, 9999.0, 1.0);
gl_PointSize = 0.0;
} else {
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = pointScale * (sizeScale / -mvPosition.z);
}
}
`;
const FRAG_SHADER = `
uniform sampler2D colormapTexture;
uniform float uOpacity;
uniform float invertColormap;
varying float vNormalizedTime;
varying float vIsStable;
varying float vVisible;
void main() {
if (vVisible < 0.5) discard;
float t = clamp(vNormalizedTime, 0.0, 1.0);
t = mix(t, 0.98, vIsStable);
t = mix(t, 1.0 - t, invertColormap);
vec4 color = texture2D(colormapTexture, vec2(t, 0.5));
gl_FragColor = vec4(color.rgb, uOpacity);
}
`;
// ─── Point cloud construction ─────────────────────────────────────────────────
// Reconstruct sphere viewer positions from grid_params (binary format).
// Mirrors the Python make_sphere_grid() Fibonacci spiral algorithm.
function reconstructSpherePositions(positions, gridParams) {
const resolution = gridParams.resolution || gridParams.num_shells;
const pointsPerShell = gridParams.points_per_shell;
const goldenRatio = (1.0 + Math.sqrt(5.0)) / 2.0;
// Origin: single point at (0, 0, 0) β€” already zero-initialized.
let offset = 1;
for (let shellIdx = 0; shellIdx < resolution; shellIdx++) {
const shellFraction = (shellIdx + 1) / resolution;
const numPoints = pointsPerShell[shellIdx];
for (let i = 0; i < numPoints; i++) {
// Fibonacci sphere: uniform spacing via golden angle.
const phi = Math.acos(1.0 - 2.0 * (i + 0.5) / numPoints);
const theta = 2.0 * Math.PI * i / goldenRatio;
const sinPhi = Math.sin(phi);
positions[(offset + i) * 3 + 0] = sinPhi * Math.cos(theta) * shellFraction;
positions[(offset + i) * 3 + 1] = sinPhi * Math.sin(theta) * shellFraction;
positions[(offset + i) * 3 + 2] = Math.cos(phi) * shellFraction;
}
offset += numPoints;
}
}
function buildPointCloud(data) {
const { gridType, gridSize, gridParams, thetaRange, flipTimes, tMax, explicitPositions } = data;
Object.assign(state, { gridSize, thetaRange, flipTimes, tMax });
state.realm = gridType || 'cube';
state.gridParams = gridParams;
const totalPoints = flipTimes.length;
const isCube = (gridType !== 'sphere');
const useCubeIndex = isCube; // Cube: compute positions in shader from index
// For cube grids, skip the positions array entirely β€” shader computes from index.
// For sphere grids, we still need explicit positions (Fibonacci spiral).
let positions;
if (useCubeIndex) {
// Cube grids: single-component position = vertex index. Shader computes 3D pos.
// Saves 3x memory vs Float32Array(totalPoints * 3) β€” 256MB vs 768MB at 64M pts.
positions = new Float32Array(totalPoints);
for (let i = 0; i < totalPoints; i++) positions[i] = i;
} else if (explicitPositions) {
positions = new Float32Array(totalPoints * 3);
positions.set(explicitPositions);
} else if (gridParams) {
positions = new Float32Array(totalPoints * 3);
reconstructSpherePositions(positions, gridParams);
} else {
positions = new Float32Array(totalPoints * 3);
}
const flipTimeAttr = new Float32Array(totalPoints);
const isStableAttr = new Float32Array(totalPoints);
for (let idx = 0; idx < totalPoints; idx++) {
const ft = flipTimes[idx];
const stable = !isFinite(ft);
isStableAttr[idx] = stable ? 1.0 : 0.0;
flipTimeAttr[idx] = stable ? 0.0 : ft;
}
// For cube grids, the worker computes positions from index (no positions array needed).
// For sphere grids, pass the explicit positions to the worker.
state.positions = useCubeIndex ? null : positions;
state.isStable = isStableAttr;
state.pointCount = totalPoints;
// Send data to the stats worker via transferable buffers.
// We .slice(0) to copy (originals are used by state/GPU), then transfer
// the copies so the main-thread duplicates are detached immediately.
const ftBuf = flipTimes.buffer.slice(0);
const isBuf = isStableAttr.buffer.slice(0);
const posBuf = useCubeIndex ? null : positions.buffer.slice(0);
const transferList = [ftBuf, isBuf];
if (posBuf) transferList.push(posBuf);
statsWorker.postMessage({
type: 'init',
flipTimes: ftBuf,
isStable: isBuf,
positions: posBuf,
gridSize: gridSize,
isCubeGrid: useCubeIndex,
}, transferList);
// Build geometry with per-point scalar attributes (GPU does colormap + filtering)
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, useCubeIndex ? 1 : 3));
geometry.setAttribute('flipTime', new THREE.BufferAttribute(flipTimeAttr, 1));
geometry.setAttribute('aIsStable', new THREE.BufferAttribute(isStableAttr, 1));
const colormapTexture = generateColormapTexture(state.currentColormap);
const isSphere = state.realm === 'sphere';
const rMax = gridParams?.r_max || 170;
// Compute sizeScale to match Three.js PointsMaterial sizeAttenuation behavior
const pixelRatio = state.renderer.getPixelRatio();
const canvasHeight = state.renderer.domElement.height;
const sizeScale = 0.5 * canvasHeight * pixelRatio;
const material = new THREE.ShaderMaterial({
vertexShader: VERT_SHADER,
fragmentShader: FRAG_SHADER,
uniforms: {
colormapTexture: { value: colormapTexture },
uOpacity: { value: 0.05 },
pointScale: { value: 0.013 },
sizeScale: { value: sizeScale },
tMax: { value: tMax },
invertColormap: { value: state.invertColormap ? 1.0 : 0.0 },
timeRange: { value: new THREE.Vector2(0, tMax) },
showStable: { value: 1.0 },
sliceEnabled: { value: new THREE.Vector3(0, 0, 0) },
sliceCenter: { value: new THREE.Vector3(0, 0, 0) },
sliceThickness: { value: 5.0 },
thetaMin: { value: thetaRange[0] },
thetaSpan: { value: thetaRange[1] - thetaRange[0] },
isSphere: { value: isSphere ? 1.0 : 0.0 },
shellSliceEnabled: { value: 0.0 },
shellRadius: { value: 85.0 },
shellThickness: { value: 10.0 },
rMax: { value: rMax },
useCubeIndex: { value: useCubeIndex ? 1.0 : 0.0 },
gridSizeF: { value: parseFloat(gridSize) },
octantMask0: { value: new THREE.Vector4(1, 1, 1, 1) },
octantMask1: { value: new THREE.Vector4(1, 1, 1, 1) },
},
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
if (state.points) {
state.scene.remove(state.points);
state.geometry.dispose();
state.points.material.uniforms.colormapTexture.value.dispose();
state.points.material.dispose();
}
state.geometry = geometry;
state.points = new THREE.Points(geometry, material);
if (useCubeIndex) {
// itemSize 1 position produces wrong bounding sphere; disable frustum culling
// and pre-set sphere to prevent Three.js auto-computation.
state.points.frustumCulled = false;
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 2.0);
}
state.scene.add(state.points);
state.needsRender = true;
return geometry;
}
// ─── Filter & visibility ──────────────────────────────────────────────────────
// Debounce timer for stats counting on large datasets
let statsTimerId = null;
// Listen for stats results from the Web Worker.
statsWorker.onmessage = function(e) {
const { showing, total, totalFlipped } = e.data;
updateStats(showing, total, totalFlipped);
};
function applyFilters() {
if (!state.points) return;
const uniforms = state.points.material.uniforms;
const isSphere = state.realm === 'sphere';
// Time filter β†’ uniforms (instant)
const timeMinNorm = parseFloat(document.getElementById('time-min').value);
const timeMaxNorm = parseFloat(document.getElementById('time-max').value);
uniforms.timeRange.value.set(timeMinNorm * state.tMax, timeMaxNorm * state.tMax);
// Stable visibility
const showStable = document.getElementById('show-stable').checked;
uniforms.showStable.value = showStable ? 1.0 : 0.0;
// Cube slices
if (!isSphere) {
const sliceThickness = parseFloat(document.getElementById('slice-thickness').value);
const xEn = document.getElementById('slice-x-en').checked ? 1.0 : 0.0;
const yEn = document.getElementById('slice-y-en').checked ? 1.0 : 0.0;
const zEn = document.getElementById('slice-z-en').checked ? 1.0 : 0.0;
const xVal = parseFloat(document.getElementById('slice-x').value);
const yVal = parseFloat(document.getElementById('slice-y').value);
const zVal = parseFloat(document.getElementById('slice-z').value);
uniforms.sliceEnabled.value.set(xEn, yEn, zEn);
uniforms.sliceCenter.value.set(xVal, yVal, zVal);
uniforms.sliceThickness.value = sliceThickness;
}
// Sphere shell
if (isSphere) {
uniforms.shellSliceEnabled.value = document.getElementById('shell-slice-en').checked ? 1.0 : 0.0;
uniforms.shellRadius.value = parseFloat(document.getElementById('shell-radius').value);
uniforms.shellThickness.value = parseFloat(document.getElementById('shell-thickness').value);
}
// Octants
const mask0 = uniforms.octantMask0.value;
const mask1 = uniforms.octantMask1.value;
mask0.x = document.getElementById('octant-1').checked ? 1.0 : 0.0;
mask0.y = document.getElementById('octant-2').checked ? 1.0 : 0.0;
mask0.z = document.getElementById('octant-3').checked ? 1.0 : 0.0;
mask0.w = document.getElementById('octant-4').checked ? 1.0 : 0.0;
mask1.x = document.getElementById('octant-5').checked ? 1.0 : 0.0;
mask1.y = document.getElementById('octant-6').checked ? 1.0 : 0.0;
mask1.z = document.getElementById('octant-7').checked ? 1.0 : 0.0;
mask1.w = document.getElementById('octant-8').checked ? 1.0 : 0.0;
state.needsRender = true;
// Dispatch stats counting to the Web Worker (non-blocking).
if (statsTimerId) clearTimeout(statsTimerId);
statsTimerId = setTimeout(dispatchStatsCount, state.pointCount > 10_000_000 ? 200 : 16);
}
function dispatchStatsCount() {
if (!state.points) return;
const uniforms = state.points.material.uniforms;
const isSphere = state.realm === 'sphere';
statsWorker.postMessage({
type: 'count',
totalPoints: state.pointCount,
isSphere,
timeMin: uniforms.timeRange.value.x,
timeMax: uniforms.timeRange.value.y,
showStable: uniforms.showStable.value > 0.5,
thetaMin: state.thetaRange[0],
thetaSpan: state.thetaRange[1] - state.thetaRange[0],
sliceEn: [uniforms.sliceEnabled.value.x, uniforms.sliceEnabled.value.y, uniforms.sliceEnabled.value.z],
sliceCtr: [uniforms.sliceCenter.value.x, uniforms.sliceCenter.value.y, uniforms.sliceCenter.value.z],
thickness: uniforms.sliceThickness.value,
shellSliceEn: uniforms.shellSliceEnabled.value > 0.5,
shellRadius: uniforms.shellRadius.value,
shellThick: uniforms.shellThickness.value,
rMax: uniforms.rMax.value,
maskArr: [
uniforms.octantMask0.value.x, uniforms.octantMask0.value.y,
uniforms.octantMask0.value.z, uniforms.octantMask0.value.w,
uniforms.octantMask1.value.x, uniforms.octantMask1.value.y,
uniforms.octantMask1.value.z, uniforms.octantMask1.value.w,
],
});
}
function updateStats(showing, total, totalFlipped) {
const pct = total > 0 ? ((totalFlipped / total) * 100).toFixed(1) : '0.0';
document.getElementById('stats').innerHTML =
`Showing <span>${showing.toLocaleString()}</span> / ${total.toLocaleString()} points<br>` +
`<span>${pct}%</span> flipped`;
}
// ─── Dataset switching ───────────────────────────────────────────────────────
async function reloadDataset() {
const realm = document.getElementById('realm-select').value;
const backend = document.getElementById('backend-select').value;
const select = document.getElementById('resolution-select');
const selectedLabel = select.options[select.selectedIndex]?.textContent || '';
const loading = document.getElementById('loading');
loading.style.display = 'flex';
loading.classList.remove('hidden');
document.getElementById('loading-text').textContent = `Loading ${realm} ${backend.toUpperCase()} ${selectedLabel} data...`;
document.getElementById('demo-badge').style.display = 'none';
await new Promise(r => setTimeout(r, 50));
const data = await loadData();
buildPointCloud(data);
updateRealmOverlay();
resetView();
initSliders();
const adaptive = computeAdaptiveTimeFilter();
if (adaptive) applyTimeFilter(adaptive.minSeconds, adaptive.maxSeconds);
applyFilters();
loading.classList.add('hidden');
setTimeout(() => { loading.style.display = 'none'; }, 500);
}
function changeRealm() {
const realm = document.getElementById('realm-select').value;
const backendSelect = document.getElementById('backend-select');
// Sphere only has GPU; cube has CPU + GPU.
if (realm === 'sphere') {
backendSelect.value = 'gpu';
backendSelect.querySelector('option[value="cpu"]').disabled = true;
} else {
backendSelect.querySelector('option[value="cpu"]').disabled = false;
}
// Toggle realm-specific controls
document.getElementById('cube-slice-controls').style.display = realm === 'cube' ? '' : 'none';
document.getElementById('sphere-slice-controls').style.display = realm === 'sphere' ? '' : 'none';
// Filter state from the previous realm rarely makes sense here β€” reset.
resetFilters();
const backend = backendSelect.value;
const currentResolution = getSelectedEntry()?.resolution;
populateResolutionSelect(realm, backend, currentResolution);
reloadDataset();
}
function changeBackend() {
const realm = document.getElementById('realm-select').value;
const backend = document.getElementById('backend-select').value;
const currentResolution = getSelectedEntry()?.resolution;
populateResolutionSelect(realm, backend, currentResolution);
reloadDataset();
}
function changeResolution() {
reloadDataset();
}
// ─── Colormap switching ──────────────────────────────────────────────────────
function changeColormap() {
const mapName = document.getElementById('colormap-select').value;
state.currentColormap = mapName;
try { localStorage.setItem('tp-colormap', mapName); } catch {}
if (state.points) {
const newTexture = generateColormapTexture(mapName);
const oldTexture = state.points.material.uniforms.colormapTexture.value;
state.points.material.uniforms.colormapTexture.value = newTexture;
if (oldTexture) oldTexture.dispose();
}
drawLegend();
state.needsRender = true;
}
function toggleInvertColormap() {
state.invertColormap = !state.invertColormap;
const btn = document.getElementById('invert-btn');
btn.setAttribute('aria-pressed', state.invertColormap ? 'true' : 'false');
if (state.points) {
state.points.material.uniforms.invertColormap.value = state.invertColormap ? 1.0 : 0.0;
}
drawLegend();
state.needsRender = true;
}
// ─── Control handlers ─────────────────────────────────────────────────────────
// Log-scale mapping: slider [0,1] β†’ value [min, max] via exponential curve
const PSIZE_LOG_MIN = 0.5, PSIZE_LOG_MAX = 15;
const PSIZE_LOG_RATIO = PSIZE_LOG_MAX / PSIZE_LOG_MIN;
function pointSizeFromSlider(t) { return PSIZE_LOG_MIN * Math.pow(PSIZE_LOG_RATIO, t); }
function sliderFromPointSize(v) { return Math.log(v / PSIZE_LOG_MIN) / Math.log(PSIZE_LOG_RATIO); }
const OPACITY_LOG_MIN = 0.01, OPACITY_LOG_MAX = 1.0;
const OPACITY_LOG_RATIO = OPACITY_LOG_MAX / OPACITY_LOG_MIN;
function opacityFromSlider(t) { return OPACITY_LOG_MIN * Math.pow(OPACITY_LOG_RATIO, t); }
function sliderFromOpacity(v) { return Math.log(v / OPACITY_LOG_MIN) / Math.log(OPACITY_LOG_RATIO); }
function updatePointSize() {
const t = parseFloat(document.getElementById('point-size').value);
const val = pointSizeFromSlider(t);
document.getElementById('point-size-val').textContent = val.toFixed(1);
if (state.points) {
state.points.material.uniforms.pointScale.value = val * 0.013;
}
state.needsRender = true;
}
function updateOpacity() {
const t = parseFloat(document.getElementById('opacity').value);
const val = opacityFromSlider(t);
document.getElementById('opacity-val').textContent = val.toFixed(3);
if (state.points) {
state.points.material.uniforms.uOpacity.value = val;
}
state.needsRender = true;
}
function updateTimeFilter(source) {
const minEl = document.getElementById('time-min');
const maxEl = document.getElementById('time-max');
let minVal = parseFloat(minEl.value);
let maxVal = parseFloat(maxEl.value);
// Two-way clamp so min and max can never cross.
if (minVal > maxVal) {
if (source === 'max') {
minVal = maxVal;
minEl.value = minVal;
} else {
maxVal = minVal;
maxEl.value = maxVal;
}
}
const warn = document.getElementById('time-warning');
if (warn) {
warn.textContent = (Math.abs(maxVal - minVal) < 0.001)
? 'Range is empty β€” no points pass this filter'
: '';
}
const minSec = (minVal * state.tMax).toFixed(1);
const maxSec = maxVal >= 1.0 ? (state.tMax > 0 ? state.tMax.toFixed(1) : '--') : (maxVal * state.tMax).toFixed(1);
document.getElementById('time-min-val').textContent = minSec + ' s';
document.getElementById('time-max-val').textContent = maxSec + ' s';
applyFilters();
}
function updateSliceThickness() {
const val = parseInt(document.getElementById('slice-thickness').value);
document.getElementById('slice-thickness-val').textContent = val + 'Β°';
applyFilters();
}
function updateSliceLabel(axis) {
const val = parseInt(document.getElementById('slice-' + axis).value);
document.getElementById('slice-' + axis + '-val').textContent = val + 'Β°';
applyFilters();
}
function togglePanel() {
const panel = document.getElementById('controls');
const collapsed = panel.classList.toggle('collapsed');
document.getElementById('controls-header').setAttribute('aria-expanded', collapsed ? 'false' : 'true');
try { localStorage.setItem('tp-panel-collapsed', collapsed ? '1' : '0'); } catch {}
}
function showAllOctants() {
for (let octantIdx = 1; octantIdx <= 8; octantIdx++) {
document.getElementById('octant-' + octantIdx).checked = true;
}
applyFilters();
}
function hideAllOctants() {
for (let octantIdx = 1; octantIdx <= 8; octantIdx++) {
document.getElementById('octant-' + octantIdx).checked = false;
}
applyFilters();
}
function toggleAutoRotate() {
const checked = document.getElementById('auto-rotate').checked;
state.controls.autoRotate = checked;
state.controls.autoRotateSpeed = parseFloat(document.getElementById('rotate-speed').value);
document.getElementById('rotate-speed').disabled = !checked;
state.needsRender = true;
}
function updateRotateSpeed() {
const val = parseFloat(document.getElementById('rotate-speed').value);
document.getElementById('rotate-speed-val').textContent = val.toFixed(1);
state.controls.autoRotateSpeed = val;
state.needsRender = true;
}
function toggleAxes() {
if (state.axesHelper) state.axesHelper.visible = document.getElementById('show-axes').checked;
state.needsRender = true;
}
function toggleGrid() {
if (state.realmOverlay) {
state.realmOverlay.visible = document.getElementById('show-grid').checked;
}
state.needsRender = true;
}
function updateRealmOverlay() {
// Remove existing overlay
if (state.realmOverlay) {
state.scene.remove(state.realmOverlay);
state.realmOverlay.geometry.dispose();
state.realmOverlay.material.dispose();
state.realmOverlay = null;
}
const overlayMat = new THREE.MeshBasicMaterial({
color: 0x00f0ff,
wireframe: true,
transparent: true,
opacity: 0.08,
});
if (state.realm === 'sphere') {
state.realmOverlay = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 24), overlayMat);
} else {
state.realmOverlay = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), overlayMat);
}
state.realmOverlay.visible = document.getElementById('show-grid').checked;
state.scene.add(state.realmOverlay);
}
function updateShellLabel() {
const val = parseInt(document.getElementById('shell-radius').value);
document.getElementById('shell-radius-val').textContent = val + '\u00B0';
applyFilters();
}
function updateShellThicknessLabel() {
const val = parseInt(document.getElementById('shell-thickness').value);
document.getElementById('shell-thickness-val').textContent = val + '\u00B0';
applyFilters();
}
function resetView() {
if (state.realm === 'sphere') {
state.camera.position.set(1.8, 1.3, 1.8);
} else {
state.camera.position.set(2.2, 1.6, 2.2);
}
state.controls.target.set(0, 0, 0);
state.controls.update();
}
// ─── Export (PNG / SVG, 1:1) ──────────────────────────────────────────────────
function collectExportMetadata() {
const $ = (id) => document.getElementById(id);
const num = (id) => parseFloat($(id).value);
const bool = (id) => $(id).checked;
const cam = state.camera;
const tgt = state.controls.target;
const meta = {
app: 'Triple Pendulum 3D Chaos Voxel Map',
url: location.href,
timestamp: new Date().toISOString(),
dataset: {
realm: $('realm-select').value,
backend: $('backend-select').value,
resolution: parseInt($('resolution-select').value),
},
colormap: {
name: $('colormap-select').value,
inverted: state.invertColormap === true,
},
rendering: {
pointSize: num('point-size'),
opacity: num('opacity'),
timeMin: num('time-min'),
timeMax: num('time-max'),
showStable: bool('show-stable'),
showAxes: bool('show-axes'),
showGrid: bool('show-grid'),
autoRotate: bool('auto-rotate'),
rotateSpeed: num('rotate-speed'),
},
slice: {
thickness: num('slice-thickness'),
x: { enabled: bool('slice-x-en'), value: num('slice-x') },
y: { enabled: bool('slice-y-en'), value: num('slice-y') },
z: { enabled: bool('slice-z-en'), value: num('slice-z') },
},
shell: {
enabled: bool('shell-slice-en'),
radius: num('shell-radius'),
thickness: num('shell-thickness'),
},
octants: [1,2,3,4,5,6,7,8].map(i => bool('octant-' + i)),
camera: {
position: [cam.position.x, cam.position.y, cam.position.z],
target: [tgt.x, tgt.y, tgt.z],
fov: cam.fov,
},
};
return meta;
}
function buildExportFilename(ext) {
const m = collectExportMetadata();
const ts = m.timestamp.replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
return `triple_pendulum_${m.dataset.realm}_${m.dataset.backend}_n${m.dataset.resolution}_${ts}.${ext}`;
}
// Render scene at NxN into the renderer canvas, return data URL (PNG).
function renderSquare(size) {
const renderer = state.renderer;
const camera = state.camera;
const points = state.points;
const container = document.getElementById('canvas-container');
const prevW = container.clientWidth;
const prevH = container.clientHeight;
const prevAspect = camera.aspect;
const prevPixelRatio = renderer.getPixelRatio();
const prevSizeScale = points ? points.material.uniforms.sizeScale.value : null;
// Force pixel ratio = 1 so size = canvas pixel dims exactly
renderer.setPixelRatio(1);
renderer.setSize(size, size, false);
camera.aspect = 1;
camera.updateProjectionMatrix();
if (points) {
points.material.uniforms.sizeScale.value = 0.5 * size;
}
renderer.render(state.scene, camera);
const dataUrl = renderer.domElement.toDataURL('image/png');
// Restore
renderer.setPixelRatio(prevPixelRatio);
renderer.setSize(prevW, prevH, false);
camera.aspect = prevAspect;
camera.updateProjectionMatrix();
if (points && prevSizeScale != null) {
points.material.uniforms.sizeScale.value = prevSizeScale;
}
state.needsRender = true;
return dataUrl;
}
function dataUrlToUint8(dataUrl) {
const b64 = dataUrl.split(',')[1];
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// CRC32 for PNG chunks (table-based)
const PNG_CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = PNG_CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// Build a tEXt chunk: 4B length, 4B 'tEXt', keyword\0text, 4B CRC
function buildTextChunk(keyword, text) {
const data = new TextEncoder().encode(keyword + '\0' + text);
const type = new Uint8Array([0x74, 0x45, 0x58, 0x74]); // 'tEXt'
const chunk = new Uint8Array(4 + 4 + data.length + 4);
const dv = new DataView(chunk.buffer);
dv.setUint32(0, data.length, false);
chunk.set(type, 4);
chunk.set(data, 8);
const crcInput = new Uint8Array(4 + data.length);
crcInput.set(type, 0);
crcInput.set(data, 4);
dv.setUint32(8 + data.length, crc32(crcInput), false);
return chunk;
}
// Insert tEXt chunks into a PNG byte array (after IHDR, before IDAT)
function injectPngText(pngBytes, entries) {
// PNG signature 8 bytes; first chunk is IHDR (length 13 + 12 overhead = 25)
// We insert immediately after IHDR.
const sigEnd = 8;
const ihdrLen = (pngBytes[sigEnd] << 24) | (pngBytes[sigEnd+1] << 16) | (pngBytes[sigEnd+2] << 8) | pngBytes[sigEnd+3];
const ihdrEnd = sigEnd + 4 + 4 + ihdrLen + 4; // length + type + data + crc
const head = pngBytes.slice(0, ihdrEnd);
const tail = pngBytes.slice(ihdrEnd);
const chunks = entries.map(([k, v]) => buildTextChunk(k, v));
let total = head.length + tail.length;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
out.set(head, off); off += head.length;
for (const c of chunks) { out.set(c, off); off += c.length; }
out.set(tail, off);
return out;
}
function metadataToTextEntries(meta) {
// PNG tEXt keywords are <=79 chars, latin-1 only. Use safe ASCII keys.
const flat = {};
flat['Title'] = 'Triple Pendulum 3D Chaos Voxel Map';
flat['Software'] = meta.app;
flat['Source'] = meta.url;
flat['CreationTime'] = meta.timestamp;
flat['Realm'] = meta.dataset.realm;
flat['Backend'] = meta.dataset.backend;
flat['Resolution'] = String(meta.dataset.resolution);
flat['Colormap'] = meta.colormap.name + (meta.colormap.inverted ? ' (inverted)' : '');
flat['PointSize'] = String(meta.rendering.pointSize);
flat['Opacity'] = String(meta.rendering.opacity);
flat['TimeRange'] = meta.rendering.timeMin + '..' + meta.rendering.timeMax;
flat['Slices'] = ['x','y','z'].map(a => meta.slice[a].enabled ? `${a}=${meta.slice[a].value}` : '').filter(Boolean).join(',') || 'none';
flat['SliceThickness'] = String(meta.slice.thickness);
flat['Shell'] = meta.shell.enabled ? `r=${meta.shell.radius} thk=${meta.shell.thickness}` : 'off';
flat['Octants'] = meta.octants.map(b => b ? '1' : '0').join('');
flat['Camera'] = `pos=[${meta.camera.position.map(v => v.toFixed(4)).join(',')}] tgt=[${meta.camera.target.map(v => v.toFixed(4)).join(',')}] fov=${meta.camera.fov}`;
flat['JSON'] = JSON.stringify(meta);
return Object.entries(flat);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportPNG() {
const size = parseInt(document.getElementById('export-size').value) || 2048;
const meta = collectExportMetadata();
const dataUrl = renderSquare(size);
const raw = dataUrlToUint8(dataUrl);
const withMeta = injectPngText(raw, metadataToTextEntries(meta));
downloadBlob(new Blob([withMeta], { type: 'image/png' }), buildExportFilename('png'));
}
function escapeXml(s) {
return String(s).replace(/[<>&'"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;',"'":'&apos;','"':'&quot;'}[c]));
}
function exportSVG() {
const size = parseInt(document.getElementById('export-size').value) || 2048;
const meta = collectExportMetadata();
const dataUrl = renderSquare(size);
const metaJson = escapeXml(JSON.stringify(meta, null, 2));
const dataAttrs = [
`data-realm="${escapeXml(meta.dataset.realm)}"`,
`data-backend="${escapeXml(meta.dataset.backend)}"`,
`data-resolution="${meta.dataset.resolution}"`,
`data-colormap="${escapeXml(meta.colormap.name)}"`,
`data-inverted="${meta.colormap.inverted}"`,
`data-timestamp="${escapeXml(meta.timestamp)}"`,
].join(' ');
const svg =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
`width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" ${dataAttrs}>\n` +
` <title>Triple Pendulum 3D Chaos β€” ${escapeXml(meta.dataset.realm)} N=${meta.dataset.resolution}</title>\n` +
` <desc>Square (1:1) export. See &lt;metadata&gt; for full settings JSON.</desc>\n` +
` <metadata><![CDATA[\n${metaJson}\n]]></metadata>\n` +
` <image x="0" y="0" width="${size}" height="${size}" preserveAspectRatio="xMidYMid meet" xlink:href="${dataUrl}" />\n` +
`</svg>\n`;
downloadBlob(new Blob([svg], { type: 'image/svg+xml' }), buildExportFilename('svg'));
}
// ─── Octant grid + reset filters ─────────────────────────────────────────────
const OCTANT_DEFINITIONS = [
{ id: 1, signs: '+++' },
{ id: 2, signs: '++-' },
{ id: 3, signs: '+-+' },
{ id: 4, signs: '+--' },
{ id: 5, signs: '-++' },
{ id: 6, signs: '-+-' },
{ id: 7, signs: '--+' },
{ id: 8, signs: '---' },
];
function buildOctantGrid() {
const grid = document.getElementById('octant-grid');
if (!grid) return;
grid.innerHTML = '';
for (const o of OCTANT_DEFINITIONS) {
const row = document.createElement('div');
row.className = 'control-row';
row.setAttribute('data-tooltip',
`Octant ${o.id}: sign(θ₁)=${o.signs[0]}, sign(ΞΈβ‚‚)=${o.signs[1]}, sign(θ₃)=${o.signs[2]}`);
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = 'octant-' + o.id;
cb.checked = true;
cb.addEventListener('change', applyFilters);
const lab = document.createElement('label');
lab.setAttribute('for', cb.id);
const signSpan = (s) =>
`<span class="${s === '+' ? 'octant-sign-pos' : 'octant-sign-neg'}">${s}</span>`;
lab.innerHTML = `${o.id} ${signSpan(o.signs[0])}${signSpan(o.signs[1])}${signSpan(o.signs[2])}`;
row.appendChild(cb);
row.appendChild(lab);
grid.appendChild(row);
}
}
function setAllOctants(checked) {
for (let i = 1; i <= 8; i++) {
const cb = document.getElementById('octant-' + i);
if (cb) cb.checked = checked;
}
applyFilters();
}
function resetFilters() {
// Time
const minEl = document.getElementById('time-min');
const maxEl = document.getElementById('time-max');
if (minEl) minEl.value = 0;
if (maxEl) maxEl.value = 1;
document.getElementById('time-warning').textContent = '';
// Show stable
document.getElementById('show-stable').checked = false;
// Cube slices
for (const ax of ['x', 'y', 'z']) {
const en = document.getElementById('slice-' + ax + '-en');
const sl = document.getElementById('slice-' + ax);
if (en) en.checked = false;
if (sl) { sl.value = 0; document.getElementById('slice-' + ax + '-val').textContent = '0Β°'; }
}
const thick = document.getElementById('slice-thickness');
if (thick) { thick.value = 5; document.getElementById('slice-thickness-val').textContent = '5Β°'; }
// Sphere shell
const sh = document.getElementById('shell-slice-en');
if (sh) sh.checked = false;
const sr = document.getElementById('shell-radius');
if (sr) { sr.value = 85; document.getElementById('shell-radius-val').textContent = '85Β°'; }
const st = document.getElementById('shell-thickness');
if (st) { st.value = 10; document.getElementById('shell-thickness-val').textContent = '10Β°'; }
// Octants
setAllOctants(true);
updateTimeFilter();
}
// ─── Tooltip portal ──────────────────────────────────────────────────────────
// Floating tooltip on body so clipping containers don't hide it.
let tooltipShowTimer = null;
let tooltipCurrentTarget = null;
function placeTooltip(target, portal) {
const rect = target.getBoundingClientRect();
const portalRect = portal.getBoundingClientRect();
let left = rect.left + (rect.width - portalRect.width) / 2;
let top = rect.bottom + 6;
// Clamp horizontally
const margin = 8;
if (left < margin) left = margin;
if (left + portalRect.width > window.innerWidth - margin) {
left = window.innerWidth - portalRect.width - margin;
}
// Flip vertically if would overflow bottom
if (top + portalRect.height > window.innerHeight - margin) {
top = rect.top - portalRect.height - 6;
}
portal.style.left = left + 'px';
portal.style.top = top + 'px';
}
function setupTooltipPortal() {
const portal = document.getElementById('tooltip-portal');
if (!portal) return;
const hide = () => {
clearTimeout(tooltipShowTimer);
tooltipShowTimer = null;
tooltipCurrentTarget = null;
portal.classList.remove('visible');
};
document.addEventListener('mouseover', (e) => {
const target = e.target.closest('[data-tooltip]');
if (!target || target === tooltipCurrentTarget) return;
tooltipCurrentTarget = target;
clearTimeout(tooltipShowTimer);
tooltipShowTimer = setTimeout(() => {
portal.textContent = target.getAttribute('data-tooltip');
portal.classList.add('visible');
placeTooltip(target, portal);
}, 350);
});
document.addEventListener('mouseout', (e) => {
if (!tooltipCurrentTarget) return;
const related = e.relatedTarget;
if (related && tooltipCurrentTarget.contains(related)) return;
if (related && related.closest && related.closest('[data-tooltip]') === tooltipCurrentTarget) return;
hide();
});
// Hide on scroll, focus change, escape
document.addEventListener('scroll', hide, true);
window.addEventListener('blur', hide);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
// Show on keyboard focus too (a11y win)
document.addEventListener('focusin', (e) => {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
tooltipCurrentTarget = target;
portal.textContent = target.getAttribute('data-tooltip');
portal.classList.add('visible');
placeTooltip(target, portal);
});
document.addEventListener('focusout', hide);
}
// ─── Persistence + scanline toggle ───────────────────────────────────────────
function applyScanlines(enabled) {
document.body.classList.toggle('scanlines', enabled);
const cb = document.getElementById('scanlines-toggle');
if (cb) cb.checked = enabled;
try { localStorage.setItem('tp-scanlines', enabled ? '1' : '0'); } catch {}
}
function loadPersistedPrefs() {
try {
const cm = localStorage.getItem('tp-colormap');
if (cm) {
const select = document.getElementById('colormap-select');
if (select && [...select.options].some(o => o.value === cm)) {
select.value = cm;
state.currentColormap = cm;
}
}
const collapsed = localStorage.getItem('tp-panel-collapsed');
if (collapsed === '1') {
document.getElementById('controls').classList.add('collapsed');
document.getElementById('controls-header').setAttribute('aria-expanded', 'false');
}
const scan = localStorage.getItem('tp-scanlines');
applyScanlines(scan === null ? true : scan === '1');
} catch {
applyScanlines(true);
}
// Respect prefers-reduced-motion: default auto-rotate off
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const ar = document.getElementById('auto-rotate');
if (ar) ar.checked = false;
}
}
// ─── Wire all DOM events ─────────────────────────────────────────────────────
function wireEvents() {
// Header collapse
document.getElementById('controls-header').addEventListener('click', togglePanel);
// Toolbar
document.getElementById('reset-view').addEventListener('click', resetView);
document.getElementById('reset-filters').addEventListener('click', resetFilters);
document.getElementById('export-png').addEventListener('click', exportPNG);
document.getElementById('export-svg').addEventListener('click', exportSVG);
// Dataset
document.getElementById('realm-select').addEventListener('change', changeRealm);
document.getElementById('backend-select').addEventListener('change', changeBackend);
document.getElementById('resolution-select').addEventListener('change', changeResolution);
// Colormap
document.getElementById('colormap-select').addEventListener('change', changeColormap);
document.getElementById('invert-btn').addEventListener('click', toggleInvertColormap);
// Appearance
document.getElementById('point-size').addEventListener('input', updatePointSize);
document.getElementById('opacity').addEventListener('input', updateOpacity);
// Display
document.getElementById('show-axes').addEventListener('change', toggleAxes);
document.getElementById('show-grid').addEventListener('change', toggleGrid);
document.getElementById('auto-rotate').addEventListener('change', toggleAutoRotate);
document.getElementById('rotate-speed').addEventListener('input', updateRotateSpeed);
document.getElementById('scanlines-toggle').addEventListener('change', (e) => applyScanlines(e.target.checked));
// Time filter β€” pass source so two-way clamp knows which slider moved
document.getElementById('time-min').addEventListener('input', () => updateTimeFilter('min'));
document.getElementById('time-max').addEventListener('input', () => updateTimeFilter('max'));
document.getElementById('show-stable').addEventListener('change', applyFilters);
// Cube slices
document.getElementById('slice-thickness').addEventListener('input', updateSliceThickness);
for (const ax of ['x', 'y', 'z']) {
document.getElementById('slice-' + ax + '-en').addEventListener('change', applyFilters);
document.getElementById('slice-' + ax).addEventListener('input', () => updateSliceLabel(ax));
}
// Sphere shell
document.getElementById('shell-slice-en').addEventListener('change', applyFilters);
document.getElementById('shell-radius').addEventListener('input', updateShellLabel);
document.getElementById('shell-thickness').addEventListener('input', updateShellThicknessLabel);
// Octant toolbar (octant checkboxes wired by buildOctantGrid)
document.getElementById('octants-show-all').addEventListener('click', () => setAllOctants(true));
document.getElementById('octants-hide-all').addEventListener('click', () => setAllOctants(false));
}
// ─── Render loop ──────────────────────────────────────────────────────────────
function animate() {
requestAnimationFrame(animate);
// OrbitControls.update() returns true when damping is still active.
const controlsActive = state.controls.update();
// Render only when something changed: user interaction, auto-rotate, or explicit request.
if (state.needsRender || controlsActive || state.controls.autoRotate) {
state.renderer.render(state.scene, state.camera);
state.needsRender = false;
}
}
// ─── Initialise time-range sliders from real data ────────────────────────────
function initSliders() {
// Time filter defaults are applied by applyConfigTimeFilter()
}
// ─── Config ───────────────────────────────────────────────────────────────────
let CONFIG = null;
async function loadConfig() {
try {
const res = await fetch('viewer-config.json');
if (res.ok) CONFIG = await res.json();
} catch (e) {
console.warn('Could not load viewer-config.json, using HTML defaults');
}
}
function applyConfig() {
if (!CONFIG) return;
const d = CONFIG.dataset || {};
const r = CONFIG.rendering || {};
const t = CONFIG.timeFilter || {};
const disp = CONFIG.display || {};
// Dataset
if (d.realm) {
document.getElementById('realm-select').value = d.realm;
// Toggle realm-specific controls
const realm = d.realm;
document.getElementById('cube-slice-controls').style.display = realm === 'cube' ? '' : 'none';
document.getElementById('sphere-slice-controls').style.display = realm === 'sphere' ? '' : 'none';
if (realm === 'sphere') {
document.getElementById('backend-select').querySelector('option[value="cpu"]').disabled = true;
}
}
if (d.backend) document.getElementById('backend-select').value = d.backend;
// Rendering controls (log-scaled sliders: convert real values to slider positions)
if (r.pointSize != null) {
const sliderPos = sliderFromPointSize(Math.max(PSIZE_LOG_MIN, Math.min(PSIZE_LOG_MAX, r.pointSize)));
document.getElementById('point-size').value = sliderPos;
document.getElementById('point-size-val').textContent = r.pointSize.toFixed(1);
}
if (r.opacity != null) {
const sliderPos = sliderFromOpacity(Math.max(OPACITY_LOG_MIN, Math.min(OPACITY_LOG_MAX, r.opacity)));
document.getElementById('opacity').value = sliderPos;
document.getElementById('opacity-val').textContent = r.opacity.toFixed(3);
}
if (r.colormap) document.getElementById('colormap-select').value = r.colormap;
// Display
if (disp.showStable === false) document.getElementById('show-stable').checked = false;
if (disp.showStable === true) document.getElementById('show-stable').checked = true;
if (disp.showAxes === false) document.getElementById('show-axes').checked = false;
if (disp.showAxes === true) document.getElementById('show-axes').checked = true;
if (disp.autoRotate != null) document.getElementById('auto-rotate').checked = !!disp.autoRotate;
if (disp.autoRotateSpeed != null) {
document.getElementById('rotate-speed').value = disp.autoRotateSpeed;
document.getElementById('rotate-speed-val').textContent = disp.autoRotateSpeed.toFixed(1);
}
if (disp.sliceThickness != null) {
document.getElementById('slice-thickness').value = disp.sliceThickness;
document.getElementById('slice-thickness-val').textContent = disp.sliceThickness;
}
}
function applyConfigToMaterial() {
if (!CONFIG) return;
const r = CONFIG.rendering || {};
const disp = CONFIG.display || {};
if (state.points) {
if (r.pointSize != null) state.points.material.uniforms.pointScale.value = r.pointSize * 0.013;
if (r.opacity != null) state.points.material.uniforms.uOpacity.value = r.opacity;
}
if (state.controls && disp.autoRotate) {
state.controls.autoRotate = true;
state.controls.autoRotateSpeed = disp.autoRotateSpeed || 1.0;
}
if (state.axesHelper && disp.showAxes != null) {
state.axesHelper.visible = disp.showAxes;
}
}
function computeAdaptiveTimeFilter() {
// Target a max number of visible points for smooth rendering.
// Assumes ~85% of pendulums flip and flip times are roughly
// distributed over [0, tMax]. Adjusts the time window width
// to keep visible point count near the target.
const TARGET_POINTS = 2_000_000;
const totalPoints = state.pointCount;
const tMax = state.tMax;
if (totalPoints <= 0 || tMax <= 0) return null;
const estimatedFlipFraction = 0.85;
const flippedPoints = totalPoints * estimatedFlipFraction;
if (flippedPoints <= TARGET_POINTS) {
// Small enough to show everything
return { minSeconds: 0, maxSeconds: tMax };
}
// Window width needed to show ~TARGET_POINTS
const windowWidth = (TARGET_POINTS / flippedPoints) * tMax;
// Center around 2.5s (the interesting chaos boundary region)
const center = 2.5;
const minSec = Math.max(0, center - windowWidth / 2);
const maxSec = Math.min(tMax, minSec + windowWidth);
return { minSeconds: minSec, maxSeconds: maxSec };
}
function applyTimeFilter(minSeconds, maxSeconds) {
const tMax = state.tMax;
if (tMax <= 0) return;
const normMin = Math.min(minSeconds / tMax, 1.0);
const normMax = Math.min(maxSeconds / tMax, 1.0);
document.getElementById('time-min').value = normMin;
document.getElementById('time-min-val').textContent = (normMin * tMax).toFixed(1) + ' s';
document.getElementById('time-max').value = normMax;
document.getElementById('time-max-val').textContent = (normMax * tMax).toFixed(1) + ' s';
}
function applyConfigTimeFilter() {
// Use config values if set, otherwise compute adaptive defaults
const configFilter = CONFIG?.timeFilter || {};
const adaptive = computeAdaptiveTimeFilter();
if (!adaptive) return;
const minSec = configFilter.minSeconds != null ? configFilter.minSeconds : adaptive.minSeconds;
const maxSec = configFilter.maxSeconds != null ? configFilter.maxSeconds : adaptive.maxSeconds;
applyTimeFilter(minSec, maxSec);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
buildOctantGrid();
wireEvents();
setupTooltipPortal();
await loadConfig();
applyConfig();
// Persisted user prefs take priority over config file defaults
loadPersistedPrefs();
// Sync rotate-speed disabled state to initial auto-rotate value
document.getElementById('rotate-speed').disabled = !document.getElementById('auto-rotate').checked;
initThree();
animate();
drawLegend();
const defaultResolution = CONFIG?.dataset?.defaultResolution || null;
const initialRealm = document.getElementById('realm-select').value;
const initialBackend = document.getElementById('backend-select').value;
populateResolutionSelect(initialRealm, initialBackend, defaultResolution);
const data = await loadData();
buildPointCloud(data);
updateRealmOverlay();
applyConfigToMaterial();
applyConfigTimeFilter();
applyFilters();
drawLegend(); // Re-draw with real tMax for tick labels
// Hide loading screen
const loading = document.getElementById('loading');
loading.classList.add('hidden');
setTimeout(() => { loading.style.display = 'none'; }, 500);
}
main();
</script>
</body>
</html>