PageSourceSearch

https://aethronphantom-nexamat-crystal-viewer.hf.space/

html aethronphantom-nexamat-crystal-viewer.hf.space collected 2026-10-03 16:37:21 UTC 50,310 bytes, 919 lines download raw bytes

1<!doctype html>
2<html lang="en" data-theme="dark">
3<head>
4  <meta charset="utf-8" />
5  <meta name="viewport" content="width=device-width, initial-scale=1" />
6  <title>Nexa_Mat Gen Stack</title>
7  <link rel="preconnect" href="https://fonts.googleapis.com">
8  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
10  <style>
11    :root {
12      --bg: #04040c;
13      --panel: #080b17;
14      --panel-2: #0e1426;
15      --line: rgba(88, 119, 255, 0.22);
16      --text: #e6e8ff;
17      --muted: #8a93c5;
18      --soft: #2b335f;
19      --blue: #5577ff;
20      --cyan: #00ccff;
21      --violet: #c050ff;
22      --gold: #ffbb00;
23      --green: #30e58a;
24      --red: #ff667a;
25      --shadow: rgba(0,0,0,.34);
26    }
27    [data-theme="light"] {
28      --bg: #f6f8ff;
29      --panel: #ffffff;
30      --panel-2: #eef3ff;
31      --line: rgba(45, 69, 146, 0.18);
32      --text: #111827;
33      --muted: #55627f;
34      --soft: #d7dff6;
35      --shadow: rgba(38,51,94,.12);
36    }
37    * { box-sizing: border-box; }
38    body {
39      margin: 0;
40      width: 100%;
41      height: 100%;
42      overflow: hidden;
43      overscroll-behavior: none;
44      touch-action: none;
45      color: var(--text);
46      background: radial-gradient(circle at 22% 18%, rgba(85,119,255,.20), transparent 28%), var(--bg);
47      font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
48    }
49    button, input, textarea, select { font: inherit; }
50    button {
51      border: 1px solid var(--line);
52      background: linear-gradient(180deg, rgba(85,119,255,.18), rgba(85,119,255,.07));
53      color: var(--text);
54      border-radius: 7px;
55      padding: 9px 11px;
56      cursor: pointer;
57    }
58    button:hover { border-color: rgba(85,119,255,.55); }
59    button:disabled {
60      cursor: wait;
61      opacity: .62;
62      border-color: color-mix(in srgb, var(--cyan) 45%, var(--line));
63    }
64    textarea, input, select {
65      width: 100%;
66      border: 1px solid var(--line);
67      background: rgba(0,0,0,.12);
68      color: var(--text);
69      border-radius: 7px;
70      padding: 9px 10px;
71      outline: none;
72    }
73    [data-theme="light"] textarea, [data-theme="light"] input, [data-theme="light"] select { background: #f9fbff; }
74    label { display: grid; gap: 6px; font-size: 12px; color: var(--muted); }
75    input[type="file"] {
76      border-style: dashed;
77      padding: 10px;
78      background: color-mix(in srgb, var(--panel-2) 72%, transparent);
79    }
80    input[type="range"] { padding: 0; accent-color: var(--blue); }
81    html { width: 100%; height: 100%; overflow: hidden;
81 overscroll-behavior: none; }
82    .app { width: 100%; height: 100%; display: grid; grid-template-rows: 56px minmax(0, 1fr) 28px; overflow: hidden; }
83    header {
84      display: flex;
85      align-items: center;
86      gap: 16px;
87      padding: 0 18px;
88      border-bottom: 1px solid var(--line);
89      background: color-mix(in srgb, var(--panel) 88%, transparent);
90      box-shadow: 0 10px 32px var(--shadow);
91      z-index: 10;
92    }
93    .brand { display: flex; align-items: center; gap: 10px; min-width: 280px; }
94    .brand-mark { width: 4px; height: 28px; background: linear-gradient(var(--violet), var(--blue)); box-shadow: 0 0 16px var(--violet); }
95    .brand h1 { margin: 0; font-size: 15px; letter-spacing: .04em; }
96    .brand span { color: var(--blue); font: 11px JetBrains Mono, monospace; }
97    .top-status { display: flex; gap: 9px; align-items: center; color: var(--muted); font: 11px JetBrains Mono, monospace; }
98    .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--green); box-shadow: 0 0 10px var(--green); }
99    .main { min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(250px, 310px) minmax(360px, 1fr) minmax(320px, 420px); overflow: hidden; }
100    .rail, .side {
101      min-height: 0;
102      overflow: auto;
103      background: color-mix(in srgb, var(--panel) 92%, transparent);
104      border-right: 1px solid var(--line);
105      padding: 14px;
106    }
107    .side { border-right: 0; border-left: 1px solid var(--line); }
108    .viewer-wrap { min-width: 0; min-height: 0; position: relative; overflow: hidden; background: var(--bg);
108 overscroll-behavior: none; touch-action: none; }
109    #viewer { position: absolute; inset: 0; }
110    #viewer canvas { display: block; width: 100% !important; height: 100% !important; overscroll-behavior: none; touch-action: none; }
111    .hud {
112      position: absolute;
113      left: 14px;
114      top: 14px;
115      display: none;
116      gap: 8px;
117      flex-wrap: wrap;
118      pointer-events: none;
119    }
120    .pill {
121      border: 1px solid var(--line);
122      border-radius: 999px;
123      padding: 6px 9px;
124      color: var(--muted);
125      background: color-mix(in srgb, var(--panel) 82%, transparent);
126      font: 11px JetBrains Mono, monospace;
127    }
128    .viewer-footer {
129      position: absolute;
130      left: 0;
131      right: 0;
132      bottom: 0;
133      display: flex;
134      align-items: center;
135      justify-content: space-between;
136      gap: 12px;
137      height: 34px;
138      padding: 0 14px;
139      background: color-mix(in srgb, var(--panel) 82%, transparent);
140      border-top: 1px solid var(--line);
141      color: var(--muted);
142      font: 11px JetBrains Mono, monospace;
143    }
144    .atom-tip {
145      position: absolute;
146      display: none;
147      pointer-events: none;
148      z-index: 8;
149      max-width: 230px;
150      border: 1px solid var(--line);
151      border-radius: 8px;
152      padding: 9px 10px;
153      background: color-mix(in srgb, var(--panel) 92%, transparent);
154      box-shadow: 0 14px 38px var(--shadow);
155      color: var(--text);
156      font-size: 12px;
157      line-height: 1.45;
158    }
159    .atom-tip b { display: block; font-size: 13px; margin-bottom: 3px; }
160    .atom-tip span { color: var(--muted); font: 11px JetBrains Mono, monospace; }
161    .run-progress {
162      position: absolute;
163      left: 50%;
164      top: 18px;
165      width: min(420px, calc(100% - 42px));
166      transform: translateX(-50%) translateY(-8px);
167      z-index: 7;
168      opacity: 0;
169      pointer-events: none;
170      border: 1px solid color-mix(in srgb, var(--cyan) 35%, var(--line));
171      border-radius: 8px;
172      padding: 11px 12px 10px;
173      background: color-mix(in srgb, var(--panel) 88%, transparent);
174      box-shadow: 0 18px 48px var(--shadow);
175      transition: opacity .18s ease, transform .18s ease;
176    }
177    .run-progress.active {
178      opacity: 1;
179      transform: translateX(-50%) translateY(0);
180    }
181    .progress-copy {
182      display: flex;
183      justify-content: space-between;
184      gap: 12px;
185      color: var(--text);
186      font-size: 12px;
187      font-weight: 700;
188      letter-spacing: .02em;
189    }
190    .progress-copy span:last-child {
191      color: var(--cyan);
192      font: 700 11px JetBrains Mono, monospace;
193    }
194    .progress-track {
195      height: 7px;
196      margin-top: 8px;
197      border-radius: 999px;
198      overflow: hidden;
199      background: color-mix(in srgb, var(--soft) 70%, transparent);
200    }
201    .progress-fill {
202      width: 0%;
203      height: 100%;
204      border-radius: inherit;
205      background: linear-gradient(90deg, var(--blue), var(--cyan), var(--green));
206      box-shadow: 0 0 18px color-mix(in srgb, var(--cyan) 45%, transparent);
207      transition: width .22s ease;
208    }
209    .progress-stage {
210      margin-top: 7px;
211      color: var(--muted);
212      font: 11px JetBrains Mono, monospace;
213    }
214    .card {
215      background: linear-gradient(180deg, color-mix(in srgb, var(--panel-2) 86%, transparent), color-mix(in srgb, var(--panel) 92%, transparent));
216      border: 1px solid var(--line);
217      border-radius: 8px;
218      padding: 12px;
219      margin-bottom: 12px;
220    }
221    .card h2, .card h3 { margin: 0 0 8px; font-size: 13px; letter-spacing: .03em; }
222    .subtle { color: var(--muted); font-size: 12px; line-height: 1.55; }
223    .metric { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 8px 0; font-size: 12px; }
224    .bar { grid-column: 1 / -1; height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--soft) 65%, transparent); overflow: hidden; }
225    .fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--blue), var(--cyan)); }
226    .stack-step { border-left: 2px solid var(--soft); padding: 8px 0 8px 12px; margin-left: 6px; }
227    .stack-step.active { border-color: var(--cyan); }
228    .stack-step b { display: block; font-size: 12px; }
229    .stack-step span { color: var(--muted); font-size: 11px; line-height: 1.45; }
230    .tabs { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin-bottom: 12px; }
231    .tabs button.active { border-color: var(--cyan); box-shadow: inset 0 -2px 0 var(--cyan); }
232    .tab { display: none; }
233    .tab.active { display: block; }
234    .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
235    .candidate {
236      display: grid;
237      grid-template-columns: auto 1fr auto;
238      gap: 8px;
239      align-items: center;
240      padding: 8px 0;
241      border-top: 1px solid var(--line);
242      border-radius: 7px;
243    }
244    .candidate.pickable {
245      cursor: pointer;
246      padding: 8px 7px;
247      margin: 0 -7px;
248    }
249    .candidate.pickable:hover { background: color-mix(in srgb, var(--soft) 28%, transparent); }
250    .candidate.selected {
251      background: color-mix(in srgb, var(--blue) 18%, transparent);
252      border-top-color: color-mix(in srgb, var(--cyan) 50%, var(--line));
253      box-shadow: inset 2px 0 0 var(--cyan);
254    }
255    .candidate-controls {
256      display: grid;
257      grid-template-columns: auto 1fr auto;
258      gap: 8px;
259      align-items: center;
260      margin: 8px 0 6px;
261    }
262    .candidate-controls button { padding: 7px 9px; }
263    .candidate-count { text-align: center; color: var(--muted); font: 11px JetBrains Mono, monospace; }
264    .rank { color: var(--gold); font: 700 12px JetBrains Mono, monospace; }
265    .score { color: var(--green); font: 700 12px JetBrains Mono, monospace; }
266    footer { display: flex; align-items: center; justify-content: space-between; padding: 0 16px; color: var(--muted); border-top: 1px solid var(--line); font: 10px JetBrains Mono, monospace; }
267    @media (max-width: 1120px) {
268      .main { grid-template-columns: minmax(220px, 280px) minmax(300px, 1fr) minmax(280px, 360px); }
269      .rail, .side { padding: 10px; }
270      .brand { min-width: 220px; }
271    }
272  </style>
273</head>
274<body>
275  <div class="app">
276    <header>
277      <div class="brand">
278        <div class="brand-mark"></div>
279        <div><h1>Nexa_Mat Gen Stack</h1><span>materials science · generative on demand</span></div>
280      </div>
281      <div class="top-status"><span class="dot"></span><span id="run-state">ready</span></div>
282      <div style="flex:1"></div>
283      <button id="theme-btn">Light mode</button>
284    </header>
285    <main class="main">
286      <aside class="rail">
287        <div class="card">
288          <h2>Generated Structure</h2>
289          <div id="metrics"></div>
290        </div>
291        <div class="card">
292          <h2>Explanation</h2>
293          <div id="structure-explanation" class="subtle">Generate a candidate to inspect structure-level reasoning.</div>
294        </div>
295        <div class="card">
296          <h2>Applications</h2>
297          <div id="applications" class="subtle">Application hypotheses appear here after generation or reverse ranking.</div>
298        </div>
299        <div class="card">
300          <h2>Candidate Distribution</h2>
301          <div class="candidate-controls">
302            <button id="prev-candidate">Prev</button>
303            <span id="candidate-count" class="candidate-count">No samples yet</span>
304            <button id="next-candidate">Next</button>
305          </div>
306          <div id="candidates"></div>
307        </div>
308        <div class="card">
309          <h2>Reverse Results</h2>
310          <div id="reverse-results" class="subtle">Run reverse pool search to rank a candidate list.</div>
311        </div>
312      </aside>
313      <section class="viewer-wrap">
314        <div id="viewer"></div>
315        <div class="run-progress" id="run-progress" aria-live="polite">
316          <div class="progress-copy"><span id="progress-title">Generating candidate</span><span id="progress-percent">0%</span></div>
317          <div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
318          <div class="progress-stage" id="progress-stage">Preparing model stack</div>
319        </div>
320        <div class="hud">
321          <div class="pill" id="formula-pill">La7Ge7Te7</div>
322          <div class="pill" id="score-pill">stack 0.782</div>
323          <div class="pill" id="mind-pill">min d 1.104 Å</div>
324          <div class="pill">drag rotate · fixed zoom</div>
325        </div>
326        <div id="atom-tooltip" class="atom-tip"></div>
327        <div class="viewer-footer">
328          <span>Encoder constrains physical plausibility · diffusion expands candidates · multimodal layer supplies evidence</span>
329          <span id="hover-label">hover atoms for species</span>
330        </div>
331      </section>
332      <aside class="side">
333        <div class="tabs">
334          <button class="active" data-tab="tools">Tools</button>
335          <button data-tab="stack">Models</button>
336          <button data-tab="about">Guide</button>
337        </div>
338        <section id="tab-tools" class="tab active">
339          <div class="card">
340            <h2>Forward Generation</h2>
341            <p class="subtle">Generate a plausible crystal candidate from constraints, then trace encoder, diffusion, and multimodal agreement.</p>
342            <label>Forward use case<textarea id="prompt" rows="3">Generate a thermoelectric telluride with stable local geometry</textarea></label>
343            <div class="row" style="margin-top:10px">
344              <label>Allowed elements<input id="elements" value="La Ge Te"></label>
345              <label>Samples<input id="samples" type="number" min="4" max="64" value="12"></label>
346            </div>
347            <label style="margin-top:10px">Forward constraints<input id="forward-constraints" value="baseline geometry pass, conservative lattice, DFT triage candidate"></label>
348            <label style="margin-top:10px">Novelty temperature <span id="novelty-value">0.42</span><input id="novelty" type="range" min="0" max="1" step="0.01" value="
3480.42"></label>
349            <label style="margin-top:10px">Lattice strictness <span id="lattice-value">0.72</span><input id="lattice" type="range" min="0" max="1" step="0.01" value="0.72"></label>
350            <div class="row" style="margin-top:10px">
351              <button id="generate">Build Stack</button>
352              <button id="reset-view">Reset View</button>
353            </div>
354          </div>
355          <div class="card">
356            <h2>Reverse Pool Search</h2>
357            <p class="subtle">Upload or paste a CSV, JSON, or JSONL candidate pool. The stack goes in reverse: parse candidates, score them against the use case, then rank what should be inspected first.</p>
358            <label>Reverse use case<input id="target" value="stable ion conductor with conservative geometry"></label>
359            <label style="margin-top:10px">Pool file (.csv, .json, .jsonl)<input id="pool-file" type="file" accept=".csv,.json,.jsonl,application/json,text/csv,application/jsonl"></label>
360            <label style="margin-top:10px">Candidate pool<textarea id="pool" rows="6">Li P S Cl | sulfide electrolyte
361La Ge Te | thermoelectric
362Na Zr O | oxide conductor
363Li Zr O Cl | stable halide conductor</textarea></label>
364            <label style="margin-top:10px">Reverse constraints<input id="reverse-constraints" value="physical plausibility, evidence support, application fit"></label>
365            <button id="rank" style="margin-top:10px;width:100%">Rank Pool</button>
366          </div>
367          <div class="card">
368            <h2>Viewer Knobs</h2>
369            <div class="row">
370              <button id="rotate">Rotate: on</button>
371              <button id="bonds">Bonds: on</button>
372              <button id="cell">Cell: on</button>
373              <button id="sample-dist">Sample 512</button>
374            </div>
375          </div>
376        </section>
377        <section id="tab-stack" class="tab">
378          <div class="card">
379            <h2>Realtime Inference Pass</h2>
380            <div class="stack-step active"><b>1. Physics Encoder</b><span id="encoder-text">Validates atoms, local distances, lattice volume, and manifold score.</span></div>
381            <div class="stack-step active"><b>2. Diffusion Generator</b><span id="diffusion-text">Repairs noisy coordinates and proposes candidate variants under constraints.</span></div>
382            <div class="stack-step active"><b>3. Multimodal Controller</b><span id="mm-text">Adds application context and evidence while flagging unsupported claims.</span></div>
383            <div class="stack-step"><b>4. Decision Layer</b><span id="decision-text">Selects candidates for DFT follow-up or reverse ranking.</span></div>
384          </div>
385          <div class="card">
386            <h2>Adapter Contract</h2>
387            <p class="subtle">This app is wired so local demo functions can be replaced with real endpoints: `encode(structure)`, `diffuse(prompt, constraints)`, `ground(candidate, context)`, and `rank(pool, target)`.</p>
388          </div>
389        </section>
390        <section id="tab-about" class="tab">
391          <div class="card">
392            <h2>How To Use This App</h2>
393            <p class="subtle">Use <b>Forward Generation</b> when you want the stack to propose a new material from a design intent and element constraints. Use <b>Reverse Pool Search</b> when you already have candidates and want the stack to rank them against a target application.</p>
394            <p class="subtle">The 3D view is inspection-first: drag to rotate, scroll to zoom, hover atoms for species details, and toggle bonds or the unit cell when the structure gets dense.</p>
395          </div>
396          <div class="card">
397            <h2>What It Can Do</h2>
398            <p class="subtle">It can sketch candidate crystals, show basic geometry health, expose candidate distributions, rank a pool, and explain which model lane is responsible for each part of the decision.</p>
399          </div>
400          <div class="card">
401            <h2>Limits</h2>
402            <p class="subtle">This is a UI/controller render. It is not a DFT result and should not be treated as proof of stability. Production use should plug in the frozen encoder, diffusion checkpoint, multimodal evidence model, and downstream DFT triage.</p>
403          </div>
404        </section>
405      </aside>
406    </main>
407    <footer><span>Nexa_Mat Gen Stack · local app.py server</span><span>La / Ge / Te seed candidate · Three.js visual retained</span></footer>
408  </div>
409  
409<script type="importmap">
410    {
411      "imports": {
412        "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"
413      }
414    }
415  </script>
415
416  
416<script type="module">
417    import * as THREE from "three";
418    import { OrbitControls } from "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/controls/OrbitControls.js";
419
420    const elements = {
421      La: { z: 57, name: "Lanthanum", color: "#ffbb00", radius: 1.87 },
422      Ge: { z: 32, name: "Germanium", color: "#c050ff", radius: 1.22 },
423      Te: { z: 52, name: "Tellurium", color: "#00ccff", radius: 1.40 },
424      Li: { z: 3, name: "Lithium", color: "#7c6cff", radius: 1.28 },
425      O: { z: 8, name: "Oxygen", color: "#ef4444", radius: 0.66 },
426      S: { z: 16, name: "Sulfur", color: "#eab308", radius: 1.05 },
427      P: { z: 15, name: "Phosphorus", color: "#f97316", radius: 1.07 },
428      Na: { z: 11, name: "Sodium", color: "#38bdf8", radius: 1.66 },
429      Zr: { z: 40, name: "Zirconium", color: "#94a3b8", radius: 1.60 },
430      Cl: { z: 17, name: "Chlorine", color: "#22c55e", radius: 1.02 }
431    };
432    const baseAtoms = [
433      [2.5,1,0.5],[5.5,1,0.5],[2.5,4.5,0.5],[5.5,4.5,3.5],[5.5,4.5,6.5],[5.5,7,6.5],[5.5,7,7.5],
434      [5.5,7,8.5],[8,6.5,7.5],[8,6.5,6.5],[8,8.5,8.5],[5.5,9.5,9.5],[5.5,1,2],[8,7,8],
435      [5.5,7,5],[5.5,1.5,0.5],[10.5,1.5,2],[5.5,9.5,10.5],[7,1.5,0.5],[7,9.5,9.5],[7,9.5,10.5]
436    ];
437    const latticeBase = { a: [10.5,0,0], b: [2.5,10.5,0], c: [0,2.5,10.5] };
438    let state = { autoRotate: true, showBonds: true, showCell: true, structure: null };
439    let scene, camera, renderer, controls, root, raycaster, pointer;
440    const atomMeshes = [];
441
442    function seedHash(text) {
443      let h = 2166136261;
444      for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
445      return h >>> 0;
446    }
447    function rng(seed) {
448      let t = seed + 0x6D2B79F5;
449      return () => {
450        t = Math.imul(t ^ t >>> 15, t | 1);
451        t ^= t + Math.imul(t ^ t >>> 7, t | 61);
452        return ((t ^ t >>> 14) >>> 0) / 4294967296;
453      };
454    }
455    function parseElements(raw) {
456      const found = raw.replaceAll(",", " ").replaceAll("+", " ").split(/\s+/).map(x => x.trim()).filter(Boolean)
457        .map(x => x[0]?.toUpperCase() + x.slice(1).toLowerCase()).filter(x => elements[x]);
458      return [...new Set(found)].length ? [...new Set(found)] : ["La", "Ge", "Te"];
459    }
460    function buildStructure(prompt, rawElements, novelty, strictness, index = 0) {
461      const selected = parseElements(rawElements);
462      const rand = rng(seedHash(`${prompt}|${rawElements}|${novelty}|${strictness}|${index}`));
463      const jitter = 0.04 + novelty * 0.22;
464      const scale = 0.94 + strictness * 0.12;
465      const atoms = baseAtoms.map((p, i) => {
466        const symbol = selected[i % selected.length];
467        const meta = elements[symbol];
468        return {
469          id: i,
470          symbol,
471          name: meta.name,
472          atomicNumber: meta.z,
473          color: meta.color,
474          radius: meta.radius,
475          x: +(p[0] * scale + (rand() - .5) * jitter).toFixed(3),
476          y: +(p[1] * scale + (rand() - .5) * jitter).toFixed(3),
477          z: +(p[2] * scale + (rand() - .5) * jitter).toFixed(3)
478        };
479      });
480      const lattice = Object.fromEntries(Object.entries(latticeBase).map(([k, vec]) => [k, vec.map(v => +(v * scale + (rand() - .5) * 0.04 * (1 - strictness)).toFixed(3))]));
481      const metrics = scoreStructure(atoms, novelty, strictness);
482      const formula = formulaFromAtoms(atoms);
483      const family = selected.some(x => ["Li","Na","S","P","Cl"].includes(x)) ? "ion-conductor candidate" : "inorganic discovery candidate";
484      return { atoms, lattice, metrics, formula, family, selected };
485    }
486    function formulaFromAtoms(atoms) {
487      const counts = {};
488      atoms.forEach(a => counts[a.symbol] = (counts[a.symbol] || 0) + 1);
489      return Object.entries(counts).map(([k,v]) => `${k}${v > 1 ? v : ""}`).join("");
490    }
491    function dist(a,b) { return Math.hypot(a.x-b.x, a.y-b.y, a.z-b.z); }
492    function scoreStructure(atoms, novelty, strictness) {
493      const ds = [];
494      for (let i=0; i<atoms.length; i++) for (let j=i+1; j<atoms.length; j++) ds.push(dist(atoms[i], atoms[j]));
495      const minD = Math.min(...ds);
496      const violations = ds.filter(d => d < .85).length / ds.length;
497      const valid = minD >= .85 ? 1 : 0;
498      const encoder = clamp(.58 + .22 * valid + .12 * strictness - .05 * novelty);
499      const diffusion = clamp(.54 + .16 * Math.min(1, minD / 1.3) + .12 * (1 - Math.abs(novelty - .5)));
500      const evidence = clamp(.50 + .20 * encoder + .10 * strictness);
501      const stack = clamp(encoder * .42 + diffusion * .36 + evidence * .22);
502      return { minD, violations, valid, encoder, diffusion, evidence, stack };
503    }
504    function clamp(x) { return Math.max(0, Math.min(1, x)); }
505    function fmt(x) { return Number(x).toFixed(3); }
506
507    function initScene() {
508      root = document.getElementById("viewer");
509      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
510      renderer.setSize(root.clientWidth, root.clientHeight);
511      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
512      root.appendChild(renderer.domElement);
513      scene = new THREE.Scene();
514      camera = new THREE.PerspectiveCamera(45, root.clientWidth / root.clientHeight, .1, 500);
515      camera.position.set(24, 20, 24);
516      camera.lookAt(0, 0, 0);
517      controls = new OrbitControls(camera, renderer.domElement);
518      controls.target.set(0, 0, 0);
519      controls.enableDamping = true;
520      controls.enableZoom = false;
521      controls.enablePan = false;
522      controls.autoRotate = true;
523      controls.autoRotateSpeed = .8;
524      raycaster = new THREE.Raycaster();
525      pointer = new THREE.Vector2();
526      renderer.domElement.addEventListener("mousemove", onPointerMove);
527      renderer.domElement.addEventListener("mouseleave", hideAtomTip);
528      renderer.domElement.addEventListener("wheel", event => {
529        event.preventDefault();
530        event.stopPropagation();
531      }, { passive: false });
532      window.addEventListener("wheel", event => {
533        if (event.target && event.target.closest && event.target.closest(".viewer-wrap")) {
534          event.preventDefault();
535          event.stopPropagation();
536        }
537      }, { passive: false });
538      window.addEventListener("touchmove", event => {
539        if (event.target && event.target.closest && event.target.closest(".viewer-wrap")) {
540          event.preventDefault();
541          event.stopPropagation();
542        }
543      }, { passive: false });
544      scene.add(new THREE.AmbientLight(0xffffff, 1.15));
545      const key = new THREE.DirectionalLight(0xffffff, 1.9); key.position.set(20, 30, 20); scene.add(key);
546      const violet = new THREE.PointLight(0xc050ff, 2.0, 50); violet.position.set(-8, 10, -5); scene.add(violet);
547      const cyan = new THREE.PointLight(0x00ccff, 1.8, 50); cyan.position.set(15, -5, 15); scene.add(cyan);
548      window.addEventListener("resize", onResize);
549      animate();
550    }
551    function clearScene() {
552      while (scene.children.length > 0) scene.remove(scene.children[0]);
553      atomMeshes.length = 0;
554      scene.add(new THREE.AmbientLight(0xffffff, 1.15));
555      const key = new THREE.DirectionalLight(0xffffff, 1.9); key.position.set(20, 30, 20); scene.add(key);
556      const violet = new THREE.PointLight(0xc050ff, 2.0, 50); violet.position.set(-8, 10, -5); scene.add(violet);
557      const cyan = new THREE.PointLight(0x00ccff, 1.8, 50); cyan.position.set(15, -5, 15); scene.add(cyan);
558    }
559    function renderStructure(structure) {
560      state.structure = structure;
561      clearScene();
562      camera.lookAt(0, 0, 0);
563      controls.target.set(0, 0, 0);
564      const themeLight = document.documentElement.dataset.theme === "light";
565      scene.fog = new THREE.FogExp2(themeLight ? 0xf6f8ff : 0x04040c, 0.018);
566      const grid = new THREE.GridHelper(42, 42, themeLight ? 0xcbd5e1 : 0x1a1a4a, themeLight ? 0xe2e8f0 : 0x0d0d2a);
567      grid.position.set(0, -5.5, 0); scene.add(grid);
568      const center = new THREE.Vector3(6.5, 5.5, 5.5);
569      const point = atom => new THREE.Vector3(atom.x, atom.y, atom.z).sub(center);
570      structure.atoms.forEach(atom => {
571        const col = new THREE.Color(atom.color);
572        const mesh = new THREE.Mesh(new THREE.SphereGeometry(atom.radius * .38, 32, 32), new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: .45, roughness: .18, metalness: .45 }));
573        mesh.position.copy(point(atom)); mesh.userData = atom; scene.add(mesh); atomMeshes.push(mesh);
574        const glow = new THREE.Mesh(new THREE.SphereGeometry(atom.radius * .55, 16, 16), new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: .08, side: THREE.BackSide }));
575        mesh.add(glow);
576      });
577      if (state.showBonds) {
578        for (let i=0; i<structure.atoms.length; i++) for (let j=i+1; j<structure.atoms.length; j++) {
579          const a = point(structure.atoms[i]), b = point(structure.atoms[j]), d = a.distanceTo(b);
580          if (d < 4.2) {
581            const dir = b.clone().sub(a);
582            const mesh = new THREE.Mesh(new THREE.CylinderGeometry(.055, .055, d, 8, 1), new THREE.MeshStandardMaterial({ color: 0x334155, transparent: true, opacity: .48 }));
583            mesh.position.copy(a.clone().lerp(b, .5));
584            mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
585            scene.add(mesh);
586          }
587        }
588      }
589      if (state.showCell) drawCell(structure.lattice, center);
590      const axes = new THREE.AxesHelper(3.2);
591      axes.position.set(-6.5, -5.5, -5.5);
592      scene.add(axes);
593      updatePanels(structure);
594    }
595    function onPointerMove(event) {
596      if (!raycaster || !camera || atomMeshes.length === 0) return;
597      const rect = renderer.domElement.getBoundingClientRect();
598      pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
599      pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
600      raycaster.setFromCamera(pointer, camera);
601      const hits = raycaster.intersectObjects(atomMeshes, false);
602      if (!hits.length) { hideAtomTip(); return; }
603      const atom = hits[0].object.userData;
604      const tip = document.getElementById("atom-tooltip");
605      tip.style.display = "block";
606      tip.style.left = `${event.clientX - rect.left + 16}px`;
607      tip.style.top = `${event.clientY - rect.top + 14}px`;
608      tip.innerHTML = `<b style="color:${atom.color}">${atom.symbol} — ${atom.name}</b><span>Z=${atom.atomicNumber} · atom #${atom.id + 1}</span><br><span>coord (${fmt(atom.x)}, ${fmt(atom.y)}, ${fmt(atom.z)}) Å</span><br><span>radius ${fmt(atom.radius)} Å</span>`;
609      document.getElementById("hover-label").textContent = `${atom.symbol} Z=${atom.atomicNumber} · (${fmt(atom.x)}, ${fmt(atom.y)}, ${fmt(atom.z)}) Å`;
610    }
611    function hideAtomTip() {
612      const tip = document.getElementById("atom-tooltip");
613      if (tip) tip.style.display = "none";
614      const label = document.getElementById("hover-label");
615      if (label) label.textContent = "hover atoms for species";
616    }
617    function drawCell(lattice, center) {
618      const av = new THREE.Vector3(...lattice.a), bv = new THREE.Vector3(...lattice.b), cv = new THREE.Vector3(...lattice.c);
619      const o = center.clone().multiplyScalar(-1);
620      const c = [o.clone(), o.clone().add(av), o.clone().add(bv), o.clone().add(cv), o.clone().add(av).add(bv), o.clone().add(av).add(cv), o.clone().add(bv).add(cv), o.clone().add(av).add(bv).add(cv)];
621      [[0,1],[0,2],[0,3],[1,4],[1,5],[2,4],[2,6],[3,5],[3,6],[4,7],[5,7],[6,7]].forEach(([i,j]) => {
622        scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([c[i], c[j]]), new THREE.LineBasicMaterial({ color: 0x4466ff, transparent: true, op
622acity: .65 })));
623      });
624    }
625    function metricRow(name, value, goodHigh = true) {
626      const pct = Math.max(0, Math.min(100, value * 100));
627      const color = goodHigh ? "linear-gradient(90deg, var(--blue), var(--cyan))" : "linear-gradient(90deg, var(--gold), var(--red))";
628      return `<div class="metric"><span>${name}</span><b>${fmt(value)}</b><div class="bar"><div class="fill" style="width:${pct}%;background:${color}"></div></div></div>`;
629    }
630    function updatePanels(s) {
631      document.getElementById("formula-pill").textContent = s.formula;
632      document.getElementById("score-pill").textContent = `stack ${fmt(s.metrics.stack)}`;
633      document.getElementById("mind-pill").textContent = `min d ${fmt(s.metrics.minD)} Å`;
634      document.getElementById("metrics").innerHTML = [
635        metricRow("Stack agreement", s.metrics.stack),
636        metricRow("Physics encoder", s.metrics.encoder),
637        metricRow("Diffusion repair", s.metrics.diffusion),
638        metricRow("Evidence grounding", s.metrics.evidence),
639        metricRow("Distance violations", s.metrics.violations, false),
640        `<p class="subtle">Formula <b>${s.formula}</b><br>Family ${s.family}<br>Geometry ${s.metrics.valid ? "passes baseline" : "needs review"}</p>`
641      ].join("");
642      document.getElementById("structure-explanation").innerHTML = `The current candidate is <b>${s.formula}</b>. The encoder lane reads local distances and lattice geometry, the diffusion lane proposes a repaired crystal arrangement, and the multimodal lane turns that into an evidence packet for screening. This candidate is a <b>${s.family}</b> with baseline geometry marked <b>${s.metrics.valid ? "pass" : "review"}</b>.`;
643      document.getElementById("applications").innerHTML = applicationText(s);
644      document.getElementById("encoder-text").textContent = `Physical manifold score ${fmt(s.metrics.encoder)} with min distance ${fmt(s.metrics.minD)} Å.`;
645      document.getElementById("diffusion-text").textContent = `Candidate repair score ${fmt(s.metrics.diffusion)} under ${s.selected.join(", ")} constraints.`;
646      document.getElementById("mm-text").textContent = `Evidence score ${fmt(s.metrics.evidence)}. Unsupported claims remain marked as screening hypotheses.`;
647      document.getElementById("decision-text").textContent = `Stack score ${fmt(s.metrics.stack)}: ${s.metrics.stack > .74 ? "promote to DFT triage" : "keep as candidate, needs more evidence"}.`;
648    }
649    function applicationText(s) {
650      const set = new Set(s.selected);
651      if (set.has("Li") || set.has("Na")) {
652        return "Best-fit use case: solid electrolyte or ion-transport screening. Treat high-scoring outputs as candidates for DFT relaxation, migration-barrier checks, and interface stability review.";
653      }
654      if (set.has("Te") || set.has("Ge")) {
655        return "Best-fit use case: thermoelectric, phase-change, or narrow-gap inorganic screening. The app can surface candidates, but band structure and stability still require downstream physics validation.";
656      }
657      return "Best-fit use case: broad inorganic discovery. Use the stack score to triage candidates, then push promising structures into DFT or domain-specific adapters.";
658    }
659    let uploadedPoolRows = [];
660    let gradioClientPromise = null;
661    let progressTimer = null;
662    let progressStartedAt = 0;
663    let forwardCandidates = [];
664    let selectedCandidateIndex = 0;
665    let forwardCandidateSource = "backend";
666    const progressStages = {
667      forward: ["Encoding constraints", "Sampling diffusion candidates", "Repairing geometry", "Grounding evidence", "Rendering structure"],
668      reverse: ["Parsing candidate pool", "Encoding target use case", "Scoring physical fit", "Grounding evidence", "Ranking candidates"]
669    };
670    function setControlsBusy(isBusy) {
671      ["generate", "rank", "sample-dist", "prev-candidate", "next-candidate"].forEach(id => {
672        const el = document.getElementById(id);
673        if (el) el.disabled = isBusy;
674      });
675    }
676    function setForwardCandidates(candidates, sourceLabel = "backend") {
677      forwardCandidates = candidates || [];
678      forwardCandidateSource = sourceLabel;
679      selectedCandidateIndex = 0;
680      selectForwardCandidate(0);
681    }
682    function selectForwardCandidate(index) {
683      if (!forwardCandidates.length) {
684        document.getElementById("candidate-count").textContent = "No samples yet";
685        document.getElementById("candidates").innerHTML = "";
686        return;
687      }
688      selectedCandidateIndex = (index + forwardCandidates.length) % forwardCandidates.length;
689      const candidate = forwardCandidates[selectedCandidateIndex];
690      renderStructure(candidate);
691      renderCandidateList();
692    }
693    function renderCandidateList() {
694      const total = forwardCandidates.length;
695      document.getElementById("candidate-count").textContent = total ? `Sample ${selectedCandidateIndex + 1} of ${total}` : "No samples yet";
696      document.getElementById("candidates").innerHTML = forwardCandidates.map((c, i) => {
697        const active = i === selectedCandidateIndex ? " selected" : "";
698        return `<div class="candidate pickable${active}" role="button" tabindex="0" data-candidate-index="${i}"><span class="rank">#${i+1}</span><span>${escapeHtml(c.formula)}<br><small class="subtle">${escapeHtml(c.family)} · min d ${fmt(c.metrics.minD)} Å · ${forwardCandidateSource}</small></span><span class="score">${fmt(c.metrics.stack)}</span></div>`;
699      }).join("");
700      document.querySelectorAll("[data-candidate-index]").forEach(row => {
701        row.addEventListener("click", () => selectForwardCandidate(Number(row.dataset.candidateIndex)));
702        row.addEventListener("keydown", event => {
703          if (event.key === "Enter" || event.key === " ") {
704            event.preventDefault();
705            selectForwardCandidate(Number(row.dataset.candidateIndex));
706          }
707        });
708      });
709    }
710    function setProgress(value, stage) {
711      const pct = Math.max(0, Math.min(100, Math.round(value)));
712      document.getElementById("progress-fill").style.width = `${pct}%`;
713      document.getElementById("progress-percent").textContent = `${pct}%`;
714      if (stage) document.getElementById("progress-stage").textContent = stage;
715    }
716    function startProgress(mode) {
717      clearInterval(progressTimer);
718      progressStartedAt = performance.now();
719      const stages = progressStages[mode] || progressStages.forward;
720      const panel = document.getElementById("run-progress");
721      document.getElementById("progress-title").textContent = mode === "reverse" ? "Ranking candidate pool" : "Generating candidate";
722      panel.classList.add("active");
723      setControlsBusy(true);
724      setProgress(7, stages[0]);
725      let pct = 7;
726      progressTimer = setInterval(() => {
727        pct = Math.min(92, pct + 4 + Math.random() * 7);
728        const stage = stages[Math.min(stages.length - 1, Math.floor((pct / 100) * stages.length))];
729        setProgress(pct, stage);
730      }, 180);
731    }
732    async function finishProgress(message = "Ready") {
733      clearInterval(progressTimer);
734      setProgress(100, message);
735      const elapsed = performance.now() - progressStartedAt;
736      if (elapsed < 650) await new Promise(resolve => setTimeout(resolve, 650 - elapsed));
737      await new Promise(resolve => setTimeout(resolve, 220));
738      document.getElementById("run-progress").classList.remove("active");
739      setControlsBusy(false);
740    }
741    async function callQueuedModelStack(path, payload) {
742      const mode = path.includes("reverse") ? "reverse" : "forward";
743      const fullPayload = { ...payload, mode };
744      if (!gradioClientPromise) {
745        gradioClientPromise = import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js")
746          .then(({ Client }) => Client.connect(window.location.origin));
747      }
748      const client = await gradioClientPromise;
749      const result = await client.predict("/model_stack", { payload_json: JSON.stringify(fullPayload) });
750      const raw = Array.isArray(result?.data) ? result.data[0] : result?.data;
751      return typeof raw === "string" ? JSON.parse(raw) : raw;
752    }
753    async function postJson(path, payload) {
754      try {
755        return await callQueuedModelStack(path, payload);
756      } catch (queuedErr) {
757        const response = await fetch(path, {
758          method: "POST",
759          headers: { "Content-Type": "application/json" },
760          body: JSON.stringify(payload)
761        });
762        if (!response.ok) throw queuedErr || new Error(`${path} failed with ${response.status}`);
763        return await response.json();
764      }
765    }
766    async function runForward() {
767      const prompt = document.getElementById("prompt").value;
768      const constraints = document.getElementById("forward-constraints").value;
769      const raw = document.getElementById("elements").value;
770      const n = Number(document.getElementById("samples").value);
771      const novelty = Number(document.getElementById("novelty").value);
772      const strictness = Number(document.getElementById("lattice").value);
773      document.getElementById("run-state").textContent = "ZeroGPU backend: encoder → diffusion → multimodal";
774      startProgress("forward");
775      try {
776        const result = await postJson("/api/forward", { prompt, constraints, elements: raw, samples: n, novelty, strictness });
777        const candidates = result.candidates || [];
778        setForwardCandidates(candidates.length ? candidates : [result.structure], "backend ranked");
779        document.getElementById("decision-text").textContent += ` Cross-attention: ${result.cross_attention?.status || "adapter ready"}.`;
780        await finishProgress("Candidate rendered");
781      } catch (err) {
782        document.getElementById("run-state").textContent = "backend failed, local fallback rendered";
783        const candidates = [...Array(n)].map((_, i) => buildStructure(`${prompt} | ${constraints}`, raw, novelty, strictness, i)).sort((a,b) => b.metrics.stack - a.metrics.stack);
784        setForwardCandidates(candidates, "local fallback");
785        await finishProgress("Fallback rendered");
786      }
787      setTimeout(() => document.getElementById("run-state").textContent = "ready", 700);
788    }
789    async function runReverse() {
790      const target = document.getElementById("target").value;
791      const reverseConstraints = document.getElementById("reverse-constraints").value;
792      const strictness = Number(document.getElementById("lattice").value);
793      const pastedRows = document.getElementById("pool").value.split("\n").map(x => x.trim()).filter(Boolean);
794      const rows = uploadedPoolRows.length ? uploadedPoolRows : pastedRows;
795      document.getElementById("run-state").textContent = "ZeroGPU backend: reverse ranking pool";
796      startProgress("reverse");
797      try {
798        const result = await postJson("/api/reverse", { target, constraints: reverseConstraints, strictness, rows });
799        const ranked = result.ranked || [];
800        renderStructure(result.structure);
801        document.getElementById("reverse-results").innerHTML = ranked.map((r, i) => `<div class="candidate"><span class="rank">#${i+1}</span><span>${escapeHtml(r.row)}<br><small class="subtle">${r.structure.formula} · min d ${fmt(r.structure.metrics.minD)} Å · ${escapeHtml(r.notes || "pool candidate")} · backend ranked</small></span><span class="score">${fmt(r.score)}</span></div>`).join("");
802        document.getElementById("decision-text").textContent += ` Reverse cross-attention: ${result.cross_attention?.status || "adapter ready"}.`;
803        await finishProgress("Pool ranked");
804      } catch (err) {
805        const ranked = rows.map((row, i) => {
806          const normalized = normalizePoolRow(row);
807          const structure = buildStructure(`${target} | ${reverseConstraints}`, normalized.elements, .38, strictness, i + 100);
808          const haystack = `${normalized.label} ${normalized.notes}`.toLowerCase();
809          const bonus = haystack.split(/\W+/).some(t => t && `${target} ${reverseConstraints}`.toLowerCase().includes(t)) ? .08 : 0;
810          return { row: normalized.label, structure, score: clamp(structure.metrics.stack + bonus), notes: normalized.notes };
811        }).sort((a,b) => b.score - a.score);
812        renderStructure(ranked[0].structure);
813        document.getElementById("reverse-results").innerHTML = ranked.map((r, i) => `<div class="candidate"><span class="rank">#${i+1}</span><span>${escapeHtml(r.row)}<br><small class="subtle">${r.structure.formula} · min d ${fmt(r.structure.metrics.minD)} Å · ${escapeHtml(r.notes || "pool candidate")} · local fallback</small></span><span class="score">${fmt(r.score)}</span></div>`).join("");
814        await finishProgress("Fallback ranked");
815      }
816      setTimeout(() => document.getElementById("run-state").textContent = "ready", 700);
817    }
818    function normalizePoolRow(row) {
819      if (typeof row === "string") {
820        const symbols = row.split(/[\s|,;]+/).filter(x => elements[toSymbol(x)]).map(toSymbol).join(" ");
821        return { label: row, elements: symbols || row, notes: row };
822      }
823      const label = row.formula || row.composition || row.material || row.name || row.id || JSON.stringify(row);
824      const notes = row.notes || row.description || row.application || row.use_case || row.target || "";
825      const rawElements = row.elements || row.allowed_elements || row.formula || row.composition || label;
826      const symbols = String(rawElements).split(/[\s|,;+\-_\d]+/).filter(x => elements[toSymbol(x)]).map(toSymbol).join(" ");
827      return { label: String(label), elements: symbols || String(rawElements), notes: String(notes) };
828    }
829    function toSymbol(x) {
830      if (!x) return "";
831      return String(x).trim().replace(/[^a-zA-Z]/g, "").replace(/^([a-z])/, m => m.toUpperCase()).replace(/^([A-Z])([A-Z]+)/, (_, a, b) => a + b.toLowerCase());
832    }
833    function parseCsv(text) {
834      const lines = text.split(/\r?\n/).filter(Boolean);
835      if (!lines.length) return [];
836      const headers = splitCsvLine(lines[0]).map(h => h.trim());
837      const hasHeader = headers.some(h => /formula|composition|material|name|application|notes|elements/i.test(h));
838      if (!hasHeader) return lines.map(line => splitCsvLine(line).join(" | "));
839      return lines.slice(1).map(line => {
840        const cells = splitCsvLine(line);
841        return Object.fromEntries(headers.map((h, i) => [h, cells[i] || ""]));
842      });
843    }
844    function splitCsvLine(line) {
845      const cells = [];
846      let cur = "", quote = false;
847      for (const ch of line) {
848        if (ch === '"') quote = !quote;
849        else if (ch === "," && !quote) { cells.push(cur); cur = ""; }
850        else cur += ch;
851      }
852      cells.push(cur);
853      return cells.map(c => c.trim());
854    }
855    function parsePoolFile(name, text) {
856      const lower = name.toLowerCase();
857      if (lower.endsWith(".jsonl")) return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
858      if (lower.endsWith(".json")) {
859        const parsed = JSON.parse(text);
860        return Array.isArray(parsed) ? parsed : (parsed.candidates || parsed.materials || parsed.rows || [parsed]);
861      }
862      return parseCsv(text);
863    }
864    function escapeHtml(text) {
865      return String(text).replace(/[&<>"']/g, ch => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[ch]));
866    }
867    function sampleDistribution() {
868      const prompt = document.getElementById("prompt").value, raw = document.getElementById("elements").value;
869      const novelty = Number(document.getElementById("novelty").value), strictness = Number(document.getElementById("lattice").value);
870      const values = [...Array(512)].map((_, i) => buildStructure(prompt, raw, novelty, strictness, i).metrics.stack);
871      const mean = values.reduce((a,b) => a+b, 0) / values.length;
872      const sd = Math.sqrt(values.reduce((a,b) => a + (b-mean)**2, 0) / values.length);
873      document.getElementById("candidates").innerHTML = `<p class="subtle">512-sample distribution<br>mean stack=${fmt(mean)} · std=${fmt(sd)} · min=${fmt(Math.min(...values))} · max=${fmt(Math.max(...values))}</p>`;
874    }
875    function onResize() { camera.aspect = root.clientWidth / root.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(root.clientWidth, root.clientHeight); }
876    function animate() { requestAnimationFrame(animate); controls.autoRotate = state.autoRotate; controls.update(); renderer.render(scene, camera); }
877    document.querySelectorAll(".tabs button").forEach(btn => btn.addEventListener("click", () => {
878      document.querySelectorAll(".tabs button").forEach(b => b.classList.remove("active"));
879      document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
880      btn.classList.add("active"); document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
881    }));
882    document.getElementById("theme-btn").onclick = () => {
883      const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
884      document.documentElement.dataset.theme = next;
885      document.getElementById("theme-btn").textContent = next === "dark" ? "Light mode" : "Dark mode";
886      renderStructure(state.structure);
887    };
888    document.getElementById("generate").onclick = runForward;
889    document.getElementById("rank").onclick = runReverse;
890    document.getElementById("pool-file").onchange = async event => {
891      const file = event.target.files?.[0];
892      if (!file) return;
893      document.getElementById("run-state").textContent = "parsing reverse pool file";
894      try {
895        const text = await file.text();
896        uploadedPoolRows = parsePoolFile(file.name, text);
897        document.getElementById("pool").value = uploadedPoolRows.slice(0, 24).map(row => normalizePoolRow(row).label).join("\n");
898        document.getElementById("reverse-results").innerHTML = `<p class="subtle">Loaded ${uploadedPoolRows.length} pool rows from ${escapeHtml(file.name)}. Press Rank Pool to run reverse search.</p>`;
899      } catch (err) {
900        uploadedPoolRows = [];
901        document.getElementById("reverse-results").innerHTML = `<p class="subtle" style="color:var(--red)">Could not parse file: ${escapeHtml(err.message || err)}</p>`;
902      } finally {
903        document.getElementById("run-state").textContent = "ready";
904      }
905    };
906    document.getElementById("sample-dist").onclick = sampleDistribution;
907    document.getElementById("prev-candidate").onclick = () => selectForwardCandidate(selectedCandidateIndex - 1);
908    document.getElementById("next-candidate").onclick = () => selectForwardCandidate(selectedCandidateIndex + 1);
909    document.getElementById("rotate").onclick = e => { state.autoRotate = !state.autoRotate; e.target.textContent = `Rotate: ${state.autoRotate ? "on" : "off"}`; };
910    document.getElementById("bonds").onclick = e => { state.showBonds = !state.showBonds; e.target.textContent = `Bonds: ${state.showBonds ? "on" : "off"}`; renderStructure(state.structure); };
911    document.getElementById("cell").onclick = e => { state.showCell = !state.showCell; e.target.textContent = `Cell: ${state.showCell ? "on" : "off"}`; renderStructure(state.structure); };
912    document.getElementById("reset-view").onclick = () => { camera.position.set(24,20,24); camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update(); };
913    document.getElementById("novelty").oninput = e => document.getElementById("novelty-value").textContent = e.target.value;
914    document.getElementById("lattice").oninput = e => document.getElementById("lattice-value").textContent = e.target.value;
915    initScene();
916    runForward();
917  </script>
917
918</body>
919</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.