PageSourceSearch

https://dorskylab.netlify.app/js/app.js

js dorskylab.netlify.app collected 2026-10-03 10:39:43 UTC 28,072 bytes, 629 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════════
2   DORSKY LAB — App Logic
3   WCAG 2.1 AA Compliant
4═══════════════════════════════════════════════════════════════ */
5
6/* ── DATA is loaded from data.json at runtime (see DOMContentLoaded below) ── */
7
8/* ═══════════════════════════════════════════════════════════════
9   WEBGL HERO CANVAS — Zebrafish particle field
10═══════════════════════════════════════════════════════════════ */
11function initHeroCanvas() {
12    const canvas = document.getElementById('hero-canvas');
13    if (!canvas || !window.THREE) return;
14
15    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
16        canvas.style.display = 'none';
17        return;
18    }
19
20    const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
21    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
22    renderer.setSize(canvas.clientWidth, canvas.clientHeight);
23
24    const scene = new THREE.Scene();
25    const camera = new THREE.PerspectiveCamera(55, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
26    camera.position.z = 2.5;
27
28    const W = 180, H = 130;
29    const count = W * H;
30    const positions = new Float32Array(count * 3);
31    const colors = new Float32Array(count * 3);
32    const scales = new Float32Array(count);
33    const randoms = new Float32Array(count);
34
35    for (let i = 0; i < W; i++) {
36        for (let j = 0; j < H; j++) {
37            const idx = i * H + j;
38            const u = (i / W - 0.5) * 3.0;
39            const v = (j / H - 0.5) * 2.2;
40
41            const bodyDist = Math.sqrt((u / 1.1) ** 2 + (v / 0.55) ** 2);
42            const tailX = u - 1.0;
43            const tailDist = Math.sqrt((tailX / 0.4) ** 2 + (v / 0.32) ** 2);
44            const mask = bodyDist < 1.0 || (tailDist < 1.0 && u > 0.55);
45
46            positions[idx * 3] = u + (Math.random() - 0.5) * 0.014;
47            positions[idx * 3 + 1] = v + (Math.random() - 0.5) * 0.014;
48            positions[idx * 3 + 2] = 0;
49
50            const t = (u + 1.5) / 3.0;
51            if (mask) {
52                colors[idx * 3] = 0.90 - t * 0.35;
53                colors[idx * 3 + 1] = t * 0.03;
54                colors[idx * 3 + 2] = t * 0.03;
55            } else {
56                colors[idx * 3] = 0.08 + t * 0.03;
57                colors[idx * 3 + 1] = 0.05;
58                colors[idx * 3 + 2] = 0.05;
59            }
60
61            scales[idx] = mask ? 1.2 + Math.random() * 1.0 : 0.1 + Math.random() * 0.08;
62            randoms[idx] = Math.random() * Math.PI * 2;
63        }
64    }
65
66    const geo = new THREE.BufferGeometry();
67    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
68    geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
69    geo.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
70    geo.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
71
72    const mat = new THREE.ShaderMaterial({
73        vertexColors: true, transparent: true, depthWrite: false,
74        uniforms: {
75            uTime: { value: 0 },
76            uMouse: { value: new THREE.Vector2(0, 0) },
77            uPixelRatio: { value: renderer.getPixelRatio() },
78        },
79        vertexShader: `
80      attribute float aScale; attribute float aRandom;
81      uniform float uTime; uniform vec2 uMouse; uniform float uPixelRatio;
82      varying vec3 vColor;
83      void main() {
84        vColor = color;
85        vec3 pos = position;
86        vec2 toMouse = pos.xy - uMouse * vec2(1.5, 1.1);
87        float dist = length(toMouse);
88        float wave = sin(dist * 5.5 - uTime * 2.2) * 0.05 * (1.0 / (dist * 4.0 + 1.0));
89        pos.xy += normalize(toMouse + 0.001) * wave;
90        pos.x += sin(uTime * 0.55 + aRandom) * 0.0025;
91        pos.y += cos(uTime * 0.45 + aRandom * 1.2) * 0.0025;
92        gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0
92);
93        gl_PointSize = aScale * 3.2 * uPixelRatio;
94      }
95    `,
96        fragmentShader: `
97      varying vec3 vColor;
98      void main() {
99        vec2 uv = gl_PointCoord - 0.5;
100        float d = length(uv);
101        float alpha = 1.0 - smoothstep(0.35, 0.5, d);
102        gl_FragColor = vec4(vColor, alpha * 0.94);
103      }
104    `
105    });
106
107    const points = new THREE.Points(geo, mat);
108    scene.add(points);
109
110    const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
111    window.addEventListener('mousemove', (e) => {
112        const rect = canvas.getBoundingClientRect();
113        mouse.tx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
114        mouse.ty = -((e.clientY - rect.top) / rect.height) * 2 + 1;
115    });
116
117    window.addEventListener('resize', () => {
118        camera.aspect = canvas.clientWidth / canvas.clientHeight;
119        camera.updateProjectionMatrix();
120        renderer.setSize(canvas.clientWidth, canvas.clientHeight);
121    });
122
123    let t = 0, raf;
124    const animate = () => {
125        raf = requestAnimationFrame(animate);
126        t += 0.01;
127        mouse.x += (mouse.tx - mouse.x) * 0.06;
128        mouse.y += (mouse.ty - mouse.y) * 0.06;
129        mat.uniforms.uTime.value = t;
130        mat.uniforms.uMouse.value.set(mouse.x, mouse.y);
131        points.rotation.y = mouse.x * 0.07;
132        points.rotation.x = -mouse.y * 0.04;
133        renderer.render(scene, camera);
134    };
135    animate();
136
137    return () => { cancelAnimationFrame(raf); renderer.dispose(); };
138}
139
140/* ═══════════════════════════════════════════════════════════════
141   LEAFLET MAP
142═══════════════════════════════════════════════════════════════ */
143function initMap() {
144    if (window._mapInited) return;
145    window._mapInited = true;
146
147    const el = document.getElementById('leaflet-map');
148    if (!el || !window.L) return;
149
150    const LAB = [40.7649, -111.8359];
151    const map = L.map('leaflet-map', { center: LAB, zoom: 16, zoomControl: false });
152
153    L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
154        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
155        maxZoom: 20
156    }).addTo(map);
157
158    const markerHtml = `
159    <div style="width:40px;height:40px;background:rgba(190,0,0,0.2);border:2px solid #BE0000;
160    border-radius:50%;display:flex;align-items:center;justify-content:center;
161    box-shadow:0 0 24px rgba(190,0,0,0.4);" role="img" aria-label="Dorsky Lab location marker">
162      <div style="width:10px;height:10px;background:#BE0000;border-radius:50%;"></div>
163    </div>`;
164
165    const icon = L.divIcon({ html: markerHtml, className: '', iconSize: [40, 40], iconAnchor: [20, 20] });
166    L.marker(LAB, { icon, alt: 'Dorsky Lab, BPRB Building' }).addTo(map)
167        .bindPopup(`
168            <b style="font-size:12px">Dorsky Lab · BPRB</b><br/>
169            20 S 2030 E<br/>
170            Salt Lake City, UT
171        `);
172    L.control.zoom({ position: 'bottomright' }).addTo(map);
173}
174
175/* ═══════════════════════════════════════════════════════════════
176   INTERSECTION OBSERVER — animate-on-scroll
177═══════════════════════════════════════════════════════════════ */
178function setupReveal() {
179    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
180        document.querySelectorAll('.reveal, .rq-item, .pub-card, .research-area-card, .pi-card')
181            .forEach(el => el.classList.add('visible'));
182        return;
183    }
184
185    const targets = document.querySelectorAll('.reveal, .rq-item, .pub-card, .research-area-card, .pi-card');
186    const obs = new IntersectionObserver((entries) => {
187        entries.forEach((e, i) => {
188            if (e.isIntersecting) {
189                setTimeout(() => e.target.classList.add('visible'), i * 60);
190                obs.unobserve(e.target);
191            }
192        });
193    }, { threshold: 0.1 });
194    targets.forEach(t => obs.observe(t));
195}
196
197/* ═══════════════════════════════════════════════════════════════
198   BREADCRUMB
199═══════════════════════════════════════════════════════════════ */
200function renderBreadcrumb(pageName) {
201    if (pageName === 'Home') return '';
202    return `
203    <nav aria-label="Breadcrumb" class="breadcrumb">
204      <ol class="breadcrumb-inner" style="list-style:none;padding:0;margin:0;display:flex;align-items:center;gap:8px;">
205        <li>
206          <button class="breadcrumb-home-link" onclick="navigate('Home')">Home</button>
207        </li>
208        <li aria-hidden="true" class="breadcrumb-sep">›</li>
209        <li>
210          <span class="breadcrumb-current" aria-current="page">${pageName}</span>
211        </li>
212      </ol>
213    </nav>`;
214}
215
216/* ═══════════════════════════════════════════════════════════════
217   EXTERNAL LINK HELPER
218═══════════════════════════════════════════════════════════════ */
219function extLink(href, cls, text) {
220    return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="${cls}">${text}<span class="sr-only"> (opens in new tab)</span></a>`;
221}
222
223/* ═══════════════════════════════════════════════════════════════
224   PAGE RENDERERS
225═══════════════════════════════════════════════════════════════ */
226
227function renderHome() {
228    return `
229    <section class="hero" aria-label="Lab introduction">
230      <div class="hero-left">
231        <p class="hero-eyebrow" aria-hidden="true">Dorsky Lab · Est. 2001</p>
232        <h1 class="hero-title">
233          Wnt signaling
234          in the <strong>CNS</strong>
235        </h1>
236        <button class="hero-cta" onclick="navigate('Research')" type="button">
237          Explore Research
238          <span class="hero-cta-arrow" aria-hidden="true">→</span>
239        </button>
240      </div>
241      <div class="hero-right" style="display:flex;align-items:center;justify-content:center;background:#000;">
242        <img src="images/fig1.png" alt="Wnt signaling in the central nervous system"
243             style="width:70%;height:auto;max-height:80%;object-fit:contain;"/>
244      </div>
245    </section>
246  `;
247}
248
249function renderResearch() {
250    return `
251    ${renderBreadcrumb('Research')}
252    <div class="page-banner" role="banner">
253      <div class="page-banner-inner">
254        <p class="page-banner-eyebrow" aria-hidden="true">Research Program</p>
255        <h1 class="page-banner-title">We study the function of Wnt-dependent transcription in the zebrafish central nervous system</h1>
256      </div>
257    </div>
258
259    <section class="section" style="padding-top:96px" aria-labelledby="active-areas-heading">
260      <div class="section-inner">
261        <div class="reveal">
262          <span class="label" aria-hidden="true">Active Research Areas</span>
263          <div class="red-rule" aria-hidden="true"></div>
264          <h2 id="active-areas-heading" class="section-title">Current <em>projects</em></h2>
265        </div>
266        <div style="margin-top:48px">
267          ${DATA.research.areas.map((a, i) => `
268            <article class="research-area-card" style="transition-delay:${i * 150}ms">
269              <div class="ra-tag">${a.tag}</div>
270              <h3 class="ra-title">${a.title}</h3>
271              <p class="ra-body">${a.body}</p>
272              <img src="${a.fig}" alt="${a.figAlt}"
273                   style="margin-top:28px;max-width:420px;width:100%;border-radius:2px;display:block;"
274                   onerror="this.style.display='none'"/>
274
275            </article>`).join('')}
276        </div>
277      </div>
278    </section>
279  `;
280}
281
282function renderMembers() {
283    const pi = DATA.members.pi;
284    return `
285    ${renderBreadcrumb('Members')}
286    <div class="page-banner" role="banner">
287      <div class="page-banner-inner" style="display:grid;grid-template-columns:1fr 1fr;align-items:center;">
288        <div>
289          <p class="page-banner-eyebrow" aria-hidden="true">The Team</p>
290          <h1 class="page-banner-title">Lab <strong><em>Members</em></strong></h1>
291        </div>
292        <div style="display:flex;justify-content:center;align-items:center;">
293          <img src="images/fig4.png" alt="Dorsky Lab group photo"
294               style="max-width:480px;width:100%;max-height:360px;object-fit:contain;display:block;"
295               onerror="this.style.display='none'"/>
296        </div>
297      </div>
298    </div>
299
300    <section class="section" aria-labelledby="pi-heading">
301      <div class="section-inner">
302        <span class="label" aria-hidden="true">Principal Investigator</span>
303        <div class="red-rule" aria-hidden="true"></div>
304
305        <article class="pi-card" aria-label="Principal Investigator profile">
306          <div class="pi-photo-wrap">
307            <img src="${pi.img}" alt="Portrait of ${pi.name}, ${pi.role}"
308                 onerror="this.style.display='none';this.parentElement.style.background='var(--bg-dark-2)'"/>
309            <div class="pi-photo-overlay" aria-hidden="true"></div>
310          </div>
311          <div class="pi-info">
312            <div class="pi-badge" aria-hidden="true">Principal Investigator</div>
313            <h2 id="pi-heading" class="pi-name">${pi.name}</h2>
314            <p class="pi-title">${pi.title}</p>
315            <a href="mailto:${pi.email}" class="pi-email">${pi.email}</a>
316            <p class="pi-bio">${pi.bio}</p>
317          </div>
318        </article>
319
320        <section style="margin-top:72px" aria-labelledby="current-members-heading">
321          <h2 id="current-members-heading" class="label" style="margin-bottom:28px;font-size:inherit">Current Lab Members</h2>
322          <ul class="members-grid" style="list-style:none;padding:0;">
323            ${DATA.members.current.map((m, i) => `
324              <li class="member-card reveal" style="transition-delay:${i * 80}ms">
325                <div class="member-photo-wrap">
326                  ${m.img
327        ? `<img src="${m.img}" alt="Portrait of ${m.name}"
328                            onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"/>`
329        : ''}
330                  <div class="member-placeholder" style="display:${m.img ? 'none' : 'flex'}" aria-hidden="true">
331                    <span class="member-initials">${m.initials}</span>
332                  </div>
333                </div>
334                <div class="member-info">
335                  <p class="member-name">${m.name}</p>
336                  <p class="member-role">${m.role}</p>
337                </div>
338              </li>`).join('')}
339          </ul>
340        </section>
341      </div>
342    </section>
343
344    <section class="prev-section" aria-labelledby="prev-heading">
345      <div class="prev-inner">
346        <h2 id="prev-heading" class="prev-title reveal">Previous Trainees</h2>
347        <div class="prev-cols">
348          <div class="reveal">
349            <h3 class="prev-col-label">Ph.D. Students</h3>
350            <ul style="list-style:none;padding:0;">
351              ${DATA.members.prevPhDs.map(n => `<li class="prev-name">${n}</li>`).join('')}
352            </ul>
353          </div>
354          <div class="reveal" style="transition-delay:100ms">
355            <h3 class="prev-col-label">Postdoctoral Researchers</h3>
356            <ul style="list-style:none;padding:0;">
357              ${DATA.members.prevPostdocs.map(n => `<li class="prev-name">${n}</li>`).join('')}
358            </ul>
359          </div>
360        </div>
361      </div>
362    </section>
363  `;
364}
365
366function renderPublications() {
367    return `
368    ${renderBreadcrumb('Publications')}
369    <div class="page-banner" style="padding-right:48px;">
370      <div style="display:flex;align-items:center;justify-content:space-between;gap:48px;flex-wrap:nowrap;width:100%;max-width:var(--max-w);margin:0 auto;">
371        <div style="flex-shrink:0;">
372          <p class="page-banner-eyebrow" aria-hidden="true">Scholarship</p>
373          <h1 class="page-banner-title">Selected<br/><strong><em>Publications</em></strong></h1>
374        </div>
375        <img src="images/fig5.png" alt="Publications figure"
376             style="width:480px;height:400px;object-fit:contain;display:block;flex-shrink:0;"
377             onerror="this.style.display='none'"/>
377
378      </div>
379    </div>
380
381    <section class="section" aria-labelledby="pubs-heading">
382      <div class="section-inner">
383        <h2 id="pubs-heading" class="sr-only">Publication list</h2>
384
385        <ul id="pub-list" style="list-style:none;padding:0;">
386          ${DATA.publications.map((p, i) => `
387            <li class="pub-card" style="transition-delay:${i * 80}ms">
388              <div class="pub-year">${p.year}</div>
389              <div>
390                <p class="pub-authors">${p.authors}</p>
391                <p class="pub-title">${p.title}</p>
392                <p class="pub-journal"><strong>${p.journal}</strong> · <em>${p.volume}</em></p>
393              </div>
394            </li>`).join('')}
395        </ul>
396
397        <div class="reveal" style="transition-delay:500ms;margin-top:56px">
398          ${extLink(
399        'http://www.ncbi.nlm.nih.gov/sites/myncbi/1Vk2bUQmb3DQF/bibliography/47575458/public/?sort=date&direction=descending',
400        'pubmed-link',
401        'Full Publication List on PubMed →'
402    )}
403        </div>
404      </div>
405    </section>
406  `;
407}
408
409function renderContact() {
410    return `
411    ${renderBreadcrumb('Contact')}
412    <div class="page-banner">
413      <div class="page-banner-inner">
414        <p class="page-banner-eyebrow" aria-hidden="true">Get in Touch</p>
415        <h1 class="page-banner-title"><em>Contact</em><br/><strong>the Lab</strong></h1>
416      </div>
417    </div>
418
419    <div class="contact-grid">
420      <section class="contact-info" aria-label="Contact information">
421        <span class="label" style="color:var(--utah-red)" aria-hidden="true">Principal Investigator</span>
422        <div class="red-rule" aria-hidden="true"></div>
423        <p class="contact-pi-name">Richard Dorsky</p>
424        <a href="mailto:[email protected]" class="contact-email">[email protected]</a>
425
426        <div class="contact-rule" role="separator" aria-hidden="true"></div>
427
428        <div style="margin-bottom:32px">
429          <p class="addr-label" id="mailing-label">Mailing Address</p>
430          <address class="addr-text" aria-labelledby="mailing-label">
431            Department of Neurobiology<br/>
432            20 South 2030 East<br/>
433            Bldg. 570 BPRB, Rm. 490E<br/>
434            Salt Lake City, Utah 84112
435          </address>
436        </div>
437
438        <div style="margin-bottom:32px">
439          <p class="contact-phone" style="margin-top:16px">
440            Office: <a href="tel:+18015816073" style="color:inherit;text-decoration:underline">(801) 581-6073</a><br/>
441            Lab: <a href="tel:+18015814529" style="color:inherit;text-decoration:underline">(801) 581-4529</a>
442          </p>
443        </div>
444      </section>
445
446      <div class="contact-map-wrap">
447        <div id="leaflet-map"
448             role="application"
449             aria-label="Interactive map showing Dorsky Lab location at 1795 E. South Campus Drive, Salt Lake City, UT">
450        </div>
451      </div>
452    </div>
453  `;
454}
455
456/* ═══════════════════════════════════════════════════════════════
457   NAVIGATION & ROUTER
458═══════════════════════════════════════════════════════════════ */
459function renderNav(activePage) {
460    const pages = ['Home', 'Research', 'Members', 'Publications', 'Contact'];
461
462    return `
463    <header class="site-header">
464      <div class="nav-top">
465        <div class="nav-title-block">
466          <button class="nav-site-title" onclick="navigate('Home')" type="button"
467                  aria-label="Dorsky Lab — go to home page">
468            Dorsky<span style="color: #d42020;" aria-hidden="true">Lab</span>
469          </button>
470          <span class="nav-title-pipe" aria-hidden="true">|</span>
471          <span class="nav-site-subtitle">Department of Neurobiology</span>
472        </div>
473        <button class="nav-mobile-toggle" type="button"
474                aria-label="Toggle navigation menu"
475                aria-expanded="false"
476                aria-controls="nav-menu"
477                onclick="toggleMobileNav(this)">
478          <span aria-hidden="true">☰</span>
479        </button>
480      </div>
481      <nav aria-label="Primary navigation" id="nav-menu" class="nav-bottom">
482        ${pages.map(p => `
483          <a class="nav-link${activePage === p ? ' active' : ''}"
484             href="#"
485             onclick="event.preventDefault(); navigate('${p}')"
486             ${activePage === p ? 'aria-current="page"' : ''}
487          >${p}</a>
488        `).join('')}
489      </nav>
490    </header>`;
491}
492
493function renderFooter() {
494    return `
495    <footer class="site-footer" aria-label="Site footer">
496      <div class="footer-top">
497        <div>
498          <p class="footer-brand-name">Dorsky<span>Lab</span></p>
499          <p class="footer-brand-desc">
500            Investigating Wnt-mediated CNS neurogenesis using zebrafish as a model organism.
501          </p>
502        </div>
503        <nav aria-label="Footer navigation">
504          <p class="footer-col-title">Navigation</p>
505          <a class="footer-link" href="#" onclick="event.preventDefault(); navigate('Home')">Home</a>
506          <a class="footer-link" href="#" onclick="event.preventDefault(); navigate('Research')">Research</a>
507          <a class="footer-link" href="#" onclick="event.preventDefault(); navigate('Members')">Lab Members</a>
508          <a class="footer-link" href="#" onclick="event.preventDefault(); navigate('Publications')">Publications</a>
509          <a class="footer-link" href="#" onclick="event.preventDefault(); navigate('Contact')">Contact</a>
510        </nav>
511        <nav aria-label="Department links">
512          <p class="footer-col-title">Department</p>
513          ${extLink('https://medicine.utah.edu/neurobiology', 'footer-link', 'Dept. of Neurobiology')}
514          ${extLink('http://neuroscience.med.utah.edu', 'footer-link', 'Neuroscience Graduate Program')}
515          ${extLink('https://bioscience.utah.edu', 'footer-link', 'Molecular Biology Program')}
516        </nav>
517      </div>
518      <div class="footer-bottom">
519        <p class="footer-copy">© ${new Date().getFullYear()} Dorsky Laboratory · Department of Neurobiology</p>
520      </div>
521    </footer>`;
522}
523
524/* ═══════════════════════════════════════════════════════════════
525   MOBILE NAV TOGGLE
526═══════════════════════════════════════════════════════════════ */
527function toggleMobileNav(btn) {
528    const nav = document.getElementById('nav-menu');
529    if (!nav) return;
530    const isOpen = nav.classList.toggle('open');
531    btn.setAttribute('aria-expanded', String(isOpen));
532    btn.querySelector('span').textContent = isOpen ? '✕' : '☰';
533}
534
535/* ═══════════════════════════════════════════════════════════════
536   APP INIT
537═══════════════════════════════════════════════════════════════ */
538let currentPage = 'Home';
539
540window.navigate = function(page) {
541    currentPage = page;
542    render();
543    window.scrollTo({ top: 0, behavior: 'smooth' });
544    const announcer = document.getElementById('page-announcer');
545    if (announcer) {
546        announcer.textContent = '';
547        setTimeout(() => { announcer.textContent = `Navigated to ${page} page`; }, 100);
548    }
549    setTimeout(() => {
550        const main = document.getElementById('main');
551        if (main) { main.focus(); }
552    }, 150);
553};
554
555function getPageContent(page) {
556    switch (page) {
557        case 'Home': return renderHome();
558        case 'Research': return renderResearch();
559        case 'Members': return renderMembers();
560        case 'Publications': return renderPublications();
561        case 'Contact': return renderContact();
562        default: return renderHome();
563    }
564}
565
566function render() {
567    const page = currentPage;
568    document.getElementById('nav-container').innerHTML = renderNav(page);
569    document.getElementById('page-container').innerHTML = getPageContent(page);
570    document.getElementById('footer-container').innerHTML = renderFooter();
571
572    document.title = page === 'Home'
573        ? 'Dorsky Lab — Neurobiology'
574        : `${page} — Dorsky Lab · Neurobiology`;
575
576    setTimeout(() => {
577        setupReveal();
578        if (page === 'Home') initHeroCanvas();
579        if (page === 'Contact') loadLeaflet();
580    }, 50);
581}
582
583function loadLeaflet() {
584    if (window._leafletLoaded) { setTimeout(initMap, 100); return; }
585
586    const css = document.createElement('link');
587    css.rel = 'stylesheet';
588    css.href = 'https://unpkg.com/[email protected]/dist/leaflet.css';
589    document.head.appendChild(css);
590
591    const js = document.createElement('script');
592    js.src = 'https://unpkg.com/[email protected]/dist/leaflet.js';
593    js.onload = () => { window._leafletLoaded = true; setTimeout(initMap, 100); };
594    document.head.appendChild(js);
595}
596
597window.addEventListener('scroll', () => {
598    const h = document.querySelector('.site-header');
599    if (h) h.style.boxShadow = window.scrollY > 40
600        ? '0 2px 24px rgba(0,0,0,0.12)' : '0 1px 12px rgba(0,0,0,0.06)';
601});
602
603document.addEventListener('keydown', (e) => {
604    if (e.key === 'Escape') {
605        const nav = document.getElementById('nav-menu');
606        const btn = document.querySelector('.nav-mobile-toggle');
607        if (nav && nav.classList.contains('open')) {
608            nav.classList.remove('open');
609            if (btn) {
610                btn.setAttribute('aria-expanded', 'false');
611                btn.querySelector('span').textContent = '☰';
612                btn.focus();
613            }
614        }
615    }
616});
617
618document.addEventListener('DOMContentLoaded', async () => {
619    try {
620        const res = await fetch('data.json');
621        if (!res.ok) throw new Error('Failed to load data.json');
622        window.DATA = await res.json();
623    } catch (err) {
624        console.error('Could not load data.json:', err);
625        // Fallback: DATA remains undefined; page will show empty gracefully
626        window.DATA = { members: { pi: {}, current: [], prevPhDs: [], prevPostdocs: [] }, publications: [], research: { areas: [] } };
627    }
628    render();
629});

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.