Babu09 commited on
Commit
32e03e5
·
verified ·
1 Parent(s): eeada9e

Deploy MEIDNet Studio

Browse files
meidnet/studio/landing.html CHANGED
@@ -72,13 +72,22 @@ h1.mh .ln{display:block}h1.mh .for{font-weight:700;letter-spacing:-.01em}
72
  @media(max-width:560px){.method{grid-template-columns:repeat(2,minmax(0,1fr))}.method div:nth-child(odd){border-left:0}.method div:nth-child(4){border-left:1px solid var(--line)}.method div:nth-child(n+3){border-top:1px solid var(--line)}.method div:nth-child(odd)::before{display:none}.method div:nth-child(4)::before{display:block}}
73
  .frac{display:inline-flex;flex-direction:column;align-items:center;vertical-align:middle;margin:0 .15em;line-height:1.15}
74
  .frac>span:first-child{border-bottom:1px solid currentColor;padding:0 .2em .05em}.frac>span:last-child{padding:.08em .2em 0}
75
- .video{margin:12px 0 28px}.video video{width:100%;height:auto;border-radius:14px;border:1px solid var(--line);background:#000;display:block}
76
- .video figcaption{font-size:14px;color:var(--muted);margin-top:8px}
77
- .video video[hidden]{display:none}
78
- .tour-fallback{position:relative;display:block;border-radius:14px;overflow:hidden;border:1px solid var(--line)}.tour-fallback[hidden]{display:none}
79
- .tour-fallback img{width:100%;height:auto;display:block}
80
- .tour-fallback span{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--p1);color:#fff;font-weight:800;font-size:18px;padding:14px 22px;border-radius:999px;box-shadow:0 8px 30px -8px rgba(0,0,0,.6)}
81
- .tour-fallback:hover span{background:var(--p2)}
 
 
 
 
 
 
 
 
 
82
  .layers{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:8px 0 28px}@media(max-width:900px){.layers{grid-template-columns:1fr 1fr}}@media(max-width:600px){.layers{grid-template-columns:1fr}}
83
  .layers .card h2 small{display:block;font:700 11px/1.4 system-ui;letter-spacing:.12em;text-transform:uppercase;color:color-mix(in oklab,var(--accent,var(--p1)) 72%,var(--eyebrow-mix))}
84
  h2.sec{font-size:24px;margin:0 0 6px}.sec-sub{color:var(--muted);margin:0 0 14px}
@@ -144,16 +153,21 @@ footer{color:var(--faint);font-size:13.5px;padding:10px 0 40px;display:flex;gap:
144
  <div style="--k:var(--c-search)"><b>Validate</b><span class="eq">E<sub>hull</sub>(x<sup>*</sup>) ≤ ε</span></div>
145
  </section>
146
 
147
- <figure class="video" id="tour">
148
- <video id="tour-video" controls muted playsinline preload="metadata" poster="/docs/assets/meidnet_tour_poster.png">
149
- <source src="/docs/assets/meidnet_tour.webm" type="video/webm">
150
- </video>
151
- <a class="tour-fallback" id="tour-fallback" href="/studio/?tour=0" hidden>
152
- <img src="/docs/assets/meidnet_tour_poster.png" alt="MEIDNet in one minute: from the properties you want to candidate crystals">
153
- <span>▶ Play the one-minute tour</span>
154
- </a>
155
- <figcaption>MEIDNet in one minute: data → model → family → rules → targets → search → candidates. The same tour opens on your first visit to the Studio.</figcaption>
156
- </figure>
 
 
 
 
 
157
 
158
  <h2 class="sec">Contents</h2>
159
  <p class="sec-sub">Background on multimodal learning, model architectures, the MEIDNet Studio, datasets, benchmarks and contributed results.</p>
@@ -211,11 +225,6 @@ footer{color:var(--faint);font-size:13.5px;padding:10px 0 40px;display:flex;gap:
211
  // Inside the Hugging Face page this site runs in a frame, and GitHub, doi.org or arXiv refuse to load in one:
212
  // open every link that leaves this site in a new tab.
213
  document.querySelectorAll('a[href^="http"]').forEach(a=>{ try{ if(new URL(a.href).origin!==location.origin){ a.target='_blank'; a.rel='noopener'; } }catch(e){} });
214
- // The tour video is WebM (VP9). A browser that cannot play it gets the poster, linked to the same tour drawn live in the Studio.
215
- const v=document.getElementById('tour-video'), fb=document.getElementById('tour-fallback');
216
- const fallback=()=>{ v.hidden=true; v.removeAttribute('src'); fb.hidden=false; };
217
- if(!v.canPlayType('video/webm; codecs="vp9"') && !v.canPlayType('video/webm')) fallback();
218
- else { v.querySelector('source').addEventListener('error', fallback); v.addEventListener('error', fallback); }
219
  })();
220
  (function(){ const d=document.documentElement; try{ const t=localStorage.getItem('meidnet_theme'); if(t) d.dataset.theme=t; }catch(e){}
221
  const b=document.getElementById('theme'); const isDark=()=>d.dataset.theme==='dark'||(!d.dataset.theme&&matchMedia('(prefers-color-scheme: dark)').matches);
@@ -224,7 +233,9 @@ footer{color:var(--faint);font-size:13.5px;padding:10px 0 40px;display:flex;gap:
224
  document.getElementById('copybib').onclick=async()=>{ const m=document.getElementById('copied'); try{ await navigator.clipboard.writeText(document.getElementById('bib').textContent); m.textContent='copied'; setTimeout(()=>m.textContent='',1500); }catch(e){ m.textContent='select the text below and copy it'; } };
225
  })();
226
  </script>
 
227
  <script src="/docs/assets/prism-hero.js" defer></script>
 
228
  <script src="/ask-prism.js" defer></script>
229
  </body>
230
  </html>
 
72
  @media(max-width:560px){.method{grid-template-columns:repeat(2,minmax(0,1fr))}.method div:nth-child(odd){border-left:0}.method div:nth-child(4){border-left:1px solid var(--line)}.method div:nth-child(n+3){border-top:1px solid var(--line)}.method div:nth-child(odd)::before{display:none}.method div:nth-child(4)::before{display:block}}
73
  .frac{display:inline-flex;flex-direction:column;align-items:center;vertical-align:middle;margin:0 .15em;line-height:1.15}
74
  .frac>span:first-child{border-bottom:1px solid currentColor;padding:0 .2em .05em}.frac>span:last-child{padding:.08em .2em 0}
75
+ .tour3d{margin:4px 0 30px;border:1px solid var(--line);border-top:4px solid var(--t3c,var(--c-family));border-radius:16px;background:var(--card);padding:16px 16px 14px;transition:border-color .3s}
76
+ .tour3d h2{margin:0 0 2px}.tour3d .sec-sub{margin:0}
77
+ .t3-strip{list-style:none;margin:12px 0 10px;padding:0;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}
78
+ .t3-strip button{position:relative;width:100%;border:2px solid var(--bc);background:var(--card);color:color-mix(in oklab,var(--bc) 78%,var(--ink));border-radius:999px;padding:8px 4px;font:700 14.5px/1 var(--display);cursor:pointer;overflow:hidden}
79
+ .t3-strip button.on{background:var(--bc);color:#fff}.t3-strip button:hover{box-shadow:0 4px 14px -8px var(--bc)}
80
+ .t3-strip button i{position:absolute;left:0;bottom:0;height:3px;width:0;background:rgba(255,255,255,.9)}
81
+ .t3-stage{position:relative;aspect-ratio:16/9;border-radius:12px;overflow:hidden;border:1px solid var(--line);background:radial-gradient(60% 70% at 72% 40%,rgba(99,102,241,.08),transparent 70%),radial-gradient(50% 60% at 20% 80%,rgba(20,184,166,.07),transparent 70%),var(--bg)}
82
+ .t3-stage canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
83
+ .t3-cap{font-size:16.5px;line-height:1.5;margin:12px 2px 12px;min-height:3em}
84
+ .t3-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
85
+ .t3-b{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:8px 12px;font:600 14px/1 system-ui,sans-serif;cursor:pointer}
86
+ .t3-b:hover{box-shadow:0 4px 14px -8px rgba(0,0,0,.5)}.t3-b.primary{background:var(--p1);border-color:var(--p1);color:#fff;min-width:100px}
87
+ .t3-prog{flex:1 1 160px;height:8px;border-radius:4px;background:var(--line);cursor:pointer;overflow:hidden}
88
+ .t3-prog i{display:block;height:100%;width:0;background:var(--t3c,var(--p1))}
89
+ .t3-time{font:500 13px/1 var(--mono);color:var(--muted)}.t3-open{font-weight:700;text-decoration:none;margin-left:auto}
90
+ @media(max-width:700px){.tour3d{padding:12px 10px}.t3-strip{grid-template-columns:repeat(4,minmax(0,1fr))}.t3-strip button{font-size:13px;padding:7px 2px}.t3-cap{font-size:15.5px}.t3-open{margin-left:0;width:100%}}
91
  .layers{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:8px 0 28px}@media(max-width:900px){.layers{grid-template-columns:1fr 1fr}}@media(max-width:600px){.layers{grid-template-columns:1fr}}
92
  .layers .card h2 small{display:block;font:700 11px/1.4 system-ui;letter-spacing:.12em;text-transform:uppercase;color:color-mix(in oklab,var(--accent,var(--p1)) 72%,var(--eyebrow-mix))}
93
  h2.sec{font-size:24px;margin:0 0 6px}.sec-sub{color:var(--muted);margin:0 0 14px}
 
153
  <div style="--k:var(--c-search)"><b>Validate</b><span class="eq">E<sub>hull</sub>(x<sup>*</sup>) ≤ ε</span></div>
154
  </section>
155
 
156
+ <section class="tour3d" id="tour3d" aria-labelledby="t3-title">
157
+ <h2 class="sec" id="t3-title">MEIDNet in one minute</h2>
158
+ <p class="sec-sub">From the properties you want to candidate crystals, block by block. Click a block to jump to it.</p>
159
+ <ol class="t3-strip" aria-label="Chapters"></ol>
160
+ <div class="t3-stage"><canvas tabindex="0" role="img" aria-label="Animated tour of MEIDNet in three dimensions: data, model, family, rules, targets, search and candidates. The caption below gives each step in words."></canvas></div>
161
+ <p class="t3-cap" aria-live="polite"></p>
162
+ <div class="t3-bar">
163
+ <button type="button" class="t3-b primary" data-t3="play" aria-label="Play the tour">▶ Play</button>
164
+ <button type="button" class="t3-b" data-t3="prev" aria-label="Previous chapter">◀</button>
165
+ <button type="button" class="t3-b" data-t3="next" aria-label="Next chapter">▶</button>
166
+ <div class="t3-prog" role="slider" tabindex="0" aria-label="Position in the tour, in seconds" aria-valuemin="0" aria-valuemax="63" aria-valuenow="0"><i></i></div>
167
+ <span class="t3-time">0:00</span>
168
+ <a class="t3-open" href="/studio/">Open the Studio →</a>
169
+ </div>
170
+ </section>
171
 
172
  <h2 class="sec">Contents</h2>
173
  <p class="sec-sub">Background on multimodal learning, model architectures, the MEIDNet Studio, datasets, benchmarks and contributed results.</p>
 
225
  // Inside the Hugging Face page this site runs in a frame, and GitHub, doi.org or arXiv refuse to load in one:
226
  // open every link that leaves this site in a new tab.
227
  document.querySelectorAll('a[href^="http"]').forEach(a=>{ try{ if(new URL(a.href).origin!==location.origin){ a.target='_blank'; a.rel='noopener'; } }catch(e){} });
 
 
 
 
 
228
  })();
229
  (function(){ const d=document.documentElement; try{ const t=localStorage.getItem('meidnet_theme'); if(t) d.dataset.theme=t; }catch(e){}
230
  const b=document.getElementById('theme'); const isDark=()=>d.dataset.theme==='dark'||(!d.dataset.theme&&matchMedia('(prefers-color-scheme: dark)').matches);
 
233
  document.getElementById('copybib').onclick=async()=>{ const m=document.getElementById('copied'); try{ await navigator.clipboard.writeText(document.getElementById('bib').textContent); m.textContent='copied'; setTimeout(()=>m.textContent='',1500); }catch(e){ m.textContent='select the text below and copy it'; } };
234
  })();
235
  </script>
236
+ <script src="/docs/assets/prism3d.js" defer></script>
237
  <script src="/docs/assets/prism-hero.js" defer></script>
238
+ <script src="/docs/assets/prism-tour.js" defer></script>
239
  <script src="/ask-prism.js" defer></script>
240
  </body>
241
  </html>
site/assets/prism-hero.js CHANGED
@@ -5,11 +5,7 @@
5
  "use strict";
6
  var NS = "http://www.w3.org/2000/svg";
7
 
8
- function isDark() {
9
- var t = document.documentElement.getAttribute("data-theme");
10
- if (t) return t === "dark";
11
- return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
12
- }
13
 
14
  /* ═══════════ the lettering: one glyph skeleton, two renderings ═══════════
15
  "neural": nodes at regular spacing along the strokes, thin connections, faint synapses inside each letter
@@ -182,21 +178,11 @@
182
  var ctx = canvas.getContext("2d");
183
  var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
184
 
185
- // crystal: the cubic ABX3 cell, A cations on the corners, the BX6 octahedron in the centre (X on the face centres)
186
- var a = 1.25, B = [[0, 0, 0]], A = [], EDGES = [], FACES = [];
187
- var X = [[a, 0, 0], [-a, 0, 0], [0, a, 0], [0, -a, 0], [0, 0, a], [0, 0, -a]];
188
- [-a, a].forEach(function (x) { [-a, a].forEach(function (y) { [-a, a].forEach(function (z) { A.push([x, y, z]); }); }); });
189
- A.forEach(function (p) { A.forEach(function (q) { // the cube: corners one lattice step apart
190
- var d = Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1]) + Math.abs(p[2] - q[2]);
191
- if (Math.abs(d - 2 * a) < 1e-6 && p < q) EDGES.push([p, q]);
192
- }); });
193
- [[0, 2, 4], [0, 4, 3], [0, 3, 5], [0, 5, 2], [1, 4, 2], [1, 3, 4], [1, 5, 3], [1, 2, 5]].forEach(function (f) {
194
- FACES.push([X[f[0]], X[f[1]], X[f[2]]]);
195
- });
196
 
197
  // panels behind the crystal: centre, half sizes, rotation about y, what they show
198
- function rng(seed) { return function () { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; }; }
199
- var r = rng(7), LATENT = [];
200
  for (var i = 0; i < 90; i++) { // paired points: a structure (teal) and its properties (violet), close together
201
  var t = r() * 2 - 1, u = r() * 2 - 1, cx = 0.75 * t, cy = 0.55 * Math.sin(2.2 * t) * 0.8 + 0.25 * u;
202
  LATENT.push({u: cx, v: cy, du: (r() - 0.5) * 0.09, dv: (r() - 0.5) * 0.09, c: t});
@@ -209,7 +195,7 @@
209
  {c: [2.25, -1.25, -1.6], h: [0.8, 0.56], ry: -0.62, kind: "property", label: "property: band gap"}
210
  ];
211
 
212
- var W = 0, H = 0, DPR = 1, yaw = 0, pitch = 0, dragYaw = 0, dragPitch = 0, t0 = performance.now();
213
  function resize() {
214
  var b = canvas.getBoundingClientRect();
215
  DPR = Math.min(2, window.devicePixelRatio || 1);
@@ -217,28 +203,13 @@
217
  canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
218
  draw();
219
  }
220
- function proj(p) {
221
- var cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
222
- var x1 = p[0] * cy + p[2] * sy, z1 = -p[0] * sy + p[2] * cy, y1 = p[1];
223
- var y2 = y1 * cp - z1 * sp, z2 = y1 * sp + z1 * cp;
224
- var D = 9, F = Math.min(W, H * 1.1) * D / 8.3, k = F / (D - z2);
225
- return [W * 0.52 + x1 * k, H * 0.5 - y2 * k, z2, k];
226
- }
227
  function panelPoint(P, u, v, w) { // panel-local (u, v in [-1, 1]) to world
228
  var c = Math.cos(P.ry), sn = Math.sin(P.ry), x = u * P.h[0], y = v * P.h[1];
229
  return [P.c[0] + x * c + (w || 0) * sn, P.c[1] + y, P.c[2] - x * sn + (w || 0) * c];
230
  }
231
  function poly(pts) { ctx.beginPath(); pts.forEach(function (p, j) { j ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }); ctx.closePath(); }
232
 
233
- function sphere(p, rad, col, dark) {
234
- var q = proj(p), R = rad * q[3];
235
- var g = ctx.createRadialGradient(q[0] - R * 0.38, q[1] - R * 0.42, R * 0.08, q[0], q[1], R);
236
- g.addColorStop(0, "#ffffff"); g.addColorStop(0.22, col[0]); g.addColorStop(0.72, col[1]); g.addColorStop(1, col[2]);
237
- ctx.beginPath(); ctx.arc(q[0], q[1], R, 0, 2 * Math.PI);
238
- ctx.fillStyle = g; ctx.fill();
239
- ctx.lineWidth = 0.8; ctx.strokeStyle = dark ? "rgba(0,0,0,.35)" : "rgba(15,23,42,.12)"; ctx.stroke();
240
- }
241
-
242
  function drawPanel(P, dark) {
243
  var corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(function (c) { return proj(panelPoint(P, c[0], c[1])); });
244
  poly(corners);
@@ -291,6 +262,7 @@
291
  function draw() {
292
  if (!W) return;
293
  var dark = isDark();
 
294
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
295
  ctx.clearRect(0, 0, W, H);
296
  // floor grid
@@ -305,36 +277,7 @@
305
  ctx.fillStyle = sh; ctx.beginPath(); ctx.ellipse(f[0], f[1], 2.0 * f[3], 0.62 * f[3], 0, 0, 2 * Math.PI); ctx.fill();
306
  // panels, far first
307
  PANELS.slice().sort(function (p, q) { return proj(p.c)[2] - proj(q.c)[2]; }).forEach(function (P) { drawPanel(P, dark); });
308
- // crystal: faces and atoms sorted by depth
309
- var items = [];
310
- FACES.forEach(function (fc) {
311
- var q = fc.map(proj), z = (q[0][2] + q[1][2] + q[2][2]) / 3;
312
- items.push({z: z, f: fc, q: q});
313
- });
314
- B.forEach(function (p) { items.push({z: proj(p)[2], p: p, rad: 0.27, col: ["#93c5fd", "#3b82f6", "#1e3a8a"]}); });
315
- X.forEach(function (p) { items.push({z: proj(p)[2], p: p, rad: 0.2, col: ["#99f6e4", "#14b8a6", "#115e59"]}); });
316
- A.forEach(function (p) { items.push({z: proj(p)[2], p: p, rad: 0.25, col: ["#c4b5fd", "#8b5cf6", "#3b0764"]}); });
317
- EDGES.forEach(function (e) { var q = [proj(e[0]), proj(e[1])]; items.push({z: (q[0][2] + q[1][2]) / 2 - 0.4, line: q}); });
318
- items.sort(function (m, n) { return m.z - n.z; });
319
- var Ldir = [-0.45, 0.7, 0.55];
320
- items.forEach(function (it) {
321
- if (it.p) return sphere(it.p, it.rad, it.col, dark);
322
- if (it.line) {
323
- ctx.lineWidth = 1.2; ctx.strokeStyle = dark ? "rgba(148,163,184,.45)" : "rgba(100,116,139,.45)";
324
- ctx.beginPath(); ctx.moveTo(it.line[0][0], it.line[0][1]); ctx.lineTo(it.line[1][0], it.line[1][1]); ctx.stroke(); return;
325
- }
326
- var v = it.f, e1 = [v[1][0] - v[0][0], v[1][1] - v[0][1], v[1][2] - v[0][2]], e2 = [v[2][0] - v[0][0], v[2][1] - v[0][1], v[2][2] - v[0][2]];
327
- var n = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]], nl = Math.hypot(n[0], n[1], n[2]);
328
- var cy = Math.cos(yaw), sy = Math.sin(yaw), nx = (n[0] * cy + n[2] * sy) / nl, nz = (-n[0] * sy + n[2] * cy) / nl, ny = n[1] / nl;
329
- var lit = Math.abs(nx * Ldir[0] + ny * Ldir[1] + nz * Ldir[2]);
330
- poly(it.q);
331
- var gr = ctx.createLinearGradient(it.q[0][0], it.q[0][1], it.q[2][0], it.q[2][1]);
332
- gr.addColorStop(0, dark ? "rgba(147,197,253," + (0.1 + 0.22 * lit).toFixed(3) + ")" : "rgba(255,255,255," + (0.25 + 0.45 * lit).toFixed(3) + ")");
333
- gr.addColorStop(1, dark ? "rgba(59,130,246," + (0.08 + 0.14 * lit).toFixed(3) + ")" : "rgba(129,161,250," + (0.12 + 0.22 * lit).toFixed(3) + ")");
334
- ctx.fillStyle = gr; ctx.fill();
335
- ctx.lineWidth = 1; ctx.strokeStyle = dark ? "rgba(191,219,254,.5)" : "rgba(255,255,255,.95)"; ctx.stroke();
336
- ctx.lineWidth = 0.6; ctx.strokeStyle = dark ? "rgba(96,165,250,.35)" : "rgba(99,102,241,.25)"; ctx.stroke();
337
- });
338
  }
339
 
340
  var running = false, visible = true;
 
5
  "use strict";
6
  var NS = "http://www.w3.org/2000/svg";
7
 
8
+ var P3 = window.Prism3D, isDark = P3.isDark;
 
 
 
 
9
 
10
  /* ═══════════ the lettering: one glyph skeleton, two renderings ═══════════
11
  "neural": nodes at regular spacing along the strokes, thin connections, faint synapses inside each letter
 
178
  var ctx = canvas.getContext("2d");
179
  var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
180
 
181
+ // the cubic ABX3 cell (A on the corners, the BX6 octahedron in the centre), half edge 1.25 in this scene
182
+ var CELL = P3.perovskite({A: "A", B: "B", X: "X"});
 
 
 
 
 
 
 
 
 
183
 
184
  // panels behind the crystal: centre, half sizes, rotation about y, what they show
185
+ var r = P3.T.rng(7), LATENT = [];
 
186
  for (var i = 0; i < 90; i++) { // paired points: a structure (teal) and its properties (violet), close together
187
  var t = r() * 2 - 1, u = r() * 2 - 1, cx = 0.75 * t, cy = 0.55 * Math.sin(2.2 * t) * 0.8 + 0.25 * u;
188
  LATENT.push({u: cx, v: cy, du: (r() - 0.5) * 0.09, dv: (r() - 0.5) * 0.09, c: t});
 
195
  {c: [2.25, -1.25, -1.6], h: [0.8, 0.56], ry: -0.62, kind: "property", label: "property: band gap"}
196
  ];
197
 
198
+ var W = 0, H = 0, DPR = 1, yaw = 0, pitch = 0, dragYaw = 0, dragPitch = 0, t0 = performance.now(), cam = null;
199
  function resize() {
200
  var b = canvas.getBoundingClientRect();
201
  DPR = Math.min(2, window.devicePixelRatio || 1);
 
203
  canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
204
  draw();
205
  }
206
+ function proj(p) { return cam.proj(p); }
 
 
 
 
 
 
207
  function panelPoint(P, u, v, w) { // panel-local (u, v in [-1, 1]) to world
208
  var c = Math.cos(P.ry), sn = Math.sin(P.ry), x = u * P.h[0], y = v * P.h[1];
209
  return [P.c[0] + x * c + (w || 0) * sn, P.c[1] + y, P.c[2] - x * sn + (w || 0) * c];
210
  }
211
  function poly(pts) { ctx.beginPath(); pts.forEach(function (p, j) { j ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }); ctx.closePath(); }
212
 
 
 
 
 
 
 
 
 
 
213
  function drawPanel(P, dark) {
214
  var corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(function (c) { return proj(panelPoint(P, c[0], c[1])); });
215
  poly(corners);
 
262
  function draw() {
263
  if (!W) return;
264
  var dark = isDark();
265
+ cam = P3.camera({cx: W * 0.52, cy: H * 0.5, scale: Math.min(W, H * 1.1) / 8.3, yaw: yaw, pitch: pitch});
266
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
267
  ctx.clearRect(0, 0, W, H);
268
  // floor grid
 
277
  ctx.fillStyle = sh; ctx.beginPath(); ctx.ellipse(f[0], f[1], 2.0 * f[3], 0.62 * f[3], 0, 0, 2 * Math.PI); ctx.fill();
278
  // panels, far first
279
  PANELS.slice().sort(function (p, q) { return proj(p.c)[2] - proj(q.c)[2]; }).forEach(function (P) { drawPanel(P, dark); });
280
+ P3.crystal(ctx, cam, CELL, {s: 1.25, atomScale: 0.6, dark: dark});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  }
282
 
283
  var running = false, visible = true;
site/assets/prism-tour.js ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The MEIDNet tour on the landing page: the one-minute story of the Studio (data → model → family → rules → targets →
2
+ search → candidates) as a live 3D animation. Every frame is a pure function of time, so playing, seeking, the
3
+ chapter buttons and reduced motion all show the same pictures. The numbers are those of the Studio with its
4
+ defaults: the published Perov-5 model, the cubic halide perovskite family, targets 2 eV and −0.1 eV/atom. */
5
+ (function () {
6
+ "use strict";
7
+ var P = window.Prism3D, root = document.getElementById("tour3d");
8
+ if (!P || !root) return;
9
+ var T = P.T, canvas = root.querySelector("canvas"), ctx = canvas.getContext("2d");
10
+ var VW = 960, VH = 540;
11
+
12
+ var BLOCKS = [{id: "data", name: "Data", c: "#2a78d6"}, {id: "model", name: "Model", c: "#eb6834"}, {id: "family", name: "Family", c: "#1baf7a"},
13
+ {id: "rules", name: "Rules", c: "#eda100"}, {id: "targets", name: "Targets", c: "#e87ba4"}, {id: "search", name: "Search", c: "#008300"},
14
+ {id: "candidates", name: "Candidates", c: "#4a3aa7"}];
15
+ var COL = {}; BLOCKS.forEach(function (b) { COL[b.id] = b.c; });
16
+ var th = P.theme();
17
+ function tc(id) { // a block colour readable as text on this theme
18
+ var c = P.rgbOf(COL[id] || COL.family);
19
+ return P.css(th.dark ? P.mix(c, [255, 255, 255], 0.3) : P.mix(c, [0, 0, 0], id === "rules" ? 0.3 : id === "targets" ? 0.2 : 0.05));
20
+ }
21
+ var sub = function (f) { return String(f).replace(/\d/g, function (d) { return "₀₁₂₃₄₅₆₇₈₉"[+d]; }); };
22
+ var fmt = function (v) { return (Math.round(v * 100) / 100).toString().replace("-", "−"); };
23
+
24
+ /* ── the facts (read from the Studio with its defaults) ── */
25
+ var ROWS = [{f: "SrTiO3", e: {A: "Sr", B: "Ti", X: "O"}, v: [-0.24, 4.3]}, {f: "KNbO3", e: {A: "K", B: "Nb", X: "O"}, v: [-0.42, 4.1]},
26
+ {f: "LaTiNO2", e: {A: "La", B: "Ti", X: ["N", "O", "O"]}, v: [0.06, 2.5]}, {f: "SrZrNOF", e: {A: "Sr", B: "Zr", X: ["N", "O", "F"]}, v: [0.54, 3.2]}];
27
+ var GROUPS = [{g: "A", sample: ["Ba", "Sr", "Ca", "Na", "K", "Rb", "Cs", "La", "Ce", "Pr", "Nd", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu"]},
28
+ {g: "B", sample: ["Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Sn", "Pb", "Sc", "Cr", "V"]}, {g: "X", sample: ["F", "Cl", "Br", "I"]}];
29
+ var RULES = [{name: "charges balance", lost: 796}, {name: "tolerance factor", lost: 60}, {name: "octahedral factor", lost: 41}];
30
+ // compositions on the conveyor: the rule that stops each one (gate index), or -1 when it passes every rule
31
+ var FLOW = [{f: "BaMnF3", e: {A: "Ba", B: "Mn", X: "F"}, gate: 0}, {f: "NaFeI3", e: {A: "Na", B: "Fe", X: "I"}, gate: -1},
32
+ {f: "NaPbF3", e: {A: "Na", B: "Pb", X: "F"}, gate: 1}, {f: "NaMnF3", e: {A: "Na", B: "Mn", X: "F"}, gate: 2},
33
+ {f: "KFeI3", e: {A: "K", B: "Fe", X: "I"}, gate: -1}, {f: "BaMnCl3", e: {A: "Ba", B: "Mn", X: "Cl"}, gate: 0},
34
+ {f: "KCoI3", e: {A: "K", B: "Co", X: "I"}, gate: -1}, {f: "BaFeF3", e: {A: "Ba", B: "Fe", X: "F"}, gate: 0}];
35
+ // the 27 compositions that pass every rule: predicted formation enthalpy (eV/atom) and direct band gap (eV)
36
+ var ALIVE = [["CsMnI3", 1.9, 1.57], ["CsPbI3", 2.05, 3.19], ["CsZnI3", 2.07, 2.58], ["KCoI3", 1.29, 1.82], ["KCuI3", 1.85, 1.44],
37
+ ["KFeI3", 0.96, 1.74], ["KMnI3", 1.71, 0.59], ["KNiI3", 1.86, 1.04], ["KPbI3", 2.01, 2.02], ["KSnI3", 1.72, 1.87],
38
+ ["KZnI3", 2.09, 1.87], ["NaCoBr3", 0.96, 2.82], ["NaCoI3", 0.95, 2.69], ["NaCuI3", 1.45, 3.49], ["NaFeI3", 0.95, 2.08],
39
+ ["NaMnI3", 1.84, 1.66], ["NaNiBr3", 1.31, 3.04], ["NaNiI3", 1.18, 2.92], ["NaSnI3", 1.71, 2.98], ["NaZnI3", 1.98, 1.51],
40
+ ["RbCoI3", 1.71, 1.18], ["RbCuI3", 2.09, 2.07], ["RbFeI3", 1.46, 1.49], ["RbMnI3", 1.74, 2.41], ["RbPbI3", 2.01, 2.32],
41
+ ["RbSnI3", 1.91, 1.83], ["RbZnI3", 2.02, 2.81]];
42
+ var RANKED = ["NaFeI3", "KFeI3", "KCoI3"];
43
+ // a search with the Studio defaults (seed 937): the three crystals it returned, with their predicted properties
44
+ var FOUND = [{f: "NaNiI3", e: {A: "Na", B: "Ni", X: "I"}, gap: 1.97, dh: -0.05, flag: false},
45
+ {f: "NaSnI3", e: {A: "Na", B: "Sn", X: "I"}, gap: 1.98, dh: -1.37, flag: true},
46
+ {f: "NaMnI3", e: {A: "Na", B: "Mn", X: "I"}, gap: 2.37, dh: 14.3, flag: true}];
47
+ var STOPPED = [{e: {A: "Ba", B: "Mn", X: "F"}}, {e: {A: "Na", B: "Pb", X: "F"}}, {e: {A: "Na", B: "Mn", X: "F"}}, {e: {A: "Ba", B: "Mn", X: "Cl"}}, {e: {A: "Ba", B: "Fe", X: "F"}}];
48
+ var cells = {};
49
+ function cell(e) { var k = JSON.stringify(e); return cells[k] || (cells[k] = P.perovskite(e)); }
50
+
51
+ /* ── flat helpers in the 960 × 540 stage ── */
52
+ function card(x, y, w, h, o) {
53
+ o = o || {};
54
+ ctx.save(); if (o.alpha !== undefined) ctx.globalAlpha *= T.clamp(o.alpha);
55
+ P.rrect(ctx, x, y, w, h, o.r === undefined ? 14 : o.r);
56
+ ctx.fillStyle = o.fill || (th.dark ? "rgba(27,31,36,.86)" : "rgba(255,255,255,.88)"); ctx.fill();
57
+ if (o.stroke !== null) { ctx.lineWidth = o.sw || 1.5; ctx.strokeStyle = o.stroke || th.line; ctx.stroke(); }
58
+ ctx.restore();
59
+ }
60
+ function tint(id, a) { return P.css(P.rgbOf(COL[id]), a); }
61
+ function label(str, x, y, o) { o = o || {}; P.text(ctx, str, x, y, {size: o.size || 18, weight: o.weight || 600, color: o.color || th.ink, align: o.align, alpha: o.alpha, family: o.family, italic: o.italic, baseline: o.baseline}); }
62
+ function cam(cx, cy, scale, yaw, pitch) { return P.camera({cx: cx, cy: cy, scale: scale, yaw: yaw, pitch: pitch === undefined ? -0.38 : pitch}); }
63
+ function chip(x, y, str, id, on, o) {
64
+ o = o || {}; var w = o.w || Math.max(46, 12 * str.length + 26), h = o.h || 32;
65
+ card(x - w / 2, y - h / 2, w, h, {r: h / 2, fill: on ? COL[id] : (th.dark ? "#1b1f24" : "#fff"), stroke: COL[id], sw: 2, alpha: o.alpha});
66
+ label(str, x, y + 6, {align: "center", size: o.size || 17, weight: 700, color: on ? "#fff" : tc(id), alpha: o.alpha});
67
+ }
68
+ function star(x, y, r, color, a) {
69
+ ctx.save(); ctx.globalAlpha *= a === undefined ? 1 : a; ctx.beginPath();
70
+ for (var i = 0; i < 10; i++) { var rr = i % 2 ? r * 0.45 : r, an = -Math.PI / 2 + i * Math.PI / 5; ctx.lineTo(x + rr * Math.cos(an), y + rr * Math.sin(an)); }
71
+ ctx.closePath(); ctx.fillStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 12; ctx.fill(); ctx.restore();
72
+ }
73
+ function dot(cm, p, r, color, a) {
74
+ var q = cm.proj(p); ctx.save(); ctx.globalAlpha *= a === undefined ? 1 : a;
75
+ ctx.beginPath(); ctx.arc(q[0], q[1], Math.max(1, r * q[3]), 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.restore(); return q;
76
+ }
77
+ function cloud(seed, n, radius) {
78
+ var R = T.rng(seed), pts = [];
79
+ while (pts.length < n) { var p = [R() * 2 - 1, R() * 2 - 1, R() * 2 - 1]; if (p[0] * p[0] + p[1] * p[1] + p[2] * p[2] <= 1) pts.push(p.map(function (v) { return v * radius; })); }
80
+ return pts;
81
+ }
82
+ var DATA_CLOUD = cloud(21, 320, 1.25), MAP_CLOUD = cloud(5, 170, 1.7);
83
+
84
+ /* ── scenes ── */
85
+ var SCENES = [
86
+ {block: "intro", title: "Start", ms: 4800,
87
+ cap: "Tell MEIDNet the properties you want and it suggests crystals that should have them. Here is how, block by block.",
88
+ draw: function (t) {
89
+ var a = T.out(T.seg(t, 0, 0.22));
90
+ ctx.save(); ctx.translate(-50 * (1 - a), 0);
91
+ card(64, 168, 316, 196, {fill: th.dark ? "rgba(232,123,164,.12)" : "rgba(253,237,243,.92)", stroke: COL.targets, sw: 2.5, r: 20, alpha: a});
92
+ label("You want", 92, 216, {size: 24, color: th.muted, alpha: a});
93
+ label("Direct band gap", 92, 266, {size: 31, weight: 800, color: tc("targets"), alpha: a});
94
+ label("≈ 2 eV", 92, 324, {size: 42, weight: 800, alpha: a});
95
+ ctx.restore();
96
+ label("MEIDNet", 500, 238, {size: 30, weight: 800, color: tc("family"), align: "center", alpha: T.seg(t, 0.22, 0.4)});
97
+ P.arrow(ctx, 402, 266, 600, 266, T.ease(T.seg(t, 0.22, 0.46)), COL.family, 4.5);
98
+ var c = cam(770, 268, 80, -0.7 + 1.2 * t);
99
+ P.crystal(ctx, c, cell(FOUND[0].e), {s: 1, explode: 1 - T.ease(T.seg(t, 0.32, 0.72)), glass: T.seg(t, 0.62, 0.82), edges: T.seg(t, 0.62, 0.82),
100
+ labels: t > 0.6, dark: th.dark, alpha: T.seg(t, 0.28, 0.4)});
101
+ label("NaNiI₃", 770, 420, {size: 20, weight: 700, color: th.muted, align: "center", alpha: T.seg(t, 0.75, 0.9)});
102
+ label("inverse design: from properties to crystals", 480, 492, {size: 25, color: th.muted, align: "center", alpha: T.seg(t, 0.6, 0.82)});
103
+ }},
104
+
105
+ {block: "data", title: "Data", ms: 7000,
106
+ cap: "MEIDNet learns from known materials: a table in which each row is a crystal structure with its properties. Here, 11,356 materials.",
107
+ draw: function (t) {
108
+ card(32, 60, 600, 420);
109
+ var hd = {size: 15, weight: 700, color: th.muted};
110
+ label("structure", 56, 98, hd); label("formula", 168, 98, hd); label("formation enthalpy", 330, 90, hd); label("eV/atom", 330, 110, {size: 13, color: th.faint});
111
+ label("band gap", 510, 90, hd); label("eV", 510, 110, {size: 13, color: th.faint});
112
+ ctx.fillStyle = th.line; ctx.fillRect(48, 124, 568, 1.5);
113
+ ROWS.forEach(function (r, k) {
114
+ var p = T.out(T.seg(t, 0.05 + k * 0.1, 0.27 + k * 0.1)), y = 130 + k * 80;
115
+ ctx.save(); ctx.translate(70 * (1 - p), 0); ctx.globalAlpha *= p;
116
+ P.crystal(ctx, cam(96, y + 40, 22, -0.6 + 1.1 * t + k), cell(r.e), {dark: th.dark});
117
+ label(sub(r.f), 168, y + 48, {size: 25, weight: 700});
118
+ label(fmt(r.v[0]), 330, y + 48, {size: 24, family: P.MATH}); label(fmt(r.v[1]), 510, y + 48, {size: 24, family: P.MATH});
119
+ ctx.fillStyle = th.line; ctx.fillRect(48, y + 79, 568, 1);
120
+ ctx.restore();
121
+ });
122
+ label("four real rows of the Perov-5 training set", 56, 466, {size: 14, color: th.faint, alpha: T.seg(t, 0.4, 0.6)});
123
+ // the whole table: one point per material
124
+ var c = cam(800, 220, 82, -0.5 + 0.9 * t, -0.3), n = Math.round(DATA_CLOUD.length * T.ease(T.seg(t, 0.38, 0.85)));
125
+ var pts = DATA_CLOUD.slice(0, n).map(function (p) { return [c.proj(p), p]; }).sort(function (a, b) { return a[0][2] - b[0][2]; });
126
+ pts.forEach(function (q) { var d = (q[0][2] + 1.3) / 2.6; dot(c, q[1], 0.045, P.css(P.mix(P.rgbOf(COL.data), [255, 255, 255], 0.5 * (1 - d))), 0.55 + 0.45 * d); });
127
+ label((Math.round(11356 * T.ease(T.seg(t, 0.4, 0.85)))).toLocaleString("en-US"), 800, 420, {size: 52, weight: 800, color: tc("data"), align: "center", alpha: T.seg(t, 0.38, 0.45)});
128
+ label("materials · 2 properties each", 800, 456, {size: 20, weight: 600, align: "center", color: th.muted, alpha: T.seg(t, 0.7, 0.88)});
129
+ }},
130
+
131
+ {block: "model", title: "Model", ms: 7800,
132
+ cap: "Two encoders place each crystal and its properties on one shared map. Training pulls the two points of the same material together.",
133
+ draw: function (t) {
134
+ var col = COL.model, a1 = T.ease(T.seg(t, 0.05, 0.2)), a2 = T.ease(T.seg(t, 0.18, 0.34));
135
+ P.crystal(ctx, cam(96, 150, 36, -0.5 + 0.8 * t), cell(ROWS[0].e), {dark: th.dark});
136
+ label("crystal", 96, 238, {size: 16, weight: 700, color: th.muted, align: "center"});
137
+ card(40, 296, 112, 70, {r: 12});
138
+ label("4.3 eV", 96, 326, {size: 21, weight: 800, align: "center"}); label("−0.24 eV/atom", 96, 350, {size: 13, color: th.muted, align: "center"});
139
+ label("properties", 96, 392, {size: 16, weight: 700, color: th.muted, align: "center"});
140
+ P.arrow(ctx, 158, 150, 206, 150, a1, col, 3.5); P.arrow(ctx, 158, 331, 206, 331, a1, col, 3.5);
141
+ [[115, "crystal encoder", "graph network"], [296, "property encoder", "dense network"]].forEach(function (b) {
142
+ card(210, b[0], 180, 70, {fill: th.dark ? "rgba(235,104,52,.14)" : "rgba(253,236,228,.95)", stroke: col, sw: 2.5});
143
+ label(b[1], 300, b[0] + 33, {size: 18, weight: 800, color: tc("model"), align: "center"});
144
+ label(b[2], 300, b[0] + 55, {size: 13, color: th.muted, align: "center", family: P.MONO});
145
+ });
146
+ P.arrow(ctx, 394, 150, 470, 205, a2, col, 3.5); P.arrow(ctx, 394, 331, 470, 290, a2, col, 3.5);
147
+ // the shared map as a 3D space
148
+ var c = cam(700, 262, 108, -0.65 + 0.55 * t, -0.32), box = 1.15, show = T.seg(t, 0.28, 0.4), pull = T.ease(T.seg(t, 0.42, 0.86));
149
+ ctx.save(); ctx.globalAlpha *= show;
150
+ [[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(function (u) {
151
+ P.line3(ctx, c, [-box, u[0] * box, u[1] * box], [box, u[0] * box, u[1] * box], th.dark ? "rgba(148,163,184,.35)" : "rgba(100,116,139,.35)", 1);
152
+ P.line3(ctx, c, [u[0] * box, -box, u[1] * box], [u[0] * box, box, u[1] * box], th.dark ? "rgba(148,163,184,.35)" : "rgba(100,116,139,.35)", 1);
153
+ P.line3(ctx, c, [u[0] * box, u[1] * box, -box], [u[0] * box, u[1] * box, box], th.dark ? "rgba(148,163,184,.35)" : "rgba(100,116,139,.35)", 1);
154
+ });
155
+ var R = T.rng(7), pairs = [];
156
+ for (var k = 0; k < 8; k++) {
157
+ var m = [R() * 1.6 - 0.8, R() * 1.6 - 0.8, R() * 1.6 - 0.8], d = [R() - 0.5, R() - 0.5, R() - 0.5], s = 0.55 * (1 - pull);
158
+ pairs.push({a: [m[0] + d[0] * s, m[1] + d[1] * s, m[2] + d[2] * s], b: [m[0] - d[0] * s, m[1] - d[1] * s, m[2] - d[2] * s]});
159
+ }
160
+ pairs.forEach(function (pr) { P.line3(ctx, c, pr.a, pr.b, th.dark ? "rgba(203,213,225,.55)" : "rgba(100,116,139,.6)", 1.4, [4, 4]); });
161
+ pairs.forEach(function (pr) {
162
+ P.sphere(ctx, c, pr.a, 0.07, COL.data, {dark: th.dark});
163
+ var q = c.proj(pr.b); ctx.beginPath(); ctx.arc(q[0], q[1], 0.08 * q[3], 0, 2 * Math.PI); ctx.lineWidth = 3; ctx.strokeStyle = COL.targets; ctx.stroke();
164
+ });
165
+ ctx.restore();
166
+ label("shared map (latent space)", 520, 84, {size: 17, weight: 800, color: th.muted, alpha: show});
167
+ label("crystal", 538, 474, {size: 15, color: th.muted, alpha: show}); dotFlat(526, 469, 6, COL.data, show);
168
+ ringFlat(612, 469, 7, COL.targets, show); label("its properties", 626, 474, {size: 15, color: th.muted, alpha: show});
169
+ label("pulled together", 930, 474, {size: 15, weight: 800, color: tc("model"), align: "right", alpha: T.seg(t, 0.8, 0.92)});
170
+ }},
171
+
172
+ {block: "family", title: "Family", ms: 7000,
173
+ cap: "You choose a crystal type, here the cubic ABX₃ halide perovskite, and the elements allowed on each site: 924 compositions.",
174
+ draw: function (t) {
175
+ var step = Math.min(5, Math.floor(t * 7)), combo = {};
176
+ GROUPS.forEach(function (g, j) { var vis = g.sample.slice(0, 6); combo[g.g] = vis[(step * (j + 2) + j) % vis.length]; });
177
+ P.crystal(ctx, cam(232, 262, 96, -0.9 + 1.25 * t), cell(combo), {dark: th.dark, labels: true});
178
+ label("cubic ABX₃ perovskite", 232, 478, {size: 21, weight: 800, color: tc("family"), align: "center"});
179
+ label(sub(combo.A + combo.B + combo.X + "3"), 232, 506, {size: 17, weight: 600, color: th.muted, align: "center"});
180
+ var x0 = 480, W = 450;
181
+ GROUPS.forEach(function (g, j) {
182
+ var x = x0 + W / 3 * (j + 0.5), a = T.out(T.seg(t, 0.05 + j * 0.08, 0.25 + j * 0.08));
183
+ chip(x, 84, g.g + " site", "family", true, {w: 104, h: 34, size: 18, alpha: a});
184
+ g.sample.slice(0, 6).forEach(function (el, m) { chip(x, 132 + m * 40, el, "family", el === combo[g.g], {w: 72, alpha: a}); });
185
+ if (g.sample.length > 6) label("+" + (g.sample.length - 6) + " more", x, 132 + 6 * 40 + 2, {size: 15, color: th.muted, align: "center", alpha: a});
186
+ });
187
+ label("21 × 11 × 4 = " + Math.round(924 * T.ease(T.seg(t, 0.42, 0.85))).toLocaleString("en-US") + " compositions", x0 + W / 2, 470,
188
+ {size: 26, weight: 800, color: tc("family"), align: "center", alpha: T.seg(t, 0.4, 0.48)});
189
+ }},
190
+
191
+ {block: "rules", title: "Rules", ms: 8000,
192
+ cap: "Simple chemistry rules remove impossible combinations: charges must balance and ions must fit. 27 of 924 pass every rule.",
193
+ draw: function (t) {
194
+ var c = cam(470, 300, 66, -0.42, -0.42), GX = [-2.6, -0.6, 1.4], EXIT = 3.9, START = -6.2;
195
+ label(Math.round(T.lerp(924, 27, T.ease(T.seg(t, 0.1, 0.92)))).toLocaleString("en-US"), 40, 96, {size: 40, weight: 800});
196
+ label("compositions left", 40, 122, {size: 15, color: th.muted});
197
+ // the belt
198
+ var shade = th.dark ? "rgba(148,163,184,.35)" : "rgba(100,116,139,.38)";
199
+ P.line3(ctx, c, [START, -0.75, -1.05], [EXIT + 0.4, -0.75, -1.05], shade, 1.5); P.line3(ctx, c, [START, -0.75, 1.05], [EXIT + 0.4, -0.75, 1.05], shade, 1.5);
200
+ for (var s = 0; s < 22; s++) { var x = START + ((s * 0.5 + t * 6) % 11); if (x < EXIT + 0.4) P.line3(ctx, c, [x, -0.75, -1.05], [x, -0.75, 1.05], shade, 1); }
201
+ // the gates: glass panes
202
+ GX.forEach(function (gx, j) {
203
+ var a = [gx, -0.75, -1.2], b = [gx, -0.75, 1.2], d = [gx, 1.45, 1.2], e = [gx, 1.45, -1.2];
204
+ var q = [a, b, d, e].map(c.proj);
205
+ ctx.save(); ctx.beginPath(); q.forEach(function (p, i) { i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }); ctx.closePath();
206
+ ctx.fillStyle = tint("rules", th.dark ? 0.16 : 0.2); ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = tint("rules", 0.85); ctx.stroke(); ctx.restore();
207
+ var top = c.proj([gx, 1.6, 0]), words = RULES[j].name.split(" ");
208
+ label(words[0], top[0], top[1] - 24, {size: 16, weight: 800, color: tc("rules"), align: "center"});
209
+ label(words.slice(1).join(" "), top[0], top[1] - 6, {size: 16, weight: 800, color: tc("rules"), align: "center"});
210
+ var bot = c.proj([gx, -0.75, 1.35]);
211
+ label("−" + RULES[j].lost, bot[0], bot[1] + 26, {size: 19, weight: 800, color: th.bad, align: "center", alpha: T.seg(t, 0.25 + j * 0.13, 0.35 + j * 0.13)});
212
+ });
213
+ var ex = c.proj([EXIT + 0.4, 1.6, 0]);
214
+ label("pass every", ex[0] + 26, ex[1] - 24, {size: 16, weight: 800, color: th.good, align: "center"});
215
+ label("rule", ex[0] + 26, ex[1] - 6, {size: 16, weight: 800, color: th.good, align: "center"});
216
+ var passed = 0, span = 0.36;
217
+ FLOW.forEach(function (it, k) {
218
+ var st = 0.03 + k * 0.085, stop = it.gate >= 0 ? GX[it.gate] - 0.55 : EXIT, tStop = st + span * (stop - START) / (EXIT - START);
219
+ if (t < st) return;
220
+ var lane = k % 2 ? 0.6 : -0.6, x = t < tStop ? T.lerp(START, EXIT, (t - st) / span) : stop, y = -0.25, alpha = 1, fall = 0;
221
+ if (t >= tStop && it.gate >= 0) { fall = T.ease(T.seg(t, tStop + 0.04, tStop + 0.13)); alpha = 1 - fall; y -= 1.4 * fall; }
222
+ if (t >= tStop && it.gate < 0) { var slot = passed++, f = T.ease(T.seg(t, tStop, tStop + 0.08));
223
+ var to = [870, 228 + slot * 70], from = c.proj([EXIT, y, lane]);
224
+ var cx2 = T.lerp(from[0], to[0], f), cy2 = T.lerp(from[1], to[1], f);
225
+ P.crystal(ctx, cam(cx2, cy2, 15, t * 3 + k, -0.35), cell(it.e), {dark: th.dark, glass: 0.8});
226
+ label(sub(it.f), cx2 + 24, cy2 + 6, {size: 16, weight: 800, alpha: f});
227
+ P.check(ctx, cx2 - 22, cy2 - 20, true, T.seg(t, tStop, tStop + 0.04), th);
228
+ return; }
229
+ if (alpha <= 0.02) return;
230
+ P.crystal(ctx, c, cell(it.e), {at: [x, y, lane], s: 0.32, spin: t * 3 + k, dark: th.dark, alpha: alpha, glass: 0.8});
231
+ var q = c.proj([x, y + 0.55, lane]);
232
+ label(sub(it.f), q[0], q[1] - 4, {size: 14, weight: 800, align: "center", alpha: alpha});
233
+ if (t >= tStop && it.gate >= 0) P.check(ctx, q[0] + 30, q[1] + 8, false, T.seg(t, tStop, tStop + 0.04), th);
234
+ });
235
+ }},
236
+
237
+ {block: "targets", title: "Targets", ms: 7400,
238
+ cap: "You say what you want, for example a direct band gap of 2 eV. The model predicts every composition and ranks the closest.",
239
+ draw: function (t) {
240
+ var c = cam(330, 282, 76, -0.32 + 0.18 * Math.sin(t * 2.2), -0.3);
241
+ var xl = -0.3, xh = 2.4, yl = 0.3, yh = 3.8, X = function (v) { return -2.4 + (v - xl) / (xh - xl) * 4.8; }, Y = function (v) { return -1.7 + (v - yl) / (yh - yl) * 3.4; };
242
+ var ax = th.dark ? "rgba(203,213,225,.6)" : "rgba(71,85,105,.6)", gr = th.dark ? "rgba(148,163,184,.14)" : "rgba(100,116,139,.15)";
243
+ for (var g = 0; g <= 4; g++) { P.line3(ctx, c, [-2.4 + g * 1.2, -1.7, 0], [-2.4 + g * 1.2, 1.7, 0], gr, 1); P.line3(ctx, c, [-2.4, -1.7 + g * 0.85, 0], [2.4, -1.7 + g * 0.85, 0], gr, 1); }
244
+ P.line3(ctx, c, [-2.4, -1.7, 0], [2.6, -1.7, 0], ax, 2); P.line3(ctx, c, [-2.4, -1.7, 0], [-2.4, 1.9, 0], ax, 2);
245
+ var xa = c.proj([0, -2.15, 0]), ya = c.proj([-2.85, 0, 0]);
246
+ label("predicted formation enthalpy (eV/atom)", xa[0], xa[1] + 10, {size: 14, color: th.muted, align: "center"});
247
+ ctx.save(); ctx.translate(ya[0] - 6, ya[1]); ctx.rotate(-Math.PI / 2); label("predicted direct band gap (eV)", 0, 0, {size: 14, color: th.muted, align: "center"}); ctx.restore();
248
+ var tg = [X(-0.1), Y(2.0), 0], tq = c.proj(tg), ta = T.seg(t, 0.16, 0.26);
249
+ ctx.save(); ctx.globalAlpha *= ta; ctx.setLineDash([6, 5]); ctx.strokeStyle = COL.targets; ctx.lineWidth = 2;
250
+ var v1 = c.proj([tg[0], -1.7, 0]), v2 = c.proj([tg[0], 1.7, 0]), h1 = c.proj([-2.4, tg[1], 0]), h2 = c.proj([2.4, tg[1], 0]);
251
+ ctx.beginPath(); ctx.moveTo(v1[0], v1[1]); ctx.lineTo(v2[0], v2[1]); ctx.moveTo(h1[0], h1[1]); ctx.lineTo(h2[0], h2[1]); ctx.stroke(); ctx.setLineDash([]);
252
+ ctx.beginPath(); ctx.arc(tq[0], tq[1], 15, 0, 2 * Math.PI); ctx.lineWidth = 3.5; ctx.stroke(); ctx.restore();
253
+ label("your target", tq[0] + 18, tq[1] + 36, {size: 16, weight: 800, color: tc("targets"), alpha: T.seg(t, 0.3, 0.42)});
254
+ var pts = ALIVE.map(function (r, k) { return {r: r, k: k, p: [X(r[1]), Y(r[2]), 0], rank: RANKED.indexOf(r[0])}; });
255
+ pts.forEach(function (o) { if (o.rank < 0) P.sphere(ctx, c, o.p, 0.075, COL.family, {dark: th.dark, alpha: T.seg(t, 0.02 + o.k * 0.006, 0.12 + o.k * 0.006) * 0.8}); });
256
+ pts.filter(function (o) { return o.rank >= 0; }).sort(function (a, b) { return b.rank - a.rank; }).forEach(function (o) {
257
+ var rise = T.ease(T.seg(t, 0.5 + o.rank * 0.07, 0.64 + o.rank * 0.07)), top = [o.p[0], o.p[1], 0.9 * rise];
258
+ P.line3(ctx, c, o.p, top, P.css(P.rgbOf(COL.candidates), 0.7), 2);
259
+ var q = P.sphere(ctx, c, top, 0.13, COL.candidates, {dark: th.dark, alpha: T.seg(t, 0.48, 0.52)});
260
+ label(String(o.rank + 1), q[0], q[1] + 5, {size: 13, weight: 800, color: "#fff", align: "center", alpha: rise});
261
+ });
262
+ var a = T.seg(t, 0.55, 0.68);
263
+ card(650, 84, 280, 330, {alpha: a});
264
+ label("closest to your target", 670, 118, {size: 17, weight: 800, color: tc("targets"), alpha: a});
265
+ RANKED.forEach(function (f, k) {
266
+ var r = ALIVE.filter(function (x) { return x[0] === f; })[0], y = 170 + k * 78, b = T.seg(t, 0.6 + k * 0.08, 0.7 + k * 0.08);
267
+ ctx.save(); ctx.globalAlpha *= b; ctx.beginPath(); ctx.arc(686, y - 7, 14, 0, 2 * Math.PI); ctx.fillStyle = COL.candidates; ctx.fill(); ctx.restore();
268
+ label(String(k + 1), 686, y - 1, {size: 15, weight: 800, color: "#fff", align: "center", alpha: b});
269
+ label(sub(f), 712, y, {size: 22, weight: 800, alpha: b});
270
+ label(fmt(r[2]) + " eV · " + fmt(r[1]) + " eV/atom", 712, y + 24, {size: 15, color: th.muted, alpha: b});
271
+ });
272
+ label("27 compositions that pass every rule", 670, 400, {size: 13, color: th.faint, alpha: a});
273
+ }},
274
+
275
+ {block: "search", title: "Search", ms: 8800,
276
+ cap: "Starting at your target’s point on the map, the search moves points until the model predicts your targets, turns each into a crystal and keeps those that pass every rule.",
277
+ draw: function (t) {
278
+ var c = cam(330, 280, 104, -0.4 + 0.5 * t, -0.3);
279
+ card(690, 64, 240, 412);
280
+ label("found", 712, 98, {size: 20, weight: 800, color: tc("search")}); label("pass every rule", 712, 120, {size: 15, color: th.muted});
281
+ MAP_CLOUD.map(function (p) { return [c.proj(p), p]; }).sort(function (a, b) { return a[0][2] - b[0][2]; })
282
+ .forEach(function (q) { dot(c, q[1], 0.03, th.dark ? "#94a3b8" : "#94a3b8", 0.25 + 0.25 * (q[0][2] + 1.7) / 3.4); });
283
+ var o = c.proj([0, 0, 0]);
284
+ star(o[0], o[1], 16, COL.targets);
285
+ label("your target", o[0], o[1] + 36, {size: 15, weight: 800, color: tc("targets"), align: "center"});
286
+ var who = [{ok: false, e: STOPPED[0].e}, {ok: true, i: 0}, {ok: false, e: STOPPED[1].e}, {ok: false, e: STOPPED[2].e},
287
+ {ok: true, i: 1}, {ok: false, e: STOPPED[3].e}, {ok: true, i: 2}, {ok: false, e: STOPPED[4].e}];
288
+ function pos(k, u) {
289
+ var th0 = k * 0.785 + 0.3, ph = 0.6 * Math.sin(k * 1.7), r = 0.18 + 1.25 * T.ease(T.seg(u, 0.06, 0.58)) * (0.6 + 0.4 * ((k * 37) % 10) / 10);
290
+ return [r * Math.cos(th0 + 0.8 * u) * Math.cos(ph), r * Math.sin(ph) + 0.08 * Math.sin(u * 9 + k), r * Math.sin(th0 + 0.8 * u) * Math.cos(ph)];
291
+ }
292
+ var slot = 0;
293
+ who.forEach(function (w, k) {
294
+ var p = pos(k, Math.min(t, 0.6));
295
+ if (t < 0.6) {
296
+ ctx.save(); ctx.strokeStyle = P.css(P.rgbOf(COL.search), 0.5); ctx.lineWidth = 2.5; ctx.beginPath();
297
+ for (var q = 8; q >= 0; q--) { var pp = c.proj(pos(k, Math.max(0, t - q * 0.015))); q === 8 ? ctx.moveTo(pp[0], pp[1]) : ctx.lineTo(pp[0], pp[1]); }
298
+ ctx.stroke(); ctx.restore();
299
+ P.sphere(ctx, c, p, 0.07, th.dark ? "#22c55e" : COL.search, {dark: th.dark, alpha: T.seg(t, 0.03, 0.1)});
300
+ return;
301
+ }
302
+ var pop = T.pop(T.seg(t, 0.6, 0.68)), chk = T.seg(t, 0.7, 0.76), fade = w.ok ? 0 : T.seg(t, 0.8, 0.88), fly = w.ok ? T.ease(T.seg(t, 0.84, 0.97)) : 0;
303
+ var my = w.ok ? slot++ : 0, q = c.proj(p), X = T.lerp(q[0], 740, fly), Y = T.lerp(q[1], 190 + my * 92, fly);
304
+ var e = w.ok ? FOUND[w.i].e : w.e;
305
+ if (fade >= 1) return;
306
+ ctx.save(); ctx.globalAlpha *= 1 - fade;
307
+ P.crystal(ctx, cam(X, Y, 19 * Math.max(0.05, pop), -0.6 + t * 2, -0.35), cell(e), {dark: th.dark, glass: 0.8});
308
+ if (chk > 0) P.check(ctx, X + 24, Y - 24, w.ok, chk, th);
309
+ ctx.restore();
310
+ if (w.ok) label(sub(FOUND[w.i].f), 782, 197 + my * 92, {size: 21, weight: 800, alpha: T.seg(t, 0.92, 0.99)});
311
+ });
312
+ }},
313
+
314
+ {block: "candidates", title: "Candidates", ms: 7800,
315
+ cap: "Each new material comes with its crystal, the rules it passed and its predicted properties. Confirm the best ones with calculations or experiments.",
316
+ draw: function (t) {
317
+ label("example: a search with the Studio defaults", 930, 52, {size: 13, weight: 700, color: th.muted, align: "right", alpha: T.seg(t, 0.1, 0.25)});
318
+ FOUND.forEach(function (f, k) {
319
+ var x0 = 34 + k * 302, a = T.out(T.seg(t, 0.04 + k * 0.12, 0.3 + k * 0.12));
320
+ ctx.save(); ctx.translate(0, 50 * (1 - a)); ctx.globalAlpha *= a;
321
+ card(x0, 70, 286, 330, {stroke: COL.candidates, sw: 2.2});
322
+ P.crystal(ctx, cam(x0 + 70, 146, 34, -0.6 + 0.8 * t + k), cell(f.e), {dark: th.dark});
323
+ label(sub(f.f), x0 + 134, 132, {size: 25, weight: 800});
324
+ label("✓ every rule", x0 + 134, 158, {size: 15, weight: 700, color: th.good});
325
+ if (f.flag) label("⚠ extrapolating", x0 + 134, 180, {size: 13, weight: 700, color: th.warn});
326
+ [["Direct band gap", f.gap, 2.0, "eV", [0, 7.9]], ["Formation enthalpy", f.dh, -0.1, "eV/atom", [-0.64, 5.16]]].forEach(function (b, j) {
327
+ var y = 232 + j * 82, lo = Math.min(b[4][0], b[1], b[2]), hi = Math.max(b[4][1], b[1], b[2]), G = function (v) { return x0 + 22 + (v - lo) / ((hi - lo) || 1) * 242; };
328
+ label(b[0], x0 + 22, y, {size: 14, color: th.muted});
329
+ label(fmt(b[1]) + " " + b[3], x0 + 22, y + 22, {size: 17, weight: 800});
330
+ ctx.fillStyle = th.line; P.rrect(ctx, x0 + 22, y + 32, 242, 7, 3.5); ctx.fill();
331
+ ctx.save(); ctx.setLineDash([3, 2]); ctx.strokeStyle = COL.targets; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(G(b[2]), y + 27); ctx.lineTo(G(b[2]), y + 44); ctx.stroke(); ctx.restore();
332
+ label("target " + fmt(b[2]), T.clamp(G(b[2]), x0 + 50, x0 + 236), y + 60, {size: 12, weight: 700, color: tc("targets"), align: "center"});
333
+ ctx.beginPath(); ctx.arc(G(b[1]), y + 35.5, 6.5, 0, 2 * Math.PI); ctx.fillStyle = COL.candidates; ctx.fill();
334
+ });
335
+ ctx.restore();
336
+ });
337
+ var a2 = T.seg(t, 0.62, 0.74);
338
+ card(245, 428, 470, 42, {r: 21, fill: COL.candidates, stroke: null, alpha: a2});
339
+ label("next: confirm with DFT or an experiment", 480, 455, {size: 19, weight: 700, color: "#fff", align: "center", alpha: a2});
340
+ }},
341
+
342
+ {block: "outro", title: "Your turn", ms: 4600,
343
+ cap: "Your turn: open the Studio, change any block and watch the effect flow through the next ones. A good start: Targets, then Search.",
344
+ draw: function (t) {
345
+ P.crystal(ctx, cam(480, 250, 120, -0.6 + 0.9 * t, -0.35), cell(FOUND[0].e), {dark: th.dark, alpha: th.dark ? 0.07 : 0.1, glass: 0.6});
346
+ var a = T.out(T.seg(t, 0, 0.25));
347
+ label("Your turn", 480, 178, {size: 54, weight: 800, align: "center", alpha: a});
348
+ label("Open the Studio and change any block:", 480, 226, {size: 23, color: th.muted, align: "center", alpha: T.seg(t, 0.15, 0.35)});
349
+ label("the effect flows to the blocks after it.", 480, 258, {size: 23, color: th.muted, align: "center", alpha: T.seg(t, 0.2, 0.4)});
350
+ var wave = (t * 2.2) % 1;
351
+ BLOCKS.forEach(function (b, k) {
352
+ var x = 165 + k * 105, on = Math.abs(wave * 7 - k - 0.5) < 0.8, al = T.seg(t, 0.25 + k * 0.03, 0.35 + k * 0.03);
353
+ if (k < 6) { ctx.save(); ctx.globalAlpha *= T.seg(t, 0.3, 0.4); ctx.strokeStyle = th.faint; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x + 20, 330); ctx.lineTo(x + 85, 330); ctx.stroke(); ctx.restore(); }
354
+ ctx.save(); ctx.globalAlpha *= al; ctx.beginPath(); ctx.arc(x, 330, on ? 17 : 12, 0, 2 * Math.PI); ctx.fillStyle = b.c; ctx.shadowColor = b.c; ctx.shadowBlur = on ? 14 : 0; ctx.fill(); ctx.restore();
355
+ label(b.name, x, 372, {size: 14, weight: 700, color: tc(b.id), align: "center", alpha: al});
356
+ });
357
+ }}
358
+ ];
359
+ function dotFlat(x, y, r, color, a) { ctx.save(); ctx.globalAlpha *= a; ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.restore(); }
360
+ function ringFlat(x, y, r, color, a) { ctx.save(); ctx.globalAlpha *= a; ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.lineWidth = 2.5; ctx.strokeStyle = color; ctx.stroke(); ctx.restore(); }
361
+
362
+ /* ── player ── */
363
+ var TOTAL = SCENES.reduce(function (a, s) { return a + s.ms; }, 0);
364
+ var S = {ms: 0, playing: false, last: 0, raf: 0, i: -1, visible: false, started: false, W: 0, H: 0, dpr: 1};
365
+ var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
366
+ var $ = function (sel) { return root.querySelector(sel); };
367
+ var strip = $(".t3-strip"), cap = $(".t3-cap"), prog = $(".t3-prog"), playBtn = $("[data-t3=play]"), open = $(".t3-open"), time = $(".t3-time");
368
+ prog.setAttribute("aria-valuemax", String(Math.round(TOTAL / 1000)));
369
+ strip.innerHTML = BLOCKS.map(function (b) {
370
+ return '<li><button type="button" data-block="' + b.id + '" style="--bc:' + b.c + '"><span>' + b.name + "</span><i></i></button></li>";
371
+ }).join("");
372
+ function at(ms) { var a = 0; for (var i = 0; i < SCENES.length; i++) { if (ms < a + SCENES[i].ms || i === SCENES.length - 1) return {i: i, t: T.clamp((ms - a) / SCENES[i].ms), start: a}; a += SCENES[i].ms; } }
373
+ function startOf(i) { var a = 0; for (var k = 0; k < i; k++) a += SCENES[k].ms; return a; }
374
+ function clock(ms) { var s = Math.round(ms / 1000); return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2); }
375
+ function resize() {
376
+ var r = canvas.getBoundingClientRect();
377
+ S.dpr = Math.min(2, window.devicePixelRatio || 1); S.W = Math.max(1, r.width); S.H = Math.max(1, r.height);
378
+ canvas.width = Math.round(S.W * S.dpr); canvas.height = Math.round(S.H * S.dpr);
379
+ draw();
380
+ }
381
+ function draw() {
382
+ if (!S.W) return;
383
+ th = P.theme();
384
+ var w = at(S.ms), sc = SCENES[w.i], k = S.W / VW;
385
+ ctx.setTransform(S.dpr, 0, 0, S.dpr, 0, 0); ctx.clearRect(0, 0, S.W, S.H);
386
+ ctx.setTransform(S.dpr * k, 0, 0, S.dpr * k, 0, (S.H - VH * k) / 2 * S.dpr);
387
+ try { sc.draw(w.t); } catch (e) { if (window.console) console.error(e); }
388
+ prog.firstElementChild.style.width = (100 * S.ms / TOTAL).toFixed(2) + "%";
389
+ prog.setAttribute("aria-valuenow", String(Math.round(S.ms / 1000)));
390
+ time.textContent = clock(S.ms) + " / " + clock(TOTAL);
391
+ var bi = BLOCKS.map(function (b) { return b.id; }).indexOf(sc.block);
392
+ strip.querySelectorAll("button").forEach(function (b, j) {
393
+ b.classList.toggle("on", j === bi); b.classList.toggle("done", (bi >= 0 && j < bi) || sc.block === "outro");
394
+ b.querySelector("i").style.width = j === bi ? (100 * w.t).toFixed(1) + "%" : "0";
395
+ });
396
+ if (w.i !== S.i) {
397
+ S.i = w.i;
398
+ var blk = BLOCKS[bi];
399
+ cap.innerHTML = (blk ? '<b style="color:' + tc(blk.id) + '">' + blk.name + ".</b> " : "") + sc.cap;
400
+ root.style.setProperty("--t3c", blk ? blk.c : COL.family);
401
+ open.href = blk ? "/studio/?panel=" + blk.id : "/studio/";
402
+ open.textContent = blk ? "Open " + blk.name + " in the Studio →" : "Open the Studio →";
403
+ }
404
+ }
405
+ function loop(now) {
406
+ S.raf = 0; if (!S.playing) return;
407
+ var dt = S.last ? Math.min(120, now - S.last) : 0; S.last = now;
408
+ if (S.visible && !document.hidden) {
409
+ S.ms = Math.min(TOTAL, S.ms + dt); draw();
410
+ if (S.ms >= TOTAL) { pause(); playBtn.textContent = "↺ Replay"; playBtn.setAttribute("aria-label", "Play the tour again"); return; }
411
+ }
412
+ S.raf = requestAnimationFrame(loop);
413
+ }
414
+ function play() {
415
+ if (S.ms >= TOTAL - 1) S.ms = 0;
416
+ S.playing = true; S.last = 0; S.started = true;
417
+ playBtn.textContent = "❚❚ Pause"; playBtn.setAttribute("aria-label", "Pause the tour");
418
+ if (!S.raf) S.raf = requestAnimationFrame(loop);
419
+ }
420
+ function pause() { S.playing = false; playBtn.textContent = "▶ Play"; playBtn.setAttribute("aria-label", "Play the tour"); }
421
+ function go(i) { // a chapter: from its start while playing, its finished picture while paused
422
+ i = Math.max(0, Math.min(SCENES.length - 1, i));
423
+ S.ms = startOf(i) + (S.playing ? 0 : SCENES[i].ms - 1); S.i = -1; draw();
424
+ }
425
+ playBtn.addEventListener("click", function () { S.playing ? pause() : play(); });
426
+ $("[data-t3=prev]").addEventListener("click", function () { go(at(S.ms).i - 1); });
427
+ $("[data-t3=next]").addEventListener("click", function () { go(at(S.ms).i + 1); });
428
+ strip.addEventListener("click", function (e) {
429
+ var b = e.target.closest && e.target.closest("[data-block]"); if (!b) return;
430
+ var i = SCENES.map(function (s) { return s.block; }).indexOf(b.getAttribute("data-block")); if (i >= 0) go(i);
431
+ });
432
+ function seek(e) { var r = prog.getBoundingClientRect(); S.ms = T.clamp((e.clientX - r.left) / r.width) * TOTAL; S.i = -1; draw(); }
433
+ prog.addEventListener("click", seek);
434
+ prog.addEventListener("keydown", function (e) {
435
+ if (e.key === "ArrowRight") { S.ms = Math.min(TOTAL, S.ms + 5000); draw(); e.preventDefault(); }
436
+ else if (e.key === "ArrowLeft") { S.ms = Math.max(0, S.ms - 5000); draw(); e.preventDefault(); }
437
+ });
438
+ root.addEventListener("keydown", function (e) {
439
+ if (e.target.closest && e.target.closest(".t3-prog")) return;
440
+ if (e.key === "k" || (e.key === " " && e.target === canvas)) { e.preventDefault(); S.playing ? pause() : play(); }
441
+ });
442
+ var th0 = document.getElementById("theme");
443
+ if (th0) th0.addEventListener("click", function () { setTimeout(function () { S.i = -1; draw(); }, 0); });
444
+ if (window.ResizeObserver) new ResizeObserver(resize).observe(canvas); else window.addEventListener("resize", resize);
445
+ if (window.IntersectionObserver) new IntersectionObserver(function (es) {
446
+ S.visible = es[0].isIntersecting;
447
+ if (S.visible && !S.started && !reduce) play(); // starts the first time it is seen
448
+ }, {threshold: 0.35}).observe(canvas);
449
+ else { S.visible = true; if (!reduce) play(); }
450
+ if (reduce) S.ms = SCENES[0].ms - 1; // still pictures; Play animates
451
+ resize();
452
+ window.prismTour = {play: play, pause: pause, go: go, seek: function (ms) { S.ms = T.clamp(ms, 0, TOTAL); S.i = -1; draw(); },
453
+ scenes: SCENES.map(function (s) { return {block: s.block, title: s.title, ms: s.ms}; })};
454
+ })();
site/assets/prism3d.js ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Prism3D: the small 3D toolkit of the MEIDNet Prism landing page (hero scene and the live tour).
2
+ A perspective camera, glossy atoms, glass faces and the cubic ABX3 perovskite cell, drawn on a 2D canvas with the
3
+ painter's algorithm. No libraries. */
4
+ (function () {
5
+ "use strict";
6
+ var FONT = "'Plus Jakarta Sans', system-ui, 'Segoe UI', sans-serif";
7
+ var MONO = "'IBM Plex Mono', ui-monospace, monospace";
8
+ var MATH = "'STIX Two Text', 'Cambria Math', serif";
9
+ var SITE = {A: "#8b5cf6", B: "#3b82f6", X: "#14b8a6"}; // atoms are coloured by site, labelled by element
10
+ var RADIUS = {A: 0.31, B: 0.29, X: 0.23};
11
+
12
+ function isDark() {
13
+ var t = document.documentElement.getAttribute("data-theme");
14
+ if (t) return t === "dark";
15
+ return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
16
+ }
17
+ function theme() {
18
+ var cs = getComputedStyle(document.documentElement), v = function (n, d) { return (cs.getPropertyValue(n) || "").trim() || d; };
19
+ var dark = isDark();
20
+ return {dark: dark, bg: v("--bg", dark ? "#111417" : "#f6f7f5"), card: v("--card", dark ? "#1b1f24" : "#fff"),
21
+ ink: v("--ink", dark ? "#fff" : "#0b0b0b"), muted: v("--muted", dark ? "#c3c2b7" : "#52514e"),
22
+ faint: v("--faint", "#898781"), line: v("--line", dark ? "#2c2c2a" : "#e1e0d9"),
23
+ good: dark ? "#4ade80" : "#16a34a", bad: dark ? "#f87171" : "#dc2626", warn: dark ? "#fbbf24" : "#b45309"};
24
+ }
25
+
26
+ /* colours */
27
+ function rgbOf(hex) { var h = hex.replace("#", ""); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
28
+ function mix(a, b, t) { return a.map(function (v, i) { return Math.round(v + (b[i] - v) * t); }); }
29
+ function css(c, a) { return a === undefined ? "rgb(" + c.join(",") + ")" : "rgba(" + c.join(",") + "," + a + ")"; }
30
+
31
+ /* timing */
32
+ var T = {
33
+ clamp: function (x, a, b) { a = a === undefined ? 0 : a; b = b === undefined ? 1 : b; return Math.max(a, Math.min(b, x)); },
34
+ seg: function (t, a, b) { return Math.max(0, Math.min(1, (t - a) / (b - a))); },
35
+ ease: function (x) { x = Math.max(0, Math.min(1, x)); return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; },
36
+ out: function (x) { x = Math.max(0, Math.min(1, x)); return 1 - Math.pow(1 - x, 3); },
37
+ pop: function (x) { x = Math.max(0, Math.min(1, x)); var c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); },
38
+ lerp: function (a, b, x) { return a + (b - a) * x; },
39
+ rng: function (seed) { return function () { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; }; }
40
+ };
41
+
42
+ /* a perspective camera: screen centre (cx, cy), pixels per world unit at the origin, yaw about y, pitch about x */
43
+ function camera(o) {
44
+ var cy = Math.cos(o.yaw || 0), sy = Math.sin(o.yaw || 0), cp = Math.cos(o.pitch || 0), sp = Math.sin(o.pitch || 0);
45
+ var D = o.dist || 9, F = o.scale * D;
46
+ function view(p) { var x1 = p[0] * cy + p[2] * sy, z1 = -p[0] * sy + p[2] * cy; return [x1, p[1] * cp - z1 * sp, p[1] * sp + z1 * cp]; }
47
+ return {
48
+ view: view,
49
+ proj: function (p) { var v = view(p), k = F / (D - v[2]); return [o.cx + v[0] * k, o.cy - v[1] * k, v[2], k]; }
50
+ };
51
+ }
52
+
53
+ /* drawing */
54
+ function sphere(ctx, cam, p, r, hex, o) {
55
+ o = o || {};
56
+ var q = cam.proj(p), R = r * q[3], c = rgbOf(hex), dark = o.dark;
57
+ if (R < 0.4) return q;
58
+ ctx.save();
59
+ if (o.alpha !== undefined) ctx.globalAlpha *= o.alpha;
60
+ var g = ctx.createRadialGradient(q[0] - R * 0.38, q[1] - R * 0.42, R * 0.06, q[0], q[1], R);
61
+ g.addColorStop(0, "rgba(255,255,255,.96)"); g.addColorStop(0.24, css(mix(c, [255, 255, 255], 0.45)));
62
+ g.addColorStop(0.72, css(c)); g.addColorStop(1, css(mix(c, [0, 0, 0], 0.5)));
63
+ ctx.beginPath(); ctx.arc(q[0], q[1], R, 0, 2 * Math.PI); ctx.fillStyle = g; ctx.fill();
64
+ ctx.lineWidth = Math.max(0.5, R * 0.04); ctx.strokeStyle = dark ? "rgba(0,0,0,.35)" : "rgba(15,23,42,.14)"; ctx.stroke();
65
+ if (o.label && R > 6.5) {
66
+ ctx.font = "800 " + Math.min(22, R * 0.82).toFixed(1) + "px " + FONT;
67
+ ctx.textAlign = "center"; ctx.textBaseline = "middle";
68
+ ctx.lineWidth = Math.max(1.5, R * 0.12); ctx.strokeStyle = "rgba(15,23,42,.45)"; ctx.strokeText(o.label, q[0], q[1] + 0.5);
69
+ ctx.fillStyle = "#fff"; ctx.fillText(o.label, q[0], q[1] + 0.5);
70
+ }
71
+ ctx.restore();
72
+ return q;
73
+ }
74
+ var LIGHT = [-0.45, 0.7, 0.55];
75
+ function glass(ctx, cam, tri, o) {
76
+ o = o || {};
77
+ var q = tri.map(cam.proj), v = tri.map(cam.view);
78
+ var e1 = [v[1][0] - v[0][0], v[1][1] - v[0][1], v[1][2] - v[0][2]], e2 = [v[2][0] - v[0][0], v[2][1] - v[0][1], v[2][2] - v[0][2]];
79
+ var n = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]], l = Math.hypot(n[0], n[1], n[2]) || 1;
80
+ var lit = Math.abs((n[0] * LIGHT[0] + n[1] * LIGHT[1] + n[2] * LIGHT[2]) / l), a = o.alpha === undefined ? 1 : o.alpha, dark = o.dark;
81
+ var tint = rgbOf(o.tint || "#81a1fa");
82
+ ctx.beginPath(); ctx.moveTo(q[0][0], q[0][1]); ctx.lineTo(q[1][0], q[1][1]); ctx.lineTo(q[2][0], q[2][1]); ctx.closePath();
83
+ var g = ctx.createLinearGradient(q[0][0], q[0][1], q[2][0], q[2][1]);
84
+ g.addColorStop(0, dark ? css(mix(tint, [255, 255, 255], 0.35), (0.1 + 0.22 * lit) * a) : css([255, 255, 255], (0.25 + 0.45 * lit) * a));
85
+ g.addColorStop(1, css(tint, (dark ? 0.08 + 0.16 * lit : 0.12 + 0.24 * lit) * a));
86
+ ctx.fillStyle = g; ctx.fill();
87
+ ctx.lineWidth = 1; ctx.strokeStyle = dark ? "rgba(191,219,254," + 0.5 * a + ")" : "rgba(255,255,255," + 0.95 * a + ")"; ctx.stroke();
88
+ ctx.lineWidth = 0.6; ctx.strokeStyle = dark ? "rgba(96,165,250," + 0.35 * a + ")" : "rgba(99,102,241," + 0.25 * a + ")"; ctx.stroke();
89
+ }
90
+ function line3(ctx, cam, a, b, color, width, dash) {
91
+ var p = cam.proj(a), q = cam.proj(b);
92
+ ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width || 1; if (dash) ctx.setLineDash(dash);
93
+ ctx.beginPath(); ctx.moveTo(p[0], p[1]); ctx.lineTo(q[0], q[1]); ctx.stroke(); ctx.restore();
94
+ }
95
+
96
+ /* the cubic ABX3 cell, half edge 1: A on the corners, B in the centre, X on the face centres.
97
+ el = {A: 'Na', B: 'Ni', X: 'I'}; X may be a list of three (the anions on the x, y and z faces). */
98
+ function perovskite(el) {
99
+ var X = Array.isArray(el.X) ? el.X : [el.X, el.X, el.X], atoms = [], edges = [], faces = [];
100
+ [-1, 1].forEach(function (x) { [-1, 1].forEach(function (y) { [-1, 1].forEach(function (z) { atoms.push({p: [x, y, z], site: "A", el: el.A}); }); }); });
101
+ atoms.push({p: [0, 0, 0], site: "B", el: el.B});
102
+ var xs = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
103
+ xs.forEach(function (p, i) { atoms.push({p: p, site: "X", el: X[Math.floor(i / 2)]}); });
104
+ var corners = atoms.slice(0, 8);
105
+ corners.forEach(function (a, i) { corners.forEach(function (b, j) {
106
+ if (j > i && Math.abs(a.p[0] - b.p[0]) + Math.abs(a.p[1] - b.p[1]) + Math.abs(a.p[2] - b.p[2]) === 2) edges.push([a.p, b.p]);
107
+ }); });
108
+ [[0, 2, 4], [0, 4, 3], [0, 3, 5], [0, 5, 2], [1, 4, 2], [1, 3, 4], [1, 5, 3], [1, 2, 5]].forEach(function (f) {
109
+ faces.push([xs[f[0]], xs[f[1]], xs[f[2]]]);
110
+ });
111
+ return {atoms: atoms, edges: edges, faces: faces};
112
+ }
113
+
114
+ /* a crystal at `at`, size `s` (world units per half edge), turned by `spin` about its own vertical axis.
115
+ o.explode (0..1) scatters the atoms (assembly animations), o.glass sets the octahedron's opacity, o.labels names atoms. */
116
+ function crystal(ctx, cam, cell, o) {
117
+ o = o || {};
118
+ var s = o.s || 1, at = o.at || [0, 0, 0], cs = Math.cos(o.spin || 0), sn = Math.sin(o.spin || 0), ex = o.explode || 0;
119
+ var R = T.rng(o.seed || 3);
120
+ function place(p, i) {
121
+ var x = p[0] * cs + p[2] * sn, z = -p[0] * sn + p[2] * cs, y = p[1];
122
+ if (ex) { var j = [R() - 0.5, R() - 0.5, R() - 0.5]; x = x * (1 + 1.6 * ex) + j[0] * 3 * ex; y = y * (1 + 1.6 * ex) + j[1] * 3 * ex; z = z * (1 + 1.6 * ex) + j[2] * 3 * ex; }
123
+ return [at[0] + x * s, at[1] + y * s, at[2] + z * s];
124
+ }
125
+ var items = [], ga = o.glass === undefined ? 1 : o.glass, ea = o.edges === undefined ? 1 : o.edges, placed = {};
126
+ var key = function (p) { return p.join(","); }, at_ = function (p) { return placed[key(p)]; }; // faces and edges follow their atoms
127
+ cell.atoms.forEach(function (a, i) {
128
+ var p = place(a.p, i);
129
+ placed[key(a.p)] = p;
130
+ items.push({z: cam.proj(p)[2], atom: a, p: p});
131
+ });
132
+ if (ga > 0.01) cell.faces.forEach(function (f) {
133
+ var tri = f.map(at_);
134
+ items.push({z: (cam.proj(tri[0])[2] + cam.proj(tri[1])[2] + cam.proj(tri[2])[2]) / 3, tri: tri});
135
+ });
136
+ if (ea > 0.01) cell.edges.forEach(function (e) {
137
+ var a = at_(e[0]), b = at_(e[1]);
138
+ items.push({z: (cam.proj(a)[2] + cam.proj(b)[2]) / 2 - 0.3 * s, edge: [a, b]});
139
+ });
140
+ items.sort(function (m, n) { return m.z - n.z; });
141
+ var alpha = o.alpha === undefined ? 1 : o.alpha;
142
+ ctx.save(); ctx.globalAlpha *= alpha;
143
+ items.forEach(function (it) {
144
+ if (it.atom) sphere(ctx, cam, it.p, RADIUS[it.atom.site] * s * (o.atomScale || 1), (o.colors && o.colors[it.atom.site]) || SITE[it.atom.site],
145
+ {dark: o.dark, label: o.labels ? it.atom.el : null});
146
+ else if (it.tri) glass(ctx, cam, it.tri, {dark: o.dark, alpha: ga});
147
+ else line3(ctx, cam, it.edge[0], it.edge[1], o.dark ? "rgba(148,163,184," + 0.5 * ea + ")" : "rgba(100,116,139," + 0.5 * ea + ")", 1.1);
148
+ });
149
+ ctx.restore();
150
+ }
151
+
152
+ /* flat pieces */
153
+ function rrect(ctx, x, y, w, h, r) {
154
+ r = Math.min(r, w / 2, h / 2);
155
+ ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
156
+ ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
157
+ }
158
+ function text(ctx, str, x, y, o) {
159
+ o = o || {};
160
+ ctx.save();
161
+ if (o.alpha !== undefined) ctx.globalAlpha *= T.clamp(o.alpha);
162
+ ctx.font = (o.italic ? "italic " : "") + (o.weight || 600) + " " + (o.size || 20) + "px " + (o.family || FONT);
163
+ ctx.textAlign = o.align || "left"; ctx.textBaseline = o.baseline || "alphabetic";
164
+ ctx.fillStyle = o.color || "#000"; ctx.fillText(str, x, y);
165
+ ctx.restore();
166
+ }
167
+ function arrow(ctx, x1, y1, x2, y2, p, color, w) {
168
+ if (p <= 0) return;
169
+ var x = T.lerp(x1, x2, p), y = T.lerp(y1, y2, p), a = Math.atan2(y2 - y1, x2 - x1), k = 4 + (w || 3) * 2.6;
170
+ ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = w || 3; ctx.lineCap = "round"; ctx.lineJoin = "round";
171
+ ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x, y);
172
+ ctx.moveTo(x - k * Math.cos(a - 0.45), y - k * Math.sin(a - 0.45)); ctx.lineTo(x, y); ctx.lineTo(x - k * Math.cos(a + 0.45), y - k * Math.sin(a + 0.45));
173
+ ctx.stroke(); ctx.restore();
174
+ }
175
+ function check(ctx, x, y, ok, p, th) {
176
+ if (p <= 0) return;
177
+ var s = T.pop(p);
178
+ ctx.save(); ctx.translate(x, y); ctx.scale(s, s);
179
+ ctx.beginPath(); ctx.arc(0, 0, 13, 0, 2 * Math.PI); ctx.fillStyle = ok ? th.good : th.bad; ctx.fill();
180
+ ctx.strokeStyle = "#fff"; ctx.lineWidth = 3.2; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath();
181
+ if (ok) { ctx.moveTo(-5.5, 0); ctx.lineTo(-1.5, 4.5); ctx.lineTo(6, -5); } else { ctx.moveTo(-5, -5); ctx.lineTo(5, 5); ctx.moveTo(5, -5); ctx.lineTo(-5, 5); }
182
+ ctx.stroke(); ctx.restore();
183
+ }
184
+
185
+ window.Prism3D = {FONT: FONT, MONO: MONO, MATH: MATH, SITE: SITE, T: T, isDark: isDark, theme: theme, rgbOf: rgbOf, mix: mix, css: css,
186
+ camera: camera, sphere: sphere, glass: glass, line3: line3, perovskite: perovskite, crystal: crystal,
187
+ rrect: rrect, text: text, arrow: arrow, check: check};
188
+ })();