Spaces:
Running
Running
| <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 ; transition-duration: 0.001ms ; } | |
| } | |
| * { | |
| 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% ; | |
| height: 100% ; | |
| } | |
| /* 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 ; | |
| 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">▼</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°</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">θ₁ slice</label> | |
| <span class="value-label" id="slice-x-val">0°</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">θ₂ slice</label> | |
| <span class="value-label" id="slice-y-val">0°</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">θ₃ slice</label> | |
| <span class="value-label" id="slice-z-val">0°</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°</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°</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 => ({'<':'<','>':'>','&':'&',"'":''','"':'"'}[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 <metadata> 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> | |