1/* ========================================================================= 2 STAR KIM â site engine (no framework, no build step) 3 ========================================================================= */ 4(function () { 5 "use strict"; 6 var SK = window.SK || {}; 7 var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 8 9 /* ---------- tiny helpers ---------- */ 10 function $(sel, ctx) { return (ctx || document).querySelector(sel); } 11 function $all(sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); } 12 function el(html) { var t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } 13 function esc(s) { 14 return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { 15 return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; 16 }); 17 } 18 19 /* five-point star path helpers (viewBox 0 0 24 24, centred) */ 20 var STAR24 = "M12 2 L14.35 8.76 L21.51 8.91 L15.8 13.24 L17.88 20.09 L12 16 L6.12 20.09 L8.2 13.24 L2.49 8.91 L9.65 8.76 Z"; 21 22 /* ---------- branded placeholder image (data URI): shooting star on navy sky ---------- */ 23 function placeholder(label, w, h) { 24 w = w || 800; h = h || 600; 25 var cx = w / 2, cy = h / 2 - (label ? 14 : 0); 26 var svg = 27 '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">' + 28 '<defs>' + 29 '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">' + 30 '<stop offset="0" stop-color="#132239"/><stop offset="1" stop-color="#0A1424"/></linearGradient>' + 31 '<linearGradient id="trail" x1="0" y1="1" x2="1" y2="0">' + 32 '<stop offset="0" stop-color="#E9B857" stop-opacity="0"/><stop offset="1" stop-color="#F6E4B8"/></linearGradient>' + 33 '<radialGradient id="glow"><stop offset="0" stop-color="#3C64A5" stop-opacity="0.5"/><stop offset="1" stop-color="#3C64A5" stop-opacity="0"/></radialGradient>' + 34 '</defs>' + 35 '<rect width="100%" height="100%" fill="url(#sky)"/>' + 36 '<circle cx="' + (cx + 60) + '" cy="' + (cy - 30) + '" r="150" fill="url(#glow)"/>' + 37 dust(w, h) + 38 '<g transform="translate(' + (cx - 51) + ',' + (cy - 58) + ') scale(1.6)" opacity="0.9">' + 39 '<path d="M18 39 C 13 32 15 23 21 18 C 22 22 25 22 25 18 C 24 10 28 5 30 0 C 33 4 34 8 33 13 C 36 9 39 12 39 17 C 43 14 46 16 46 22 C 48 27 48 34 44 39 C 39 43 33 44 27 43 C 23 43 20 41 18 39 Z" fill="#E5402E"/>' + 40 '<path d="M32 23 L37.9 39.9 L55.8 40.3 L41.5 51.1 L46.7 68.2 L32 58 L17.3 68.2 L22.5 51.1 L8.2 40.3 L26.1 39.9 Z" fill="#E9B857"/>' + 41 '</g>' + 42 (label ? '<text x="50%" y="' + (h / 2 + 58) + '" fill="#7F8DA6" font-family="Inter, sans-serif" font-size="13" text-anchor="middle">' + esc(label) + '</text>' : "") + 43 '</svg>'; 44 return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); 45 } 46 function dust(w, h) { 47 var s = "", i, x, y, r; 48 var seed = 7; 49 function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; } 50 for (i = 0; i < 60; i++) { 51 x = Math.round(rnd() * w); y = Math.round(rnd() * h); r = rnd() < 0.12 ? 1.6 : 0.8; 52 s += '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" fill="#EAF2FF" opacity="' + (0.22 + rnd() * 0.5).toFixed(2) + '"/>'; 53 } 54 return s; 55 } 56 57 /* ---------- navy-sky starfield (progressive enhancement, decorative) ---------- */ 58 function initStarfield() { 59 $all(".starfield").forEach(function (field) { 60 var count = parseInt(field.dataset.stars || "44", 10); 61 var brights = parseInt(field.dataset.bright || "5", 10); 62 var colors = ["#EAF2FF", "#F6E4B8", "#8FB6E8"]; 63 var seed = 42; 64 function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; } 65 var html = ""; 66 for (var i = 0; i < count; i++) { 67 var big = i < brights; 68 var x = (rnd() * 100).toFixed(2), y = (rnd() * 100).toFixed(2); 69 var tw = (3 + rnd() * 5).toFixed(1), d = (rnd() * 6).toFixed(1); 70 var c = colors[Math.floor(rnd() * colors.length)]; 71 if (big) { 72 var sz = (10 + rnd() * 9).toFixed(1); 73 html += '<svg class="star star--bright" viewBox="0 0 24 24" aria-hidden="true" style="left:' + x + '%;top:' + y + 74 '%;width:' + sz + 'px;height:' + sz + 'px;color:' + c + ';--o:.95;--tw:' + tw + 's;--d:-' + d + 's">' + 75 '<path d="' + STAR24 + '" fill="currentColor"/></svg>'; 76 } else { 77 var s = (1 + rnd() * 1.7).toFixed(1); 78 var o = (0.3 + rnd() * 0.5).toFixed(2); 79 html += '<i class="star" style="left:' + x + '%;top:' + y + '%;width:' + s + 'px;height:' + s + 80 'px;background:' + c + ';--o:' + o + ';--tw:' + tw + 's;--d:-' + d + 's"></i>'; 81 } 82 } 83 field.insertAdjacentHTML("beforeend", html); 84 if (field.dataset.shooting === "true" && !reduceMotion) { 85 var sh = document.createElement("span"); 86 sh.className = "shooting-star"; 87 sh.style.setProperty("--top", (6 + rnd() * 22) + "%"); 88 sh.style.setProperty("--delay", (2 + rnd() * 4).toFixed(1) + "s"); 89 field.appendChild(sh); 90 } 91 }); 92 } 93 function smartImg(src, alt, label) { 94 var im = new Image(); 95 im.loading = "lazy"; 96 im.decoding = "async"; 97 im.alt = alt || ""; 98 // attach the error handler BEFORE setting src, or a fast 404 fires it first 99 im.addEventListener("error", function () { 100 if (im.dataset.fallback) return; 101 im.dataset.fallback = "1"; 102 im.src = placeholder(label || alt || ""); 103 }); 104 im.src = src; 105 return im; 106 } 107 108 /* build a safe URL to a file inside an album folder (folder + file may contain
109 spaces, "-", etc. â encode each path segment) */ 110 function albumSrc(id, file) { 111 return "assets/img/albums/" + encodeURIComponent(id) + "/" + encodeURIComponent(file || ""); 112 } 113 function albumMeta(al) { 114 return [al.date, al.location].filter(Boolean).join(" · "); 115 } 116 function pad2(n) { return (n < 10 ? "0" : "") + n; } 117 118 /* Turn each SK.albums entry â a plain "MMDDYY - Event Name" string, or an 119 object â into { id, title, date, sort, photos? } and sort newest-first. */ 120 function normAlbums() { 121 var MON = ["", "January", "February", "March", "April", "May", "June", "July", 122 "August", "September", "October", "November", "December"]; 123 var list = (SK.albums || []).map(function (raw) { 124 var a = typeof raw === "string" ? { id: raw } : (function () { 125 var o = {}; for (var k in raw) o[k] = raw[k]; return o; 126 })(); 127 var m = /^(\d{2})(\d{2})(\d{2})\s*-\s*(.+)$/.exec(a.id || ""); 128 if (m) { 129 if (a.title == null) a.title = m[4].trim(); 130 if (a.date == null) a.date = MON[+m[1]] + " " + (+m[2]) + ", 20" + m[3]; 131 a.sort = "20" + m[3] + m[1] + m[2]; 132 } else { 133 if (a.title == null) a.title = a.id || ""; 134 a.sort = a.sort || "0"; 135 } 136 return a; 137 }); 138 list.sort(function (x, y) { return (y.sort || "").localeCompare(x.sort || ""); }); 139 return list; 140 } 141 142 /* Walk 01.jpg, 02.jpg, 03.jpg ⦠in a folder, calling onPhoto(file) for each 143 that exists, then onDone() at the first gap. */ 144 function discoverPhotos(id, onPhoto, onDone) { 145 var i = 1, MAX = 400; 146 (function step() { 147 var file = pad2(i) + ".jpg"; 148 var probe = new Image(); 149 probe.onload = function () { onPhoto(file, i); i++; if (i <= MAX) step(); else onDone(); }; 150 probe.onerror = function () { onDone(); }; 151 probe.src = albumSrc(id, file); 152 })(); 153 } 154 155 /* ---------- icons ---------- */ 156 var ICON = { 157 arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>', 158 arrowUpRight: '<path d="M7 17 17 7M8 7h9v9"/>', 159 back: '<path d="M19 12H5M11 18l-6-6 6-6"/>', 160 play: '<path d="M8 5v14l11-7z" fill="currentColor" stroke="none"/>', 161 close: '<path d="M6 6l12 12M18 6 6 18"/>', 162 prev: '<path d="M15 18l-6-6 6-6"/>', 163 next: '<path d="M9 6l6 6-6 6"/>', 164 menu: '<path d="M4 7h16M4 12h16M4 17h16"/>', 165 mail: '<path d="M3 6h18v12H3z"/><path d="m3 7 9 6 9-6"/>', 166 scholar: '<path d="M12 3 1 9l11 6 9-4.9V17h2V9L12 3z"/><path d="M5 13v4c0 1.7 3.1 3 7 3s7-1.3 7-3v-4"/>', 167 linkedin: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 10v7M8 7v0M12 17v-4a2 2 0 0 1 4 0v4M12 12v5" stroke-linecap="round"/>', 168 github: '<path d="M9 19c-4 1.4-4-2.2-6-2.8M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.6 11.6 0 0 0-6 0C7.3 3.6 6.3 3.9 6.3 3.9a4.3 4.3 0 0 0-.1 3.2A4.6 4.6 0 0 0 5 10.3c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/>', 169 youtube: '<rect x="2" y="5" width="20" height="14" rx="3"/><path d="m10 9 5 3-5 3z" fill="currentColor" stroke="none"/>', 170 orcid: '<circle cx="12" cy="12" r="9"/><path d="M12 8v8M12 8v0" stroke-linecap="round"/><path d="M12 8h1.5a4 4 0 0 1 0 8H12"/>', 171 instagram: '<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/>', 172 site: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 2.5 15.3 0 18M12 3c-2.5 2.7-2.5 15.3 0 18"/>' 173 }; 174 function svgIcon(name, cls) { 175 return '<svg class="' + (cls || "") + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (ICON[name] || "") + '</svg>'; 176 } 177 // { name, href } -> a linked name (no icon) 178 function creditLink(c) { 179 if (!c || !c.name) return ""; 180 return c.href 181 ? '<a class="credit-link" href="' + esc(c.href) + '" target="_blank" rel="noopener">' + esc(c.name) + '</a>' 182 : esc(c.name); 183 } 184 185 /* ---------- nav ---------- */ 186 function initNav() { 187 var toggle = $(".nav-toggle"); 188 var nav = $("#primary-nav"); 189 if (toggle && nav) { 190 toggle.addEventListener("click", function () { 191 var open = nav.getAttribute("data-open") === "true"; 192 nav.setAttribute("data-open", String(!open)); 193 toggle.setAttribute("aria-expanded", String(!open)); 194 });
195 $all("a", nav).forEach(function (a) { 196 a.addEventListener("click", function () { 197 nav.setAttribute("data-open", "false"); 198 toggle.setAttribute("aria-expanded", "false"); 199 }); 200 }); 201 } 202 // mark current page 203 var here = location.pathname.split("/").pop() || "index.html"; 204 $all("#primary-nav a[data-page]").forEach(function (a) { 205 if (a.getAttribute("data-page") === here || (here === "album.html" && a.getAttribute("data-page") === "media.html")) { 206 a.setAttribute("aria-current", "page"); 207 } 208 }); 209 } 210 211 /* ---------- reveal on scroll ---------- */ 212 function initReveal() { 213 var items = $all(".reveal"); 214 if (reduceMotion || !("IntersectionObserver" in window)) { 215 items.forEach(function (i) { i.classList.add("is-in"); }); 216 return; 217 } 218 document.documentElement.classList.add("reveal-ready"); 219 var io = new IntersectionObserver(function (entries) { 220 entries.forEach(function (e) { 221 if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); } 222 }); 223 }, { rootMargin: "0px 0px -8% 0px", threshold: 0.12 }); 224 items.forEach(function (i) { io.observe(i); }); 225 } 226 227 function initYear() { 228 $all("[data-year]").forEach(function (n) { n.textContent = new Date().getFullYear(); }); 229 } 230 231 /* ---------- social links (footer + hero) ---------- */
232 function renderSocial() { 233 if (!SK.site) return; 234 var links = (SK.site.social || []).map(function (s) { 235 return '<a href="' + esc(s.href) + '" target="_blank" rel="noopener noreferrer" aria-label="' + 236 esc(s.label) + '" title="' + esc(s.label) + '">' + svgIcon(s.icon || "arrowUpRight") + '</a>'; 237 }).join(""); 238 $all("[data-render='social']").forEach(function (mount) { mount.innerHTML = links; }); 239 } 240 function renderEmail() { 241 $all("[data-email]").forEach(function (n) { 242 if (!SK.site) return; 243 var m = SK.site.email; 244 if (n.tagName === "A") { n.href = "mailto:" + m; if (n.dataset.email === "text") n.textContent = m; } 245 else n.textContent = m; 246 }); 247 } 248 249 /* ---------- HOME ---------- */ 250 function renderIntro() { 251 var m = $("[data-render='site-intro']"); 252 if (m && SK.site && SK.site.intro) m.innerHTML = "<p>" + SK.site.intro + "</p>"; 253 } 254 255 /* home-page panels: a short preview of each section, drawn from that section's data */ 256 function miniItem(title, meta) { 257 return '<li>' + 258 (title ? '<span class="mini__t">' + esc(title) + '</span>' : "") + 259 (meta ? '<span class="mini__m">' + esc(meta) + '</span>' : "") + 260 '</li>'; 261 } 262 function fillMini(sel, html) { 263 var m = $(sel); 264 if (m) m.innerHTML = html; // mount is already the <ul class="mini"> 265 } 266 267 function renderHomePanels() { 268 // Research â the focus areas 269 if (SK.areas) { 270 fillMini("[data-render='home-research']", SK.areas.slice(0, 4).map(function (a) { 271 return miniItem(a.title, a.short || a.blurb.split(". ")[0]); 272 }).join("")); 273 } 274 // Publications â 3 most recent 275 if (SK.publications) { 276 var pubs = SK.publications.slice().sort(function (x, y) { return y.year - x.year; }).slice(0, 3); 277 fillMini("[data-render='home-publications']", pubs.map(function (p) { 278 var kind = (p.kind || "").charAt(0).toUpperCase() + (p.kind || "").slice(1); 279 return miniItem(p.title, (p.month ? p.month + " " : "") + p.year + " · " + kind); 280 }).join("")); 281 } 282 // Media â 2 most recent 283 if (SK.media) { 284 fillMini("[data-render='home-media']", SK.media.slice(0, 2).map(function (m) { 285 return miniItem(m.title, m.source + (m.date ? " · " + m.date : "")); 286 }).join("")); 287 } 288 // Talks â next upcoming, else most recent 289 if (SK.talks) { 290 var up = SK.talks.filter(function (t) { return t.status === "upcoming"; }); 291 var list = (up.length ? up : SK.talks.filter(function (t) { return t.status === "past"; })).slice(0, 2); 292 fillMini("[data-render='home-talks']", list.map(function (t) { 293 var titles = t.titles || (t.title ? [t.title] : []); 294 var sub = titles.length > 1 295 ? titles.length + " presentations" 296 : (titles[0] ? (typeof titles[0] === "string" ? titles[0] : titles[0].title) : ""); 297 return miniItem(t.event, t.when + (sub ? " · " + sub : "") + (t.place ? " · " + t.place : "")); 298 }).join("")); 299 } 300 // Galleries â 3 album covers 301 var gm = $("[data-render='home-galleries']"); 302 if (gm && SK.albums) { 303 gm.innerHTML = '<div class="mini-albums"></div>'; 304 var row = gm.firstChild; 305 normAlbums().slice(0, 3).forEach(function (al) { 306 var a = el('<a href="album.html?album=' + encodeURIComponent(al.id) + '"><span class="mini-albums__cover"></span><span class="mini-albums__t">' + esc(al.title) + '</span></a>'); 307 a.querySelector(".mini-albums__cover").appendChild( 308 smartImg(albumSrc(al.id, al.cover || (al.photos && al.photos[0]) || "01.jpg"), al.title, al.title) 309 ); 310 row.appendChild(a); 311 }); 312 } 313 } 314 315 function renderHome() { 316 // detailed area cards (research.html) 317 var a = $("[data-render='home-areas']"); 318 if (a && SK.areas) { 319 a.innerHTML = ""; 320 SK.areas.forEach(function (area, i) { 321 a.appendChild(el( 322 '<article class="cell reveal">' + 323 '<div class="cell__no">' + String(i + 1).padStart(2, "0") + '</div>' + 324 '<h3>' + esc(area.title) + '</h3>' + 325 '<p>' + esc(area.blurb) + '</p>' + 326 '<ul>' + (area.techniques || area.tags || []).map(function (t) { return '<li>' + esc(t) + '</li>'; }).join("") + '</ul>' + 327 '</article>' 328 )); 329 }); 330 } 331 // compact focus list (home) 332 var fl = $("[data-render='home-focus']"); 333 if (fl && SK.areas) { 334 fl.innerHTML = ""; 335 SK.areas.forEach(function (area) { 336 fl.appendChild(el( 337 '<li><strong>' + esc(area.title) + '</strong><span>' + esc(area.short || area.blurb) + '</span></li>' 338 )); 339 }); 340 } 341 var f = $("[data-render='home-featured']"); 342 if (f && SK.projects) { 343 f.innerHTML = ""; 344 SK.projects.filter(function (p) { return p.flagship; }
344).forEach(function (p) { 345 f.appendChild(el( 346 '<li><a class="feat__row" href="research.html#' + esc(p.id) + '">' + 347 '<span class="feat__text">' + 348 '<span class="feat__t">' + esc(p.title) + '</span>' + 349 '<span class="feat__d">' + esc(p.card || p.kicker || "") + '</span>' + 350 '</span>' + 351 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>' + 352 '</a></li>' 353 )); 354 }); 355 } 356 } 357 358 /* ---------- WORK ---------- */ 359 /* ---------- project demo media (tiny thumb) ---------- 360 youtube -> opens the video on YouTube in a new tab 361 image -> opens the lightbox */ 362 function projectDemo(p) { 363 if (!p.youtube && !p.image) return null; 364 var isVideo = !!p.youtube; 365 var thumb = p.youtube ? "https://i.ytimg.com/vi/" + p.youtube + "/hqdefault.jpg" 366 : [].concat(p.image)[0]; 367 var label = isVideo ? "Watch demo on YouTube" : "View images"; 368 var inner = 369 '<span class="project__demo-thumb">' + 370 (isVideo ? '<span class="project__demo-play">' + svgIcon("play") + '</span>' : '') + 371 '</span>' + 372 '<span class="project__demo-label">' + label + 373 (isVideo ? ' ' + svgIcon("arrowUpRight") : '') + '</span>'; 374 var node = isVideo 375 ? el('<a class="project__demo" href="https://www.youtube.com/watch?v=' + esc(p.youtube) + 376 '" target="_blank" rel="noopener">' + inner + '</a>') 377 : el('<button class="project__demo" type="button" aria-label="View project images">' + inner + '</button>'); 378 node.querySelector(".project__demo-thumb").insertBefore( 379 smartImg(thumb, p.title, p.title), node.querySelector(".project__demo-thumb").firstChild); 380 if (!isVideo) node.addEventListener("click", function () { 381 var imgs = [].concat(p.image); 382 openLightbox(imgs.map(function (s) { return { src: s, caption: p.title }; }), 0); 383 }); 384 return node; 385 } 386 387 function renderWork() { 388 var mount = $("[data-render='work']"); 389 var list = SK.projects || SK.areas; 390 if (!mount || !list) return; 391 var ordered = list.slice().sort(function (x, y) { return (y.flagship ? 1 : 0) - (x.flagship ? 1 : 0); }); 392 mount.innerHTML = ""; 393 function blurbHtml(b) { 394 if (Array.isArray(b)) { 395 return '<p>' + esc(b[0]) + '</p>' + 396 (b.length > 1 ? '<ul class="project__list">' + 397 b.slice(1).map(function (x) { return '<li>' + esc(x) + '</li>'; }).join("") + '</ul>' : ""); 398 } 399 return '<p>' + esc(b) + '</p>'; 400 } 401 ordered.forEach(function (p, i) { 402 var hasRole = p.contributions && p.contributions.length; 403 var node = el( 404 '<article class="project reveal"' + (p.id ? ' id="' + esc(p.id) + '"' : '') + '>' + 405 '<div class="project__head">' + 406 '<div class="project__index">' + String(i + 1).padStart(2, "0") + (p.year ? ' / ' + esc(p.year) : '') + '</div>' + 407 (p.kicker ? '<span class="project__flag">' + esc(p.kicker) + '</span>' : '') + 408 '<h3>' + esc(p.title) + '</h3>' + 409 '<div class="project__body">' + blurbHtml(p.summary || p.blurb) + 410 (p.collaborator ? '<p class="project__collab">Clinical collaborator: ' + creditLink(p.collaborator) + '</p>' : "") + 411 (hasRole ? '<p style="color:var(--text-3);font-size:var(--fs-sub);margin-bottom:6px">My role</p>' + 412 '<ul style="margin:0 0 4px;padding-left:18px;color:var(--text-2);font-size:var(--fs-sub)">' + 413 p.contributions.map(function (c) { return '<li style="margin-bottom:4px">' + esc(c) + '</li>'; }).join("") + '</ul>' : '') + 414 (p.links && p.links.length ? '<div style="margin-top:12px;display:flex;gap:16px">' + p.links.map(function (l) { return '<a class="link" href="' + esc(l.href) + '">' + esc(l.label) + '</a>'; }).join("") + '</div>' : "") + 415 ((p.techniques || p.tags || []).length 416 ? '<p class="project__tech-label">Techniques</p><div class="project__tags">' + 417 (p.techniques || p.tags).map(function (t) { return '<span>' + esc(t) + '</span>'; }).join("") + '</div>' 418 : "") + 419 '</div></div>' + 420 '</article>' 421 ); 422 423 var body = node.querySelector(".project__body"); 424 var demo = projectDemo(p); 425 if (demo) body.appendChild(demo); 426 427 var team = p.id ? peopleFor(p.id) : []; 428 if (team.length) { 429 var row = el('<div class="project__team"><p class="project__team-label">People</p><div class="person-row"></div></div>'); 430 var strip = row.querySelector(".person-row");
431 team.forEach(function (m) { strip.appendChild(memberPhoto(m)); }); 432 node.querySelector(".project__head").appendChild(row); 433 } else if (p.recruiting) { 434 var rec = el('<p class="project__recruiting">We are actively looking for students to join this project. <a class="link" href="contact.html">Get in touch</a>.</p>'); 435 node.querySelector(".project__head").appendChild(rec); 436 } 437 mount.appendChild(node); 438 }); 439 if (location.hash) { 440 var t = document.getElementById(location.hash.slice(1)); 441 if (t) t.scrollIntoView(); 442 } 443 } 444 445 /* ---------- FUNDING (Research page) ---------- */ 446 function renderGrants() { 447 var mount = $("[data-render='grants']"); 448 if (!mount || !SK.grants || !SK.grants.length) return; 449 mount.innerHTML = SK.grants.map(function (g) { 450 var meta = [g.role, g.funder, g.period, g.amount].filter(Boolean).join(" · "); 451 return '<article class="grant">' + 452 '<h3>' + esc(g.title) + (g.internal ? ' <span class="grant__tag">internal</span>' : '') + '</h3>' + 453 '<p class="grant__meta">' + esc(meta) + '</p>' + 454 (g.project ? '<p class="grant__project">' + esc(g.project) + '</p>' : '') + 455 '</article>'; 456 }).join(""); 457 var host = mount.closest("section"); 458 if (host) host.hidden = false; 459 } 460 461 /* ---------- ABOUT ---------- */ 462 function renderAbout() { 463 if (!SK.about) return; 464 var bio = $("[data-render='bio']"); 465 if (bio) bio.innerHTML = SK.about.bio.map(function (p) { 466 return Array.isArray(p) 467 ? '<ul class="bio-list">' + p.map(function (x) { return '<li>' + esc(x) + '</li>'; }).join("") + '</ul>' 468 : '<p>' + esc(p) + '</p>'; 469 }).join(""); 470 471 // `note` may be a string or an array of strings; either way it renders as a 472 // bullet list so every entry looks consistent. 473 function noteBlock(note) { 474 if (!note) return ""; 475 return '<ul class="tl-notes">' + 476 [].concat(note).map(function (n) { return '<li>' + esc(n) + '</li>'; }).join("") + '</ul>'; 477 } 478 479 fillTimeline("[data-render='education']", SK.about.education, function (d) { 480 return '<span class="yr">' + esc(d.yr) + '</span><h3>' + esc(d.title) + '</h3><span class="org">' + esc(d.org) + '</span>' + noteBlock(d.note); 481 }); 482 fillTimeline("[data-render='experience']", SK.about.experience, function (d) { 483 return '<span class="yr">' + esc(d.yr) + '</span><h3>' + esc(d.title) + '</h3><span class="org">' + esc(d.org) + '</span>' + noteBlock(d.note); 484 }); 485 486 var rec = $("[data-render='recognition']"); 487 if (rec) { 488 rec.innerHTML = ""; 489 (SK.about.recognition || []).forEach(function (r) { 490 rec.appendChild(el('<li style="padding:14px 0;border-bottom:1px solid var(--hairline);display:flex;gap:16px"><span style="color:var(--gold);flex:none;font-family:var(--font-display)">' + esc(r.yr) + '</span><span style="color:var(--text-2)">' + esc(r.text) + '</span></li>')); 491 }); 492 } 493 var st = $("[data-render='about-stats']"); 494 if (st) { 495 st.innerHTML = ""; 496 (SK.about.stats || []).forEach(function (s) { 497 st.appendChild(el('<div><dt>' + esc(s.n) + '</dt><dd>' + esc(s.label) + '</dd></div>')); 498 }); 499 } 500 } 501 function fillTimeline(sel, list, tpl) { 502 var m = $(sel); 503 if (!m || !list) return; 504 m.innerHTML = ""; 505 list.forEach(function (d) { m.appendChild(el('<div class="tl-item reveal">' + tpl(d) + '</div>')); }); 506 } 507 508 /* ---------- PUBLICATIONS ---------- */ 509 var PUB_MON = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12 }; 510 function pubKey(p) { return (p.year || 0) * 100 + (PUB_MON[String(p.month || "").toLowerCase()] || 0); } 511 function renderPublications() { 512 var mount = $("[data-render='publications']"); 513 if (!mount || !SK.publications) return; 514 var list = SK.publications.slice().sort(function (a, b) { return pubKey(b) - pubKey(a); }); 515 516 // summary counts 517 var summary = $("[data-render='pub-summary']"); 518 if (summary) { 519 var order = ["journal", "conference", "abstract", "chapter", "preprint", "patent"]; 520 var labels = { 521 journal: "Journal articles", conference: "Conference papers", abstract: "Abstracts", 522 chapter: "Book chapters", preprint: "Preprints", patent: "Patents" 523 }; 524 var counts = {}; 525 list.forEach(function (p) { counts[p.kind] = (counts[p.kind] || 0) + 1; }); 526 var cells = order.filter(function (k) { return counts[k]; }).map(function (k) { 527 var n = counts[k], lbl = counts[k] === 1 ? labels[k].replace(/s$/, "") : labels[k]; 528 return '<div class="pub-summary__stat"><span class="pub-summary__n">' + n + 529 '</span><span class="pub-summary__l">' + esc(lbl) + '</span></div>'; 530 }); 531 if (SK.inPrep && SK.inPrep.length) { 532 cells.push('<div class="pub-summary__stat"><span class="pub-summary__n">' + SK.inPrep.length + 533 '</span><span class="pub-summary__l">In preparation</span></div>'); 534 } 535 summary.innerHTML = cells.join(""); 536 } 537 var KIND_ORDER = ["journal", "conference", "abstract", "chapter", "preprint", "patent"]; 538 var present = list.map(function (p) { return p.kind; }); 539 var kinds = ["all"].concat(KIND_ORDER.filter(function (k) { return present.indexOf(k) !== -1; })); 540 var filterBar = $("[data-render='pub-filters']"); 541 var active = "all"; 542 543 if (filterBar) { 544 filterBar.innerHTML = ""; 545 kinds.forEach(function (k) { 546 var b = el('<button type="button" aria-pressed="' + (k === "all") + '">' + esc(k === "all" ? "All" : k.charAt(0).toUpperCase() + k.slice(1)) + '</button>'); 547 b.addEventListener("click", function () { 548 active = k;
549 $all("button", filterBar).forEach(function (x) { x.setAttribute("aria-pressed", String(x === b)); }); 550 draw(); 551 }); 552 filterBar.appendChild(b); 553 }); 554 } 555 function boldName(s) { 556 return esc(s).replace(/(Kim,?\s*B\.?|B\.?\s?Kim|S\.?\s?Kim)/g, "<strong style='color:var(--text)'>$1</strong>"); 557 } 558 function draw() { 559 mount.innerHTML = ""; 560 var shown = list.filter(function (p) { return active === "all" || p.kind === active; }); 561 shown.forEach(function (p) { 562 var links = p.links || []; 563 var doi = links.filter(function (l) { return /doi/i.test(l.label); })[0]; 564 var rest = links.filter(function (l) { return !/doi/i.test(l.label); }); 565 mount.appendChild(el( 566 '<article class="pub">' + 567 '<div class="pub__top"><span class="pub__yr">' + esc((p.month ? p.month + " " : "") + p.year) + '</span>' + 568 '<span class="pub__kind">' + esc(p.kind) + '</span></div>' + 569 '<h3>' + esc(p.title) + 570 (doi ? ' <a class="pub__doi" href="' + esc(doi.href) + '" target="_blank" rel="noopener">(DOI)</a>' : '') + '</h3>' + 571 '<div class="pub__authors">' + boldName(p.authors) + '</div>' + 572 '<div class="pub__venue">' + esc(p.venue) + '</div>' + 573 (rest.length ? '<div class="pub__links">' + rest.map(function (l) { return '<a href="' + esc(l.href) + '"' + (l.href.charAt(0) === "#" ? "" : ' target="_blank" rel="noopener"') + '>' + esc(l.label) + ' ' + svgIcon("arrowUpRight") + '</a>'; }).join("") + '</div>' : "") + 574 '</article>' 575 )); 576 }); 577 } 578 draw(); 579 580 // "In preparation" â its own section, no year/venue 581 var prepMount = $("[data-render='publications-inprep']"); 582 if (prepMount && SK.inPrep && SK.inPrep.length) { 583 prepMount.innerHTML = SK.inPrep.map(function (p) { 584 return '<article class="pub pub--prep">' + 585 '<h3>' + esc(p.title) + '</h3>' + 586 '<div class="pub__authors">' + boldName(p.authors) + '</div>' + 587 '<div class="pub__venue">Manuscript in preparation</div>' + 588 '</article>'; 589 }).join(""); 590 var host = prepMount.closest("section"); 591 if (host) host.hidden = false; 592 } 593 } 594 595 /* ---------- MEDIA ---------- */ 596 function renderMedia() { 597 var mount = $("[data-render='media']"); 598 if (!mount || !SK.media) return; 599 mount.innerHTML = ""; 600 SK.media.forEach(function (m, i) { 601 if (m.type === "link") { 602 mount.appendChild(el( 603 '<a class="video-card" href="' + esc(m.href) + '" target="_blank" rel="noopener">' + 604 '<div class="video-card__frame ticked"><div class="video-card__play"><span>' + svgIcon("arrowUpRight") + '</span></div></div>' + 605 '<h3>' + esc(m.title) + '</h3><p>' + esc(m.note) + '</p>' + 606 '<span class="src">' + esc(m.source) + ' · ' + esc(m.date) + '</span></a>' 607 )); 608 return; 609 } 610 var id = m.type === "youtube" ? m.youtube : m.vimeo; 611 var card = el( 612 '<article class="video-card">' + 613 '<button class="video-card__frame ticked" type="button" aria-label="Play: ' + esc(m.title) + '">' + 614 '<div class="video-card__play"><span>' + svgIcon("play") + '</span></div></button>' + 615 '<h3>' + esc(m.title) + '</h3><p>' + esc(m.note) + '</p>' + 616 (m.credit ? '<p class="video-card__credit">With ' + creditLink(m.credit) + '</p>' : "") + 617 '<span class="src">' + esc(m.source) + ' · ' + esc(m.date) + '</span></article>' 618 ); 619 var frame = card.querySelector(".video-card__frame"); 620 var thumbSrc = m.thumb 621 ? m.thumb 622 : (m.type === "youtube" && id ? "https://i.ytimg.com/vi/" + id + "/hqdefault.jpg" : placeholder(m.title)); 623 frame.insertBefore(smartImg(thumbSrc, m.title, "Video thumbnail"), frame.firstChild); 624 frame.addEventListener("click", function () { 625 if (!id) { alert("Add the video ID in data/media.js to enable playback."); return; } 626 var src = m.type === "youtube" 627 ? "https://www.youtube-nocookie.com/embed/" + id + "?autoplay=1&rel=0" 628 : "https://player.vimeo.com/video/" + id + "?autoplay=1"; 629 var wrap = el('<div class="embed-frame ticked"><iframe src="' + src + '" title="' + esc(m.title) + '" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>');
630 frame.replaceWith(wrap); 631 }); 632 mount.appendChild(card); 633 }); 634 } 635 636 /* ---------- GALLERIES (album grid, on media.html) ---------- */ 637 function renderGalleries() { 638 var mount = $("[data-render='albums']"); 639 if (!mount || !SK.albums) return; 640 mount.innerHTML = ""; 641 normAlbums().forEach(function (al) { 642 var card = el( 643 '<a class="album-card reveal" href="album.html?album=' + encodeURIComponent(al.id) + '">' + 644 '<span class="album-card__count" hidden></span>' + 645 '<div class="album-card__cover"></div>' + 646 '<div class="album-card__body"><h3>' + esc(al.title) + '</h3>' + 647 '<div class="meta">' + esc(albumMeta(al)) + '</div></div>' + 648 '</a>' 649 ); 650 card.querySelector(".album-card__cover").appendChild( 651 smartImg(albumSrc(al.id, al.cover || (al.photos && al.photos[0]) || "01.jpg"), al.title + " â cover", al.title) 652 ); 653 mount.appendChild(card); 654 655 var badge = card.querySelector(".album-card__count"); 656 function setCount(n) { 657 if (!n) return; 658 badge.textContent = n + (n === 1 ? " photo" : " photos"); 659 badge.hidden = false; 660 } 661 if (al.photos && al.photos.length) setCount(al.photos.length); 662 else { var n = 0; discoverPhotos(al.id, function () { n++; }, function () { setCount(n); }); } 663 }); 664 } 665 666 /* ---------- ALBUM (detail) ---------- */ 667 function renderAlbum() { 668 var mount = $("[data-render='album']"); 669 if (!mount || !SK.albums) return; 670 var want = new URLSearchParams(location.search).get("album"); 671 var al = normAlbums().filter(function (a) { return a.id === want; })[0]; 672 if (!al) { 673 mount.innerHTML = '<p class="lead">Album not found. <a class="link" href="media.html">Back to all albums</a>.</p>'; 674 return; 675 } 676 document.title = al.title + " â Star Kim"; 677 $("[data-album-title]") && ($("[data-album-title]").textContent = al.title); 678 $("[data-album-meta]") && ($("[data-album-meta]").textContent = albumMeta(al)); 679 680 if (al.note || al.credit) { 681 mount.appendChild(el('<p class="album-note">' + 682 (al.note ? esc(al.note) + " " : "") + creditLink(al.credit) + '</p>')); 683 } 684 685 var grid = el('<div class="photo-grid"></div>'); 686 mount.appendChild(grid); 687 var photos = []; // { src, caption } â grows as photos are found 688 689 function addPhoto(file, caption) { 690 var idx = photos.length; 691 photos.push({ src: albumSrc(al.id, file), caption: caption || "" }); 692 var b = el('<button type="button" aria-label="Open photo ' + (idx + 1) + 693 (caption ? ": " + esc(caption) : "") + '"></button>'); 694 b.appendChild(smartImg(photos[idx].src, caption || (al.title + " photo " + (idx + 1)), al.title)); 695 b.addEventListener("click", function () { openLightbox(photos, idx); }); 696 grid.appendChild(b); 697 } 698 699 if (al.photos && al.photos.length) { 700 // explicit list (supports { src, caption }) 701 al.photos.forEach(function (p) { 702 var o = typeof p === "string" ? { src: p } : p; 703 addPhoto(o.src, o.caption); 704 }); 705 } else { 706 // auto-discover 01.jpg, 02.jpg, ⦠707 discoverPhotos(al.id, function (file) { addPhoto(file); }, function () { 708 if (!photos.length) grid.innerHTML = 709 '<p class="lead">No photos in this album yet.</p>'; 710 }); 711 } 712 } 713 714 /* ---------- LIGHTBOX ---------- */ 715 var lb; 716 function ensureLightbox() { 717 if (lb) return lb; 718 lb = el( 719 '<div class="lightbox" hidden role="dialog" aria-modal="true" aria-label="Photo viewer">' + 720 '<button class="lightbox__btn lightbox__close" type="button" aria-label="Close">' + svgIcon("close") + '</button>' + 721 '<div class="lightbox__stage"><img alt=""></div>' + 722 '<div class="lightbox__bar">' + 723 '<button class="lightbox__btn" type="button" data-dir="-1" aria-label="Previous photo">' + svgIcon("prev") + '</button>' + 724 '<span class="lightbox__caption"></span>' + 725 '<button class="lightbox__btn" type="button" data-dir="1" aria-label="Next photo">' + svgIcon("next") + '</button>' + 726 '</div></div>' 727 ); 728 document.body.appendChild(lb); 729 return lb; 730 } 731 var lbState = { list: [], idx: 0, lastFocus: null }; 732 function openLightbox(list, idx) { 733 ensureLightbox(); 734 lbState.list = list; lbState.idx = idx; lbState.lastFocus = document.activeElement; 735 lb.hidden = false;
736 document.body.style.overflow = "hidden"; 737 drawLb(); 738 lb.querySelector(".lightbox__close").focus(); 739 lb.addEventListener("click", onLbClick); 740 document.addEventListener("keydown", onLbKey); 741 } 742 function closeLightbox() { 743 lb.hidden = true; 744 document.body.style.overflow = ""; 745 lb.removeEventListener("click", onLbClick); 746 document.removeEventListener("keydown", onLbKey); 747 if (lbState.lastFocus) lbState.lastFocus.focus(); 748 } 749 function stepLb(d) { 750 lbState.idx = (lbState.idx + d + lbState.list.length) % lbState.list.length; 751 drawLb(); 752 } 753 function drawLb() { 754 var cur = lbState.list[lbState.idx]; 755 var img = lb.querySelector(".lightbox__stage img"); 756 img.src = cur.src; img.alt = cur.caption || ""; 757 img.onerror = function () { img.onerror = null; img.src = placeholder(cur.caption || "Photo"); }; 758 lb.querySelector(".lightbox__caption").textContent = 759 (lbState.idx + 1) + " / " + lbState.list.length + (cur.caption ? " â " + cur.caption : ""); 760 } 761 function onLbClick(e) { 762 if (e.target.closest(".lightbox__close") || e.target === lb) return closeLightbox(); 763 var dir = e.target.closest("[data-dir]"); 764 if (dir) stepLb(parseInt(dir.getAttribute("data-dir"), 10)); 765 } 766 function onLbKey(e) { 767 if (e.key === "Escape") closeLightbox(); 768 else if (e.key === "ArrowLeft") stepLb(-1); 769 else if (e.key === "ArrowRight") stepLb(1); 770 else if (e.key === "Tab") { 771 var f = $all("button", lb); 772 var first = f[0], last = f[f.length - 1]; 773 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 774 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 775 } 776 } 777 778 /* ---------- PERSON PANEL (a lab member's outcomes) ---------- */ 779 var MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; 780 var SEASONS = { winter: 1, spring: 4, summer: 7, fall: 10, autumn: 10 }; 781 function hlSort(date) { 782 var d = String(date || "").toLowerCase(); 783 var ym = d.match(/(\d{4})/); 784 var year = ym ? +ym[1] : 0; 785 var mon = 0; 786 var iso = d.match(/\d{4}-(\d{1,2})/); 787 if (iso) mon = +iso[1]; 788 if (!mon) for (var i = 0; i < 12; i++) if (d.indexOf(MONTHS[i]) > -1) { mon = i + 1; break; } 789 if (!mon) Object.keys(SEASONS).forEach(function (s) { if (d.indexOf(s) > -1) mon = SEASONS[s]; }); 790 return year * 100 + mon; 791 } 792 function hlItem(h) { 793 var body = h.href 794 ? '<a class="mh__link" href="' + esc(h.href) + '" target="_blank" rel="noopener">' + esc(h.text || "") + '</a>' 795 : esc(h.text || ""); 796 return '<li class="mh">' + 797 '<span class="mh__date">' + esc(h.date || "") + '</span>' + 798 (h.type ? '<span class="mh__type">' + esc(h.type) + '</span>' : "") + 799 '<span class="mh__text">' + body + '</span></li>'; 800 } 801 var pm, pmLast; 802 function ensurePersonModal() { 803 if (pm) return pm; 804 pm = el( 805 '<div class="pmodal" hidden role="dialog" aria-modal="true" aria-labelledby="pmodal-name">' + 806 '<div class="pmodal__card">' + 807 '<button class="pmodal__close" type="button" aria-label="Close">' + svgIcon("close") + '</button>' + 808 '<div class="pmodal__head">' + 809 '<div class="pmodal__photo"></div>' + 810 '<div><h3 id="pmodal-name" class="pmodal__name"></h3>' + 811 '<p class="pmodal__role"></p>' + 812 '<p class="pmodal__meta"></p></div>' + 813 '</div>' + 814 '<ul class="pmodal__list"></ul>' + 815 '</div></div>' 816 ); 817 document.body.appendChild(pm); 818 pm.addEventListener("click", function (e) { 819 if (e.target === pm || e.target.closest(".pmodal__close")) closePerson(); 820 }); 821 return pm; 822 } 823 function onPmKey(e) { if (e.key === "Escape") closePerson(); } 824 function openPerson(m) { 825 ensurePersonModal(); 826 pmLast = document.activeElement; 827 var ph = pm.querySelector(".pmodal__photo"); 828 ph.innerHTML = ""; 829 ph.appendChild(smartImg(m.photo || avatarURI(m.name), m.name || "", m.name)); 830 pm.querySelector(".pmodal__name").textContent = m.name || ""; 831 pm.querySelector(".pmodal__role").textContent =
832 [m.role, m.major, m.honors ? "Honors" : ""].filter(Boolean).join(" · "); 833 pm.querySelector(".pmodal__meta").textContent = 834 m.since ? "In the lab since " + m.since : ""; 835 var hl = (m.highlights || []).slice().sort(function (a, b) { return hlSort(b.date) - hlSort(a.date); }); 836 pm.querySelector(".pmodal__list").innerHTML = hl.length 837 ? hl.map(hlItem).join("") 838 : '<li class="mh"><span class="mh__text">Milestones will be listed here.</span></li>'; 839 pm.hidden = false; 840 document.body.style.overflow = "hidden"; 841 pm.querySelector(".pmodal__close").focus(); 842 document.addEventListener("keydown", onPmKey); 843 } 844 function closePerson() { 845 pm.hidden = true; 846 document.body.style.overflow = ""; 847 document.removeEventListener("keydown", onPmKey); 848 if (pmLast) pmLast.focus(); 849 } 850 // A photo button for one member; opens their outcomes panel on click. 851 function memberPhoto(m) { 852 var btn = el( 853 '<button class="person-photo" type="button" aria-label="' + 854 esc(m.name || "Lab member") + ' â view outcomes"><span class="person-photo__ring"></span>' + 855 '<span class="person-photo__name">' + esc(m.name || "") + '</span></button>' 856 ); 857 var ring = btn.querySelector(".person-photo__ring"); 858 var img = new Image(); 859 img.loading = "lazy"; img.decoding = "async"; img.alt = m.name || ""; 860 img.addEventListener("error", function () { 861 if (img.dataset.fb) return; 862 img.dataset.fb = "1"; 863 img.src = avatarURI(m.name); 864 }); 865 img.src = m.photo || avatarURI(m.name); 866 ring.appendChild(img); 867 btn.addEventListener("click", function () { openPerson(m); }); 868 return btn; 869 } 870 function peopleFor(areaId) { 871 var out = []; 872 (SK.people || []).forEach(function (g) { 873 if (g.projectId === areaId) out = out.concat(g.members || []); 874 }); 875 return out; 876 } 877 878 /* ---------- TALKS ---------- */ 879 function talkSort(when) { 880 var s = String(when || ""), low = s.toLowerCase(); 881 var year = +((s.match(/(\d{4})/) || [])[1] || 0); 882 var MON = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; 883 var mon = 0; 884 for (var i = 0; i < 12; i++) if (low.indexOf(MON[i]) > -1) { mon = i + 1; break; } 885 var day = +((s.match(/\b(\d{1,2})\b/) || [])[1] || 0); 886 return year * 10000 + mon * 100 + day; 887 } 888 function renderTalks() { 889 if (!SK.talks) return; 890 var up = $("[data-render='talks-upcoming']"); 891 var past = $("[data-render='talks-past']"); 892 var byDateAsc = function (a, b) { return talkSort(a.when) - talkSort(b.when); }; 893 var byDateDesc = function (a, b) { return talkSort(b.when) - talkSort(a.when); }; 894 function row(t) { 895 var titles = t.titles || (t.title ? [t.title] : []); 896 var titlesHtml = titles.length 897 ? '<ul class="trip__titles">' + titles.map(function (x) { 898 return '<li>' + esc(typeof x === "string" ? x : x.title) + '</li>'; 899 }).join("") + '</ul>' 900 : ""; 901 return el( 902 '<article class="trip reveal">' + 903 '<div><span class="trip__when">' + esc(t.when) + '</span>' + 904 (t.place ? '<br><span class="trip__place">' + esc(t.place) + '</span>' : "") + 905 '<br><span class="trip__status" data-s="' + esc(t.status) + '">' + (t.status === "upcoming" ? "Upcoming" : "Past") + '</span></div>' + 906 '<div><h3>' + esc(t.event) + '</h3>' + 907 titlesHtml + 908 (t.note ? '<p class="trip__note">' + esc(t.note) + '</p>' : "") + 909 (t.tags && t.tags.length ? '<div class="trip__tags">' + t.tags.map(function (x) { return '<span>' + esc(x) + '</span>'; }).join("") + '</div>' : "") + 910 '</div></article>' 911 ); 912 } 913 // upcoming: soonest first · past: most recent first 914 if (up) { up.innerHTML = ""; SK.talks.filter(function (t) { return t.status === "upcoming"; }).sort(byDateAsc).forEach(function (t) { up.appendChild(row(t)); }); } 915 if (past) { past.innerHTML = ""; SK.talks.filter(function (t) { return t.status === "past"; }).sort(byDateDesc).forEach(function (t) { past.appendChild(row(t)); }); } 916 } 917 918 /* ---------- PEOPLE (lab members, grouped by project) ---------- */ 919 function avatarURI(name) { 920 var parts = String(name || "?").trim().split(/\s+/); 921 var initials = ((parts[0] || "")[0] || "") + ((parts[parts.length - 1] || "")[0] || ""); 922 var svg = 923 '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">' + 924 '<rect width="240" height="240" fill="#17273F"/>' + 925 '<text x="120" y="132" fill="#E9B857" font-family="Space Grotesk, Inter, sans-serif" ' + 926 'font-size="92" font-weight="600" text-anchor="middle">' + esc(initials.toUpperCase()) + '</text></svg>'; 927 return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); 928 } 929 930 function renderPeople() { 931 var mount = $("[data-render='people']"); 932 if (!mount || !SK.people) return; 933 var areaIds = (SK.areas || []).map(function (a) { return a.id; }); 934 // Groups tied to a research area render as photos under that area 935 // (see renderWork). Here we only show the leftover groups, e.g. Alumni. 936 var groups = SK.people.filter(function (g) { return areaIds.indexOf(g.projectId) === -1; }); 937 mount.innerHTML = ""; 938 if (!groups.length) { 939 var host = mount.closest("section"); 940 if (host) host.hidden = true; 941 return; 942 } 943 groups.forEach(function (group) { 944 var section = el( 945 '<div class="people-group">' + 946 '<h3 class="people-group__title">' + esc(group.project || "Lab") + '</h3>' + 947 '<div class="person-row person-row--wrap"></div>' + 948 '</div>' 949 ); 950 var strip = section.querySelector(".person-row");
951 (group.members || []).forEach(function (m) { 952 var wrap = el('<div class="person-cell"></div>'); 953 wrap.appendChild(memberPhoto(m)); 954 if (m.role) wrap.appendChild(el('<p class="person-cell__role">' + esc(m.role) + '</p>')); 955 strip.appendChild(wrap); 956 }); 957 mount.appendChild(section); 958 }); 959 } 960 961 /* ---------- CONTACT FORM ---------- */ 962 function initContactForm() { 963 var form = $("#contact-form"); 964 if (!form) return; 965 var status = $("#form-status"); 966 var fields = $all("[data-required]", form); 967 968 // FormSubmit redirects back here with ?sent=1 after a successful send. 969 if (/[?&]sent=1\b/.test(location.search)) { 970 status.className = "form-status form-status--ok"; 971 status.hidden = false; 972 status.textContent = "Thanks â your message has been sent. Dr. Kim will reply to the ones she can help with."; 973 history.replaceState(null, "", location.pathname); 974 } 975 976 form.addEventListener("submit", function (e) { 977 var errors = []; 978 fields.forEach(function (f) { 979 var val = (f.value || "").trim(); 980 var bad = !val || (f.type === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(val)); 981 setFieldError(f, bad); 982 if (bad) errors.push(f); 983 }); 984 985 if (errors.length) { 986 e.preventDefault(); 987 status.className = "form-status"; 988 status.setAttribute("role", "alert"); 989 status.hidden = false; 990 status.innerHTML = "<strong>Please check a few fields.</strong><br>" + 991 errors.map(function (f) { 992 var lbl = form.querySelector("label[for='" + f.id + "']"); 993 return '<a href="#' + f.id + '">' + esc(lbl ? lbl.textContent : f.name) + '</a>'; 994 }).join(" · "); 995 status.focus(); 996 return; 997 } 998 // On the live site, let the form POST natively to its action (FormSubmit), 999 // which emails the submission. On localhost / file:// there is no useful 1000 // endpoint, so fall back to the visitor's mail client instead. 1001 var host = location.hostname; 1002 var isLocal = location.protocol === "file:" || host === "" || 1003 host === "localhost" || /^127\./.test(host) || host === "0.0.0.0"; 1004 var action = form.getAttribute("action") || ""; 1005 if (/^https?:\/\//i.test(action) && !isLocal) return; // native submit 1006 1007 e.preventDefault(); 1008 var to = (window.SK && SK.site && SK.site.email) || ""; 1009 var subject = encodeURIComponent("[Website] " + ($("#c-topic", form).value || "Enquiry") + " â " + $("#c-name", form).value); 1010 var body = encodeURIComponent( 1011 $("#c-message", form).value + "\n\nâ\n" + $("#c-name", form).value + "\n" + $("#c-email", form).value + 1012 ($("#c-org", form).value ? "\n" + $("#c-org", form).value : "") 1013 ); 1014 window.location.href = "mailto:" + to + "?subject=" + subject + "&body=" + body; 1015 status.className = "form-status form-status--ok"; 1016 status.hidden = false; 1017 status.innerHTML = "<strong>Opening your email appâ¦</strong><br>If nothing happens, write to <a data-email='text' href='#'></a> directly."; 1018 renderEmail(); 1019 }); 1020 1021 fields.forEach(function (f) { 1022 f.addEventListener("blur", function () { 1023 if (f.getAttribute("aria-invalid") === "true") { 1024 var val = (f.value || "").trim(); 1025 var bad = !val || (f.type === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(val)); 1026 setFieldError(f, bad); 1027 } 1028 }); 1029 }); 1030 } 1031 function setFieldError(f, bad) { 1032 var msg = document.getElementById(f.id + "-err"); 1033 f.setAttribute("aria-invalid", String(!!bad)); 1034 if (msg) msg.textContent = bad 1035 ? (f.type === "email" ? "Enter a valid email address." : "This field is required.") 1036 : ""; 1037 } 1038 1039 /* ---------- boot ---------- */ 1040 function safe(fn) { try { fn(); } catch (e) { if (window.console) console.error("[SK]", fn.name, e); } } 1041 1042 document.addEventListener("DOMContentLoaded", function () { 1043 [initNav, initYear, initStarfield, renderSocial, renderEmail, renderIntro, renderHomePanels, 1044 renderHome, renderWork, renderAbout, renderPublications, renderMedia, renderGalleries, 1045 renderAlbum, renderTalks, renderPeople, renderGrants, initContactForm].forEach(safe); 1046 // reveal runs last; hidden state is opt-in via .reveal-ready so a failure here 1047 // cannot leave content invisible 1048 safe(initReveal); 1049 }); 1050})();
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.