PageSourceSearch

https://megjhani.github.io/assets/js/pub-explorer.js

js megjhani.github.io collected 2026-10-03 09:35:48 UTC 23,497 bytes, 473 lines download raw bytes

1import * as THREE from "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js";
2
3/* Publications brain explorer
4   - Full-screen three.js brain (same procedural mesh)
5   - 38 paper nodes on surface; mouse controls spin speed/direction
6   - Hover a node → card with title/venue/year
7   - Click → panel slides in from right with full details + search links
8   - Hover over node = brain slows; move away = resumes spin
9*/
10
11const CAT = {
12  neuro:   { color: "#55d6be", label: "Neurocritical Care AI" },
13  ml:      { color: "#e6b85c", label: "Foundation Models & Methods" },
14  mind:    { color: "#9edc76", label: "Music, Consciousness & Brain States" },
15  imaging: { color: "#c9a6ff", label: "Biological Imaging & Signal Analysis" },
16};
17
18const PAPERS = [
19  { y:2026, t:"ICP-WAVES: Intracranial Pressure Waveform Analysis and Visualization for Enhanced Signal Processing", v:"IEEE TBME",            c:"neuro",   auth:"Megjhani M, Li Y, Grassi G, et al." },
20  { y:2025, t:"Application of a time series foundation model to noninvasively estimate intracranial pressure",     v:"J Clin Monit Comput",  c:"ml",      auth:"Megjhani M, Weinerman B, Alalqum T, et al." },
21  { y:2025, t:"Physiological Signals Boosted by Artificial Intelligence Save Time and Brain Function",              v:"NEJM AI",              c:"neuro",   auth:"Park S, Li Y, Megjhani M" },
22  { y:2025, t:"Let the Experts Speak: Improving Survival Prediction via Mixture-of-Experts Heads (CAMOE)",         v:"NeurIPS",              c:"ml",      auth:"Morrill T, Puli A, Megjhani M, Park S, Zemel R" },
23  { y:2025, t:"Mathematical models for hospital dynamics via information theory",                                   v:"npj Digital Medicine", c:"ml",      auth:"Megjhani M et al." },
24  { y:2025, t:"Pressure reactivity and cerebral oximetry in aneurysmal subarachnoid hemorrhage",                  v:"Neurocritical Care",   c:"neuro",   auth:"Megjhani M et al." },
25  { y:2025, t:"Bronchiolitis low-risk respiratory deterioration cohort study",                                     v:"BMJ Open",             c:"neuro",   auth:"Megjhani M et al." },
26  { y:2025, t:"Pulse-rate variability predicts length of stay in bronchiolitis PICU admissions",                   v:"J Clin Monit Comput",  c:"neuro",   auth:"Megjhani M et al." },
27  { y:2025, t:"Pediatric VA-ECMO early risk factors for mortality",                                                v:"Perfusion",            c:"neuro",   auth:"Megjhani M et al." },
28  { y:2024, t:"Non-invasive pulse arrival time and cardiac index estimation",                                      v:"Physiol Meas",         c:"neuro",   auth:"Megjhani M et al." },
29  { y:2023, t:"Suboptimal cerebral perfusion pressure and ischemia after intracerebral hemorrhage",                v:"Neurocritical Care",   c:"neuro",   auth:"Megjhani M et al." },
30  { y:2023, t:"Heart rate and HRV as prognostic biomarkers after cardiac arrest",                                  v:"Resuscitation Plus",   c:"neuro",   auth:"Megjhani M et al." },
31  { y:2023, t:"Automatic ICP waveform morphology identification via wavelet analysis",                             v:"Physiol Meas",         c:"neuro",   auth:"Megjhani M et al." },
32  { y:2023, t:"A Deep Learning Framework for Deriving Noninvasive Intracranial Pressure Waveforms from TCD",       v:"Annals of Neurology",  c:"neuro",   auth:"Megjhani M, Terilli K, Weinerman B, et al." },
33  { y:2023, t:"Oxygen reactivity and disturbed cerebral perfusion after subarachnoid hemorrhage",                  v:"Critical Care",        c:"neuro",   auth:"Megjhani M et al." },
34  { y:2023, t:"Generalizable DCI detection across centers via federated learning",                                 v:"IEEE BIBM",            c:"ml",      auth:"Megjhani M et al." },
35  { y:2023, t:"Optimal cerebral perfusion pressure and brain tissue oxygen in aneurysmal SAH",                     v:"Stroke",               c:"neuro",   auth:"Megjhani M, Weiss M, Ford J, et al." },
36  { y:2023, t:"Level of consciousness classification in a neurological intensive care unit",                       v:"Neurocritical Care",   c:"mind",    auth:"Megjhani M et al." },
37  { y:2022, t:"Vector Angle Analysis of Multimodal Neuromonitoring for Continuous DCI Prediction",                v:"Neurocritical Care",   c:"neuro",   auth:"Megjhani M, Weiss M, Kwon SB, et al." },
38  { y:2022, t:"Optimal cerebral perfusion pressure during delayed cerebral ischemia after aneurysmal SAH",         v:"Critical Care Medicine",c:"neuro",  auth:"Megjhani M et al." },
39  { y:2022, t:"Real-time machine learning deployment for delayed cerebral ischemia detection",                     v:"IEEE HI-POCT",         c:"ml",      auth:"Megjhani M et al." },
40  { y:2021, t:"Dynamic Detection of Delayed Cerebral Ischemia: A Study in Three Centers",                         v:"Stroke",               c:"neuro",   auth:"Megjhani M, Terilli K, Weiss M, et al." },
41  { y:2021, t:"Dynamic ICP waveform morphology predicts ventriculostomy-related infection",                        v:"Neurocritical Care",   c:"neuro",   auth:"Megjhani M et al." },
42  { y:2021, t:"Endotypes of hospitalized COVID-19 patients",                                                       v:"Frontiers in Medicine",c:"ml",      auth:"Megjhani M et al." },
43  { y:2020, t:"Machine learning to predict delayed cerebral ischemia and outcomes in subarachnoid hemorrhage",     v:"Neurology",            c:"ml",      auth:"Megjhani M et al." },
44  { y:2020, t:"Harnessing big data for neurocritical care precision medicine",                                     v:"Curr Treat Options",   c:"ml",      auth:"Megjhani M et al." },
45  { y:2019, t:"Detection of brain activation in unresponsive patients with acute brain injury",                    v:"NEJM",                 c:"mind",    auth:"Megjhani M et al." },
46  { y:2019, t:"HRV as a biomarker of neurocardiogenic injury after subarachnoid hemorrhage",                      v:"Neurocritical Care",   c:"neuro",   auth:"Megjhani M et al." },
47  { y:2019, t:"Deep brain lesions and impaired consciousness after traumatic brain injury",                        v:"Scientific Reports",   c:"mind",    auth:"Megjhani M et al." },
48  { y:2019, t:"Clustering ICP waveform morphology in patients with ventriculitis",                                 v:"Physiol Meas",         c:"neuro",   auth:"Megjhani M et al." },
49  { y:2018, t:"Active-learning framework for ICP waveform morphology identification",                              v:"Physiol Meas",         c:"ml",      auth:"Megjhani M et al." },
50  { y:2018, t:"Dictionary learning improves delayed cerebral ischemia prediction",                                 v:"Frontiers in Neurology",c:"ml",     auth:"Megjhani M et al." },
51  { y:2017, t:"Mobile EEG brain dynamics during art museum experience",                                            v:"Front Hum Neurosci",   c:"mind",    auth:"Megjhani M et al." },
52  { y:2017, t:"Morphological constraint spectral unmixing of biological tissues",                                  v:"Bioinformatics",       c:"imaging", auth:"Megjhani M, Roysam B" },
53  { y:2015, t:"Your Brain on Art: Emergent Cortical Dynamics During Aesthetic Experiences",                        v:"Front Hum Neurosci",   c:"mind",    auth:"Megjhani M*, Kontson K*, Cruz-Garza JG, et al." },
54  { y:2015, t:"Population-scale 3D reconstruction of microglial arbors from confocal stacks",                      v:"Bioinformatics",       c:"imaging", auth:"Megjhani M, Rey-Villamizar N, et al." },
55  { y:2015, t:"Unsupervised population profiling of microglial morphologies",                                      v:"IEEE J-STSP",          c:"imaging", auth:"Megjhani M et al." },
56  { y:2014, t:"Automated image analysis of tissue around chronically implanted neural devices",                    v:"Front Neuroinform",    c:"imaging", auth:"Megjhani M et al." },
57];
58
59// ---- value noise helpers (same as brain-scene.js) -----------------------
60function hash3(x, y, z) {
61  const n = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453;
62  return n - Math.floor(n);
63}
64function smooth(t) { return t * t * (3 - 2 * t); }
65function vnoise(x, y, z) {
66  const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
67  const xf = x - xi, yf = y - yi, zf = z - zi;
68  const u = smooth(xf), v = smooth(yf), w = smooth(zf);
69  const lerp = (a, b, t) => a + (b - a) * t;
70  return lerp(
71    lerp(lerp(hash3(xi,yi,zi),    hash3(xi+1,yi,zi),    u), lerp(hash3(xi,yi+1,zi),    hash3(xi+1,yi+1,zi),    u), v),
72    lerp(lerp(hash3(xi,yi,zi+1),  hash3(xi+1,yi,zi+1),  u), lerp(hash3(xi,yi+1,zi+1),  hash3(xi+1,yi+1,zi+1),  u), v),
73    w
74  );
75}
76function fbm(x, y, z) {
77  let f = 0, amp = 0.5, freq = 1;
78  for (let i = 0; i < 5; i++) { f += amp * vnoise(x*freq, y*freq, z*freq); freq *= 2.1; amp *= 0.48; }
79  return f;
80}
81
82const _v  = new THREE.Vector3();
83const _v2 = new THREE.Vector3();
84
85// ridged, anisotropic noise → cortex-like gyral ridges (shared with brain-scene.js)
86function brainFold(dir) {
87  const wx = fbm(dir.x*1.4+1.2, dir.y*1.4+4.7, dir.z*1.4+8.3);
88  const wy = fbm(dir.x*1.4+9.2, dir.y*1.4+2.3, dir.z*1.4+1.7);
89  const F = 4.6;
90  const n1 = fbm(dir.x*F*1.1+wx*2.6, dir.y*F*1.9+wy*2.6, dir.z*F*0.7+wx*1.8);
91  let r1 = 1 - Math.abs(2 * n1 - 1);
92  r1 = r1 * r1 * (3 - 2 * r1);
93  const n2 = fbm(dir.x*F*2.4+5.1, dir.y*F*3.3+2.7, dir.z*F*1.6+9.4);
94  const r2 = 1 - Math.abs(2 * n2 - 1);
95  return r1 * 0.72 + r2 * 0.28;
96}
97function brainPoint(dir, out, scale) {
98  const r = 1 + 0.46 * (brainFold(dir) - 0.5);
99  out.copy(dir).multiplyScalar(r * scale);
100  out.x *= 0.96; out.z *= 1.26; out.y *= 0.94;
101  const z = out.z / scale;
102  const frontNarrow = 1 - 0.12 * smooth(Math.max(0, Math.min(1, (z - 0.25) / 0.85)));
103  const backNarrow  = 1 - 0.18 * smooth(Math.max(0, Math.min(1, (-z - 0.70) / 0.60)));
104  out.x *= frontNarrow * backNarrow;
105  const fr = smooth(Math.max(0, Math.min(1, (z - 0.10) / 0.95)));
106  if (out.y > 0) out.y *= 1 + fr * 0.08;
107  // Sylvian fissure + temporal lobe
108  const sideAbs = Math.abs(out.x) / scale;
109  const sylLine = (-0.02 + z * 0.16) * scale;
110  const sylDist = (out.y - sylLine) / scale;
111  const syl = Math.exp(-(sylDist*sylDist) / 0.014) * smooth(Math.max(0, Math.min(1, (sideAbs - 0.28) / 0.35)));
112  out.addScaledVector(dir, -0.30 * scale * syl);
113  const tl = Math.max(0, (sylLine - out.y)/scale - 0.02) * smooth(Math.max(0, Math.min(1, (sideAbs - 0.24) / 0.4)));
114  out.y -= tl * 0.42 * scale;
115  out.x += Math.sign(out.x) * tl * 0.26 * scale;
116  // longitudinal fissure
117  const mid = Math.exp(-(out.x*out.x) / (0.0085*scale*scale));
118  const top = smooth(Math.max(0, Math.min(1, (out.y/scale + 0.02) / 0.5)));
119  out.addScaledVector(dir, -0.70 * scale * mid * top);
120  // flat base
121  if (out.y < -0.30*scale) out.y = -0.30*scale + (out.y + 0.30*scale) * 0.40;
122  // occipital taper
123  if (z < -0.55) {
124    const t = (-z - 0.55) / 0.55;
125    out.x *= 1 - t * 0.34;
126    out.y -= t * t * 0.10 * scale;
127  }
128  // cerebellum
129  const zb = out.z/scale + 0.70, yb = out.y/scale + 0.30;
130  const cb = Math.exp(-(zb*zb)/0.055) * Math.exp(-(yb*yb)/0.045);
131  out.y -= cb * 0.14 * scale;
132  out.z -= cb * 0.08 * scale;
133  // brainstem nub
134  const zs = out.z/scale + 0.30, xs = out.x/scale;
135  const bs = Math.exp(-(zs*zs)/0.03) * Math.exp(-(xs*xs)/0.02) * Math.max(0, -out.y/scale - 0.34);
136  out.y -= bs * 0.5 * scale;
137  return out;
138}
139
140// ---- DOM refs -------------------------------------------------------
141const canvas   = document.getElementById("pub-canvas");
142const card     = document.getElementById("pub-card");
143const cardTitle = document.getElementById("pc-title");
144const cardMeta  = document.getElementById("pc-meta");
145const cardDot   = document.getElementById("pc-dot");
146const panel     = document.getElementById("pub-panel");
147const panelBody = document.getElementById("pub-panel-body");
148const closeBtn  = document.getElementById("pub-close");
149const backdrop  = document.getElementById("pub-backdrop");
150const hint      = document.getElementById("pub-hint");
151
152if (!canvas) throw new Error("pub-canvas not found");
153
154// ---- Three.js scene -------------------------------------------------
155const scene    = new THREE.Scene();
156const camera   = new THREE.PerspectiveCamera(55, 1, 0.1, 50);
157camera.position.set(0, 0.1, 5.0);
158
159const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
160renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
161
162scene.add(new THREE.AmbientLight(0xbfeee6, 0.6));
163const key = new THREE.DirectionalLight(0xffffff, 0.7);
164key.position.set(2, 3, 4);
165scene.add(key);
166
167const world = new THREE.Group();
168scene.add(world);
169
170// ---- build brain ----------------------------------------------------
171const BSCALE  = 1.55;
172const ico     = new THREE.IcosahedronGeometry(1, 4);
173const srcPos  = ico.getAttribute("position");
174const faces   = srcPos.count / 3;
175const dir     = new THREE.Vector3();
176const A = new THREE.Vector3(), B = new THREE.Vector3(), C = new THREE.Vector3();
177
178const posArr = [], colArr = [];
179const sulcusC = new THREE.Color("#020a08");
180const gyrusC  = new THREE.Color("#dffdf7");
181
182for (let k = 0; k < 18000; k++) {
183  const f = (Math.random() * faces) | 0;
184  A.set(srcPos.getX(f*3),   srcPos.getY(f*3),   srcPos.getZ(f*3));
185  B.set(srcPos.getX(f*3+1), srcPos.getY(f*3+1), srcPos.getZ(f*3+1));
186  C.set(srcPos.getX(f*3+2), srcPos.getY(f*3+2), srcPos.getZ(f*3+2));
187  let u = Math.random(), v = Math.random();
188  if (u + v > 1) { u = 1 - u; v = 1 - v; }
189  dir.copy(A).multiplyScalar(1-u-v).addScaledVector(B, u).addScaledVector(C, v).normalize();
190  brainPoint(dir, _v, BSCALE);
191  const groove = Math.exp(-(_v.x*_v.x) / (0.010*BSCALE*BSCALE)) * (_v.y > -0.02*BSCALE ? 1 : 0);
192  if (groove > 0.38) continue;
193  posArr.push(_v.x, _v.y, _v.z);
194  const fv = brainFold(dir);
195  const bc = sulcusC.clone().lerp(gyrusC, Math.pow(fv, 0.65));
196  colArr.push(bc.r, bc.g, bc.b);
197}
198
199const brainGeo = new THREE.BufferGeometry();
200brainGeo.setAttribute("position", new THREE.BufferAttribute(Float32Array.from(posArr), 3));
201brainGeo.setAttribute("color",    new THREE.BufferAttribute(Float32Array.from(colArr), 3));
202
203const brainMat = new THREE.ShaderMaterial({
204  transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
205  uniforms: {
206    uSize:  { value: 0.028 },
207    uScale: { value: 1 },
208    uFade:  { value: 1 },
209  },
210  vertexShader: `
211    attribute vec3 color;
212    uniform float uSize; uniform float uScale; uniform float uFade;
213    varying vec3 vColor; varying float vAlpha;
214    void main() {
215      vColor = color;
216      vec4 mv = modelViewMatrix * vec4(position, 1.0);
217      float depth = clamp((-mv.z - 1.8) / 4.8, 0.0, 1.0);
218      vAlpha = (0.80 - depth * 0.48) * uFade;
219      gl_PointSize = uSize * uScale * (1.3 - depth * 0.38) / -mv.z;
220      gl_Position  = projectionMatrix * mv;
221    }
222  `,
223  fragmentShader: `
224    varying vec3 vColor; varying float vAlpha;
225    void main() {
226      vec2 c = gl_PointCoord - 0.5;
227      if (length(c) > 0.5) discard;
228      float a = smoothstep(0.5, 0.06, length(c));
229      gl_FragColor = vec4(vColor, a * vAlpha);
230    }
231  `,
232});
233world.add(new THREE.Points(brainGeo, brainMat));
234
235// faint body
236const bodyGeo = new THREE.IcosahedronGeometry(1, 4);
237const bp      = bodyGeo.getAttribute("position");
238for (let i = 0; i < bp.count; i++) {
239  dir.set(bp.getX(i), bp.getY(i), bp.getZ(i)).normalize();
240  brainPoint(dir, _v, BSCALE);
241  bp.setXYZ(i, _v.x, _v.y, _v.z);
242}
243bp.needsUpdate = true;
244bodyGeo.computeVertexNormals();
245world.add(new THREE.Mesh(bodyGeo, new THREE.MeshStandardMaterial({
246  color: 0x2f9e93, transparent: true, opacity: 0.06,
247  roughness: 0.8, metalness: 0, depthWrite: false,
248})));
249
250// ---- paper nodes on surface -----------------------------------------
251function glowTex() {
252  const cv = document.createElement("canvas");
253  cv.width = cv.height = 64;
254  const ctx = cv.getContext("2d");
255  const g   = ctx.createRadialGradient(32,32,0,32,32,32);
256  g.addColorStop(0,   "rgba(255,255,255,1)");
257  g.addColorStop(0.35,"rgba(255,255,255,0.65)");
258  g.addColorStop(1,   "rgba(255,255,255,0)");
259  ctx.fillStyle = g; ctx.fillRect(0,0,64,64);
260  return new THREE.CanvasTexture(cv);
261}
262const glowTexture = glowTex();
263const nodeGroup   = new THREE.Group();
264world.add(nodeGroup);
265const nodeObjs = [], hitObjs = [];
266const golden   = Math.PI * (3 - Math.sqrt(5));
267
268for (let i = 0; i < PAPERS.length; i++) {
269  const y   = 1 - (i / (PAPERS.length - 1)) * 2;
270  const rad = Math.sqrt(1 - y * y);
271  dir.set(Math.cos(golden * i) * rad, y, Math.sin(golden * i) * rad).normalize();
272  brainPoint(dir, _v, BSCALE);
273  _v.addScaledVector(dir, 0.07);   // lift off surface
274
275  const color = new THREE.Color(CAT[PAPERS[i].c].color);
276  const group = new THREE.Group();
277  group.position.copy(_v);
278
279  // visible sphere
280  const core = new THREE.Mesh(
281    new THREE.SphereGeometry(0.022, 12, 12),
282    new THREE.MeshBasicMaterial({ color })
283  );
284  // glow sprite
285  const glow = new THREE.Sprite(new THREE.SpriteMaterial({
286    map: glowTexture, color, transparent: true, opacity: 0.5,
287    blending: THREE.AdditiveBlending, depthWrite: false,
288  }));
289  glow.scale.setScalar(0.16);
290  // invisible hit sphere (larger for comfort)
291  const hit = new THREE.Mesh(
292    new THREE.SphereGeometry(0.08, 8, 8),
293    new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false })
294  );
295  hit.userData.index = i;
296  hitObjs.push(hit);
297
298  group.add(core, glow, hit);
299  group.userData = { hover: 0, base: color };
300  nodeGroup.add(group);
301  nodeObjs.push(group);
302}
303
304// ---- resize ---------------------------------------------------------
305function resize() {
306  const w = window.innerWidth, h = window.innerHeight;
307  renderer.setSize(w, h, false);
308  camera.aspect = w / h;
309  camera.updateProjectionMatrix();
310  brainMat.uniforms.uScale.value = renderer.domElement.height * 0.5;
311
312  // fit-to-width: on narrow/portrait screens shrink the brain so its longest
313  // axis (~2.0 world units half-extent, swings into view as it rotates) fits
314  const halfH = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camera.position.z;
315  const halfW = halfH * camera.aspect;
316  world.scale.setScalar(Math.min(1, (halfW * 0.88) / 2.0));
317}
318window.addEventListener("resize", resize);
319resize();
320
321// ---- interaction ----------------------------------------------------
322const mouse   = new THREE.Vector2(0, 0);
323const pointer = new THREE.Vector2(0, 0);
324let rect      = canvas.getBoundingClientRect();
325let hovIdx    = -1;
326let panelOpen = false;
327
328function updatePointer(e) {
329  mouse.x   = (e.clientX / window.innerWidth)  * 2 - 1;
330  mouse.y   = -((e.clientY / window.innerHeight) * 2 - 1);
331  pointer.x = ((e.clientX - rect.left) / rect.width)  * 2 - 1;
332  pointer.y = -(((e.clientY - rect.top)  / rect.height) * 2 - 1);
333}
334window.addEventListener("pointermove", updatePointer);
335window.addEventListener("scroll", () => { rect = canvas.getBoundingClientRect(); }, { passive: true });
336
337// drag (mouse or finger) rotates the brain directly, with inertia
338let dragging = false, dragMoved = 0, lastDragX = 0, dragVel = 0;
339canvas.addEventListener("pointerdown", (e) => {
340  updatePointer(e);          // touch never fires pointermove before tap
341  dragging = true;
342  dragMoved = 0;
343  lastDragX = e.clientX;
344  canvas.setPointerCapture(e.pointerId);
345});
346canvas.addEventListener("pointermove", (e) => {
347  if (!dragging || panelOpen) return;
348  const dx = e.clientX - lastDragX;
349  lastDragX = e.clientX;
350  dragMoved += Math.abs(dx);
351  dragVel = dx * 0.012;   // ~half a screen drag = full revolution
352  autoAngle += dragVel;
353});
354window.addEventListener("pointerup", () => { dragging = false; });
355
356canvas.addEventListener("click", () => {
357  if (dragMoved > 8) return;          // it was a drag, not a tap
358  if (hovIdx >= 0) openPanel(hovIdx);
359});
360
361if (closeBtn) closeBtn.addEventListener("click",  closePanel);
362if (backdrop) backdrop.addEventListener("click",  closePanel);
363
364document.addEventListener("keydown", (e) => {
365  if (e.key === "Escape") closePanel();
366});
367
368function openPanel(idx) {
369  const p = PAPERS[idx];
370  const cat = CAT[p.c];
371  const qTitle = encodeURIComponent(p.t);
372  panelBody.innerHTML = `
373    <span class="pp-cat" style="--cc:${cat.color}">${cat.label}</span>
374    <h2 class="pp-title">${p.t}</h2>
375    <p class="pp-auth">${p.auth}</p>
376    <div class="pp-meta">
377      <span class="pp-venue">${p.v}</span>
378      <span class="pp-year">${p.y}</span>
379    </div>
380    <div class="pp-actions">
381      <a class="pp-btn pp-btn--primary" href="https://scholar.google.com/scholar?q=${qTitle}" target="_blank" rel="noreferrer">
382        Open on Google Scholar
383      </a>
384      <a class="pp-btn" href="https://pubmed.ncbi.nlm.nih.gov/?term=${qTitle}" target="_blank" rel="noreferrer">
385        Search PubMed
386      </a>
387    </div>
388  `;
389  panel.classList.add("is-open");
390  if (backdrop) backdrop.classList.add("is-open");
391  panelOpen = true;
392  if (hint) hint.style.opacity = "0";
393}
394
395function closePanel() {
396  panel.classList.remove("is-open");
397  if (backdrop) backdrop.classList.remove("is-open");
398  panelOpen = false;
399  if (hint) hint.style.opacity = "";
400}
401
402// dev helper: ?open=N opens paper N's panel (also handy for deep links)
403const openParam = new URLSearchParams(window.location.search).get("open");
404if (openParam !== null) {
405  const idx = Math.max(0, Math.min(PAPERS.length - 1, parseInt(openParam, 10) || 0));
406  openPanel(idx);
407}
408
409// ---- animate --------------------------------------------------------
410const raycaster = new THREE.Raycaster();
411const tmp       = new THREE.Vector3();
412let autoAngle   = 0;
413let smoothTiltX = 0;
414
415function animate() {
416  // constant slow auto-spin (mouse-X steering removed — it fought the drag
417  // and blocked full revolutions); hover slows; drag overrides with inertia
418  const hoverSlow = hovIdx >= 0 ? 0.05 : 1.0;
419  const spinSpeed = 0.0016 * hoverSlow;
420  if (!dragging) {
421    autoAngle += spinSpeed + dragVel;   // dragVel = inertia after release
422    dragVel *= 0.94;
423  }
424  smoothTiltX += (-0.18 - mouse.y * 0.20 - smoothTiltX) * 0.04;
425
426  if (!panelOpen) {
427    world.rotation.y = autoAngle;
428    world.rotation.x = smoothTiltX;
429  }
430
431  // raycasting
432  raycaster.setFromCamera(pointer, camera);
433  const hits  = raycaster.intersectObjects(hitObjs, false);
434  const newHov = hits.length ? hits[0].object.userData.index : -1;
435
436  // update node scales
437  nodeObjs.forEach((node, i) => {
438    const target = (i === newHov) ? 1.9 : 1.0;
439    node.userData.hover += (target - node.userData.hover) * 0.14;
440    node.scale.setScalar(node.userData.hover);
441    node.children[1].material.opacity = (0.45 + (node.userData.hover - 1) * 0.4);
442  });
443
444  // update hover card
445  if (newHov !== hovIdx) {
446    hovIdx = newHov;
447    if (hovIdx >= 0) {
448      const p = PAPERS[hovIdx];
449      cardTitle.textContent = p.t;
450      cardMeta.textContent  = `${p.v} · ${p.y}`;
451      cardDot.style.setProperty("--dc", CAT[p.c].color);
452      cardDot.style.boxShadow = `0 0 10px ${CAT[p.c].color}`;
453      card.classList.add("is-visible");
454      canvas.style.cursor = "pointer";
455    } else {
456      card.classList.remove("is-visible");
457      canvas.style.cursor = "";
458    }
459  }
460
461  // reposition hover card every frame while visible
462  if (hovIdx >= 0 && !panelOpen) {
463    tmp.setFromMatrixPosition(nodeObjs[hovIdx].matrixWorld);
464    tmp.project(camera);
465    const sx = rect.left + (tmp.x * 0.5 + 0.5) * rect.width;
466    const sy = rect.top  + (-tmp.y * 0.5 + 0.5) * rect.height;
467    card.style.transform = `translate(-50%, -120%) translate(${sx}px, ${sy}px)`;
468  }
469
470  renderer.render(scene, camera);
471  requestAnimationFrame(animate);
472}
473animate();

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.