PageSourceSearch

https://seungsukam.github.io/assets/app.js

js seungsukam.github.io collected 2026-10-03 09:55:53 UTC 25,830 bytes, 518 lines download raw bytes

1/* ============================================================================
2 *  app.js — renders assets/data.js into the page.
3 *  You normally never need to touch this file. Edit assets/data.js instead.
4 * ========================================================================== */
5(function () {
6  'use strict';
7
8  var S = window.SITE;
9  var P = S.profile;
10  var L = S.links || {};
11  var ME = (P.meName || '').trim();
12
13  /* ------------------------------------------------------------ helpers -- */
14  function esc(v) {
15    return String(v == null ? '' : v)
16      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
17      .replace(/"/g, '&quot;');
18  }
19  function markMe(text) {
20    var out = esc(text);
21    if (!ME) return out;
22    var needle = esc(ME).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23    return out.replace(new RegExp(needle, 'g'), '<u>' + esc(ME) + '</u>');
24  }
25  function set(id, html) {
26    var n = document.getElementById(id);
27    if (n) n.innerHTML = html;
28    return n;
29  }
30  function ext(href) {
31    return /^https?:/.test(href || '') ? ' target="_blank" rel="noopener"' : '';
32  }
33  /** Newest first. Lets you paste new entries anywhere in data.js. */
34  function byYear(list, key) {
35    return (list || []).slice().sort(function (a, b) {
36      // 연도가 비어 있으면(심사 중 등) 원래 순서를 유지한다
37      return (parseInt(b[key || 'year'], 10) || 0) - (parseInt(a[key || 'year'], 10) || 0);
38    });
39  }
40
41  /* ------------------------------------------------------ card artwork --- */
42  /* Line drawings for the work cards. Each is a 400×180 SVG that inherits
43     the current text colour, so they follow the light / dark theme.        */
44  function svg(inner) {
45    return '<svg viewBox="0 0 400 180" fill="none" preserveAspectRatio="xMidYMid meet" aria-hidden="true">' +
46           inner + '</svg>';
47  }
48  var ART = {
49    // ordinary light curve + one flagged excursion
50    anomaly: svg(
51      '<g stroke="currentColor" stroke-opacity=".08"><path d="M0 60h400M0 108h400"/></g>' +
52      '<path d="M0 118 20 112 40 122 60 104 80 116 100 100 120 112 140 102 160 114 180 104 200 116' +
53      ' 220 106 242 36 262 60 280 104 300 112 320 102 340 114 360 104 380 112 400 106' +
54      ' L400 180 L0 180 Z" fill="var(--tint)" fill-opacity=".5"/>' +
55      '<path d="M0 118 20 112 40 122 60 104 80 116 100 100 120 112 140 102 160 114 180 104 200 116' +
56      ' 220 106 242 36 262 60 280 104 300 112 320 102 340 114 360 104 380 112 400 106"' +
57      ' stroke="currentColor" stroke-opacity=".85" stroke-width="2.4" stroke-linejoin="round"/>' +
58      '<path d="M242 58v100" stroke="currentColor" stroke-opacity=".35" stroke-width="1.4" stroke-dasharray="4 5"/>' +
59      '<circle cx="242" cy="36" r="21" stroke="currentColor" stroke-width="2.4"/>' +
60      '<circle cx="242" cy="36" r="5" fill="currentColor"/>'
61    ),
62    // observations that land whenever they land
63    irregular: svg(
64      '<path d="M0 92C56 34 96 112 156 78s86-56 132-12 60 44 112 22 L400 180 L0 180 Z"' +
65      ' fill="var(--tint)" fill-opacity=".5"/>' +
66      '<path d="M0 92C56 34 96 112 156 78s86-56 132-12 60 44 112 22"' +
67      ' stroke="currentColor" stroke-opacity=".85" stroke-width="2.4" stroke-linecap="round"/>' +
68      '<path d="M0 152h400" stroke="currentColor" stroke-opacity=".3" stroke-width="1.6"/>' +
69      '<g stroke="currentColor" stroke-opacity=".55" stroke-width="2">' +
70        '<path d="M22 146v12M38 146v12M46 146v12M74 146v12M112 146v12M126 146v12M134 146v12' +
71        'M176 146v12M228 146v12M240 146v12M276 146v12M312 146v12M330 146v12M382 146v12"/></g>' +
72      '<g stroke="currentColor" stroke-opacity=".28" stroke-width="1.2" stroke-dasharray="3 5">' +
73        '<path d="M38 70v82M112 90v62M176 74v78M240 50v102M312 56v96"/></g>' +
74      '<g fill="currentColor">' +
75        '<circle cx="38" cy="70" r="4.5"/><circle cx="112" cy="90" r="4.5"/><circle cx="176" cy="74" r="4.5"/>' +
76        '<circle cx="240" cy="50" r="4.5"/><circle cx="312" cy="56" r="4.5"/></g>'
77    ),
78    // competing risks pulling one cohort apart
79    survival: svg(
80      '<path d="M0 26h62v28h56v30h60v22h74v22h60v16h88 L400 180 L0 180 Z"' +
81      ' fill="var(--tint)" fill-opacity=".5"/>' +
82      '<path d="M0 26h62v28h56v30h60v22h74v22h60v16h88"' +
83      ' stroke="currentColor" stroke-opacity=".85" stroke-width="2.6" stroke-linejoin="round"/>' +
84      '<path d="M0 26h40v46h58v30h60v28h72v22h66v14h104"' +
85      ' stroke="currentColor" stroke-opacity=".38" stroke-width="2" stroke-dasharray="6 5" stroke-linejoin="round"/>' +
86      '<path d="M0 158h400" stroke="currentColor" stroke-opacity=".25" stroke-width="1.6"/>' +
87      '<g stroke="currentColor" stroke-opacity=".7" stroke-width="2">' +
88        '<path d="M90 48v12M170 78v12M262 100v12M330 122v12"/></g>'
89    ),
90    // dose-response densities with a chosen starting point
91    dosing: svg(
92      '<path d="M14 180c50 0 36-102 84-102s34 102 84 102z" fill="currentColor" fill-opacity=".08"/>' +
93      '<path d="M14 180c50 0 36-102 84-102s34 102 84 102" stroke="currentColor" stroke-opacity=".35" stroke-width="2"/>' +
94      '<path d="M218 180c50 0 36-88 84-88s34 88 84 88z" fill="currentColor" fill-opacity=".08"/>' +
95      '<path d="M218 180c50 0 36-88 84-88s34 88 84 88" stroke="currentColor" stroke-opacity=".35" stroke-width="2"/>' +
96      '<path d="M116 180c50 0 36-148 84-148s34 148 84 148z" fill="var(--tint)" fill-opacity=".55"/>' +
97      '<path d="M116 180c50 0 36-148 84-148s34 148 84 148" stroke="currentColor" stroke-opacity=".85" stroke-width="2.6"/>' +
98      '<path d="M200 32v148" stroke="currentColor" stroke-opacity=".4" stroke-width="1.4" stroke-dasharray="4 5"/>' +
99      '<circle cx="200" cy="32" r="6" fill="currentColor"/>' +
100      '<path d="M0 174h400" stroke="currentColor" stroke-opacity=".25" stroke-width="1.6"/>'
101    ),
102    // heterogeneous series pre-trained into one backbone
103    foundation: svg(
104      '<g stroke="currentColor" stroke-opacity=".45" stroke-width="2" stroke-linejoin="round">' +
105        '<path d="M8 34 34 24 60 38 86 28 112 36"/>' +
106        '<path d="M8 78 34 68 60 84 86 62 112 74"/>' +
107        '<path d="M8 122 34 132 60 110 86 124 112 114"/>' +
108        '<path d="M8 164 34 154 60 166 86 150 112 158"/></g>' +
109      '<g stroke="currentColor" stroke-opacity=".25" stroke-width="1.4">' +
110        '<path d="M116 36 146 76M116 74 146 84M116 114 146 100M116 158 146 110"/></g>' +
111      '<rect x="148" y="52" width="76" height="76" rx="14" fill="var(--tint)" fill-opacity=".6"/>' +
112      '<rect x="148" y="52" width="76" height="76" rx="14" stroke="currentColor" stroke-width="2.4"/>' +
113      '<g fill="currentColor" fill-opacity=".55">' +
114        '<circle cx="170" cy="76" r="3.4"/><circle cx="186" cy="90" r="3.4"/><circle cx="202" cy="76" r="3.4"/>' +
115        '<circle cx="178" cy="106" r="3.4"/><circle cx="194" cy="106" r="3.4"/></g>' +
116      '<path d="M228 90h26" stroke="currentColor" stroke-opacity=".3" stroke-width="1.6"/>' +
117      '<path d="M256 92 288 62 320 96 352 48 400 70"' +
118      ' stroke="currentColor" stroke-opacity=".85" stroke-width="2.6" stroke-linejoin="round"/>' +
119      '<path d="M352 48c22 6 34 2 48-8" stroke="currentColor" stroke-opacity=".35" stroke-width="2" stroke-dasharray="6 5"/>'
120    ),
121    // health degrading toward a failure threshold, with the life that remains
122    rul: svg(
123      '<rect x="214" y="0" width="106" height="180" fill="var(--tint)" fill-opacity=".55"/>' +
124      '<path d="M0 130h400" stroke="currentColor" stroke-opacity=".4" stroke-width="1.6" stroke-dasharray="6 5"/>' +
125      '<path d="M0 26C92 32 156 56 214 90" stroke="currentColor" stroke-opacity=".85" stroke-width="2.8"/>' +
126      '<path d="M214 90c48 22 74 32 106 40" stroke="currentColor" stroke-opacity=".4" stroke-width="2.4" stroke-dasharray="7 5"/>' +
127      '<path d="M214 8v164" stroke="currentColor" stroke-opacity=".45" stroke-width="1.6"/>' +
128      '<circle cx="214" cy="90" r="6" fill="currentColor"/>' +
129      '<circle cx="320" cy="130" r="6.5" fill="none" stroke="currentColor" stroke-width="2.4"/>' +
130      '<g stroke="currentColor" stroke-opacity=".7" stroke-width="2">' +
131        '<path d="M220 44h94M220 44l9-6M220 44l9 6M314 44l-9-6M314 44l-9 6"/></g>' +
132      '<path d="M0 172h400" stroke="currentColor" stroke-opacity=".25" stroke-width="1.6"/>'
133    ),
134    // generic fallback
135    forecast: svg(
136      '<path d="M0 120 60 96 120 108 180 66 240 82 300 44 360 60 400 32 L400 160 L0 160 Z"' +
137      ' fill="var(--tint)" fill-opacity=".5"/>' +
138      '<path d="M0 120 60 96 120 108 180 66 240 82 300 44 360 60 400 32"' +
139      ' stroke="currentColor" stroke-opacity=".85" stroke-width="2.4" stroke-linejoin="round"/>'
140    )
141  };
142
143  /* --------------------------------------------------------------- nav --- */
144  var NAV = [
145    { id: 'about',        label: 'About' },
146    { id: 'work',         label: 'Work' },
147    { id: 'publications', label: 'Publications' },
148    { id: 'talks',        label: 'Talks' },
149    { id: 'teaching',     label: 'Teaching' }
150  ];
151
152  document.querySelectorAll('[data-wordmark]').forEach(function (n) {
153    n.textContent = P.name;
154  });
155
156  set('navMenu', NAV.map(function (n) {
157    return '<a href="#' + n.id + '" data-nav="' + n.id + '">' + esc(n.label) + '</a>';
158  }).join(''));
159
160  set('navActions',
161    '<a class="btn btn-outline btn-sm" href="mailto:' + esc(P.email) + '">Email</a>' +
162    (L.scholar ? '<a class="btn btn-primary btn-sm" href="' + esc(L.scholar) + '"' + ext(L.scholar) + '>Google Scholar</a>' : '') +
163    '<button class="icon-btn" id="themeBtn" type="button" aria-label="Toggle colour theme">' +
164      '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">' +
165      '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>' +
166    '</button>' +
167    '<button class="icon-btn nav-toggle" id="navToggle" type="button" aria-label="Menu" aria-expanded="false">' +
168      '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">' +
169      '<path d="M3 6h18M3 12h18M3 18h18"/></svg>' +
170    '</button>');
171
172  /* 최근 논문 3편 — publications 전체에서 최신순으로 자동 선택 */
173  function heroRecent() {
174    var all = [];
175    Object.keys(S.publications || {}).forEach(function (k) {
176      (S.publications[k] || []).forEach(function (it) { all.push(it); });
177    });
178    all = byYear(all).slice(0, 3);
179    if (!all.length) return '';
180    return '<aside class="hero-aside">' +
181             '<p class="eyebrow">Recent</p>' +
182             '<ul class="hero-recent">' + all.map(function (it) {
183               return '<li>' +
184                        '<span class="meta">' + esc(it.year) + ' &middot; ' + esc(it.venue || '') + '</span>' +
185                        '<span class="ttl">' + esc(it.title) + '</span>' +
186                      '</li>';
187             }).join('') + '</ul>' +
188             '<a class="btn-text" href="#publications">All publications <span class="arw">&#8594;</span></a>' +
189           '</aside>';
190  }
191
192  /* -------------------------------------------------------------- hero --- */
193  var hp = P.heroPhoto || {};
194  set('hero',
195    // 1. the banner photograph, edge to edge, straight under the menu bar
196    (hp.src
197      ? '<figure class="hero-photo">' +
198          '<div class="hero-photo-frame">' +
199            // 좌우 날개는 같은 사진을 좌우 반전한 것 — 이음선에서 픽셀이 정확히 이어진다
200            '<img class="hero-photo-wing wing-l" src="' + esc(hp.src) + '" alt="" aria-hidden="true" />' +
201            '<img class="hero-photo-main" src="' + esc(hp.src) + '" alt="' + esc(hp.alt || '') + '" />' +
202            '<img class="hero-photo-wing wing-r" src="' + esc(hp.src) + '" alt="" aria-hidden="true" />' +
203          '</div>' +
204          // caption only when data.js actually provides one
205          (hp.caption || hp.year
206            ? '<figcaption><div class="container">' +
207                '<p>' + esc(hp.caption || '') + '</p>' +
208                (hp.year ? '<span>' + esc(hp.year) + '</span>' : '') +
209              '</div></figcaption>'
210            : '') +
211        '</figure>'
212      : '') +
213    // 2. identity line + thesis
214    '<div class="hero-body">' +
215      '<div class="orb orb-lavender orb-a"></div>' +
216      '<div class="orb orb-mint orb-b"></div>' +
217      '<div class="orb orb-peach orb-c"></div>' +
218      '<div class="container"><div class="hero-split">' +
219        '<div class="hero-copy">' +
220          '<p class="identity"><b>' + esc(P.name) + '</b><span>' + esc(P.role) + '</span></p>' +
221          '<h1 class="display-mega">' + P.heroLine + '</h1>' +
222          '<p class="lead">' + esc(P.heroLead) + '</p>' +
223          '<div class="hero-cta">' +
224            '<a class="btn btn-primary" href="mailto:' + esc(P.email) + '">Get in touch</a>' +
225            '<a class="btn btn-outline" href="#work">Selected work</a>' +
226          '</div>' +
227        '</div>' +
228        heroRecent() +
229      '</div></div>' +
230    '</div>');
231
232  /* ------------------------------------------------------- logo wall ----- */
233  set('logoWall',
234    '<div class="container">' +
235      '<p class="eyebrow">Research collaborations</p>' +
236      '<div class="logo-row">' + (S.collaborators || []).map(function (c) {
237        var inner = c.logo
238          ? '<img src="' + esc(c.logo) + '" alt="' + esc(c.name) + '" />'
239          : (c.abbr ? '<span class="abbr">' + esc(c.abbr) + '</span>' : '') + esc(c.name);
240        return c.url
241          ? '<a class="logo-item" href="' + esc(c.url) + '"' + ext(c.url) + '>' + inner + '</a>'
242          : '<span class="logo-item">' + inner + '</span>';
243      }).join('') + '</div>' +
244    '</div>');
245
246  /* -------------------------------------------------------------- work --- */
247  set('work',
248    '<div class="container">' +
249      '<div class="sec-head reveal">' +
250        '<div><p class="eyebrow">Selected work</p>' +
251        '<h2 class="display-lg">' + esc(S.workTitle || 'Where the clock is not on a grid.') + '</h2></div>' +
252      '</div>' +
253      '<div class="work-grid">' + (S.work || []).map(function (w, i) {
254        // 이미지가 있으면 이미지, 없거나 로드에 실패하면 그려둔 일러스트
255        var visual = w.image
256          ? '<img src="' + esc(w.image) + '" alt="' + esc(w.title) + '" loading="lazy"' +
257            ' data-art="' + esc(w.art || 'forecast') + '" />'
258          : (ART[w.art] || ART.forecast);
259        return '<article class="work-card reveal tint-' + esc(w.orb || 'sky') + '">' +
260                 '<div class="work-art' + (w.image ? ' has-image' : '') + '">' + visual + '</div>' +
261                 '<div class="work-copy">' +
262                   '<span class="idx">' + String(i + 1).padStart(2, '0') + '</span>' +
263                   '<h3 class="display-sm">' + esc(w.title) + '</h3>' +
264                   '<p class="body-md">' + esc(w.text) + '</p>' +
265                   '<div class="tags">' + (w.tags || []).map(function (t) {
266                     return '<span class="tag">' + esc(t) + '</span>';
267                   }).join('') + '</div>' +
268                 '</div>' +
269               '</article>';
270      }).join('') + '</div>' +
271    '</div>');
272
273  /* 이미지 파일이 아직 없으면 조용히 기존 일러스트로 되돌린다 */
274  document.querySelectorAll('.work-art img[data-art]').forEach(function (img) {
275    img.addEventListener('error', function () {
276      var panel = img.parentNode;
277      panel.classList.remove('has-image');
278      panel.innerHTML = ART[img.dataset.art] || ART.forecast;
279    });
280  });
281
282  /* ------------------------------------------------------------ entries -- */
283  function entry(o) {
284    var links = (o.links || []).filter(function (l) { return l.href; });
285    return '<article class="entry reveal">' +
286             '<div class="yr">' + esc(o.year) + '</div>' +
287             '<div>' +
288               '<h4>' + esc(o.title) + '</h4>' +
289               (o.authors ? '<p class="authors">' + markMe(o.authors) + '</p>' : '') +
290               (o.venue || o.kind
291                 ? '<div class="meta-row">' +
292                     (o.venue ? '<span class="venue">' + esc(o.venue) + '</span>' : '') +
293                     (o.kind ? '<span class="kind">' + esc(o.kind) + '</span>' : '') +
294                   '</div>'
295                 : '') +
296               (o.note ? '<p class="note">' + esc(o.note) + '</p>' : '') +
297             '</div>' +
298             (links.length
299               ? '<div class="entry-links">' + links.map(function (l) {
300                   return '<a href="' + esc(l.href) + '"' + ext(l.href) + '>' + esc(l.label) + ' &#8599;</a>';
301                 }).join('') + '</div>'
302               : '<div></div>') +
303           '</article>';
304  }
305  function subLabel(text, n) {
306    return '<div class="sub-label"><span>' + esc(text) + '</span><i></i><b>' +
307           String(n).padStart(2, '0') + '</b></div>';
308  }
309  function emptyRow(msg) { return '<p class="empty">' + esc(msg) + '</p>'; }
310
311  /* ------------------------------------------------------ publications --- */
312  /* 논문 섹션에 표시되는 순서. 순서를 바꾸려면 이 배열의 줄 순서만 바꾸면 된다. */
313  var PUB_GROUPS = [
314    { key: 'underReview', label: 'Under review' },
315    { key: 'journal',     label: 'Journal' },
316    { key: 'conference',  label: 'Conference' },
317    { key: 'workshop',    label: 'Workshop' }
318  ];
319  var pubTotal = 0;
320  var papersBody = '';
321  PUB_GROUPS.forEach(function (g) {
322    var list = byYear(S.publications[g.key]);
323    if (!list.length) return;                       // 비어 있는 그룹은 아예 생략
324    pubTotal += list.length;
325    papersBody += subLabel(g.label, list.length) + list.map(entry).join('');
326  });
327  if (!papersBody) papersBody = emptyRow('Nothing here yet.');
328
329  set('publications',
330    '<div class="container">' +
331      '<div class="sec-head reveal">' +
332        '<div><p class="eyebrow">Publications</p>' +
333        '<h2 class="display-lg">Papers</h2></div>' +
334        (L.scholar ? '<a class="btn-text" href="' + esc(L.scholar) + '"' + ext(L.scholar) +
335          '>All on Google Scholar <span class="arw">&#8594;</span></a>' : '') +
336      '</div>' +
337      papersBody +
338    '</div>');
339
340  /* -------------------------------------------------------------- talks -- */
341  var talks = byYear(S.talks);
342  set('talks',
343    '<div class="container">' +
344      '<div class="sec-head reveal">' +
345        '<div><p class="eyebrow">Speaking</p>' +
346        '<h2 class="display-lg">Talks &amp; presentations</h2></div>' +
347      '</div>' +
348      (talks.length ? talks.map(entry).join('') : emptyRow('Nothing here yet.')) +
349    '</div>');
350
351  /* --------------------------------------------- teaching + education ---- */
352  var teaching = byYear(S.teaching, 'term').map(function (t) {
353    return entry({ year: t.term, title: t.course, authors: t.org, venue: t.role, note: t.note });
354  }).join('');
355  var awards = byYear(S.awards).map(function (a) {
356    return entry({ year: a.year, title: a.title, authors: a.org });
357  }).join('');
358  var education = byYear(S.education).map(function (e) {
359    return entry({ year: e.year, title: e.title, authors: e.org, note: e.note });
360  }).join('');
361
362  set('teaching',
363    '<div class="container">' +
364      '<div class="two-up">' +
365        '<div class="reveal"><p class="eyebrow">Teaching</p>' +
366          '<h2 class="display-md">Teaching assistantships</h2>' +
367          (teaching || emptyRow('Nothing here yet.')) +
368        '</div>' +
369        '<div class="reveal"><p class="eyebrow">Background</p>' +
370          '<h2 class="display-md">Education &amp; awards</h2>' +
371          subLabel('Education', (S.education || []).length) + education +
372          subLabel('Awards', (S.awards || []).length) + awards +
373        '</div>' +
374      '</div>' +
375    '</div>');
376
377  /* -------------------------------------------------------------- about -- */
378  set('about',
379    '<div class="container"><div class="about-grid">' +
380      '<div class="about-body reveal">' +
381        '<p class="eyebrow">About</p>' +
382        '<h2 class="display-md about-title">' + esc(P.aboutTitle) + '</h2>' +
383        P.about.map(function (p) { return '<p class="body-md">' + p + '</p>'; }).join('') +
384        '<div class="interest-list">' + (P.interests || []).map(function (t) {
385          return '<span class="tag">' + esc(t) + '</span>';
386        }).join('') + '</div>' +
387        '<div class="about-cta">' +
388          '<a class="btn btn-primary" href="#publications">See publications</a>' +
389          (L.scholar ? '<a class="btn-text" href="' + esc(L.scholar) + '"' + ext(L.scholar) +
390            '>Google Scholar <span class="arw">&#8594;</span></a>' : '') +
391        '</div>' +
392      '</div>' +
393      '<div class="about-side reveal">
393' +
394        '<figure class="about-portrait">' +
395          '<img src="' + esc(P.photo) + '" alt="' + esc(P.name) + '" />' +
396          '<figcaption>' + esc(P.name) + '</figcaption>' +
397        '</figure>' +
398        (P.facts && P.facts.length
399          ? '<dl class="facts">' + P.facts.map(function (f) {
400              return '<dt>' + esc(f.label) + '</dt><dd>' + esc(f.value) + '</dd>';
401            }).join('') + '</dl>'
402          : '') +
403      '</div>' +
404    '</div></div>');
405
406  /* ---------------------------------------------------------------- cta -- */
407  set('cta',
408    '<div class="container"><div class="cta-band reveal">' +
409      '<div class="orb orb-sky orb-a"></div>' +
410      '<div class="orb orb-rose orb-b"></div>' +
411      '<h2 class="display-lg">' + esc(S.cta.title) + '</h2>' +
412      '<p class="body-md">' + esc(S.cta.text) + '</p>' +
413      '<div class="btns">' +
414        '<a class="btn btn-on-dark" href="mailto:' + esc(P.email) + '">' + esc(P.email) + '</a>' +
415        (L.linkedin ? '<a class="btn btn-outline" href="' + esc(L.linkedin) + '"' + ext(L.linkedin) + '>LinkedIn</a>' : '') +
416      '</div>' +
417    '</div></div>');
418
419  /* ------------------------------------------------------------- footer -- */
420  function fLink(label, href) {
421    return href ? '<li><a href="' + esc(href) + '"' + ext(href) + '>' + esc(label) + '</a></li>' : '';
422  }
423  set('footer',
424    '<div class="container">' +
425      '<div class="footer-grid">' +
426        '<div class="footer-brand">' +
427          '<div class="wordmark" data-wordmark>' + esc(P.name) + '</div>' +
428          '<p>' + esc(P.role) + '</p>' +
429        '</div>' +
430        '<div><h5>Sections</h5><ul>' +
431          NAV.map(function (n) { return '<li><a href="#' + n.id + '">' + esc(n.label) + '</a></li>'; }).join('') +
432        '</ul></div>' +
433        '<div><h5>Elsewhere</h5><ul>' +
434          fLink('Google Scholar', L.scholar) +
435          fLink('GitHub', L.github) +
436          fLink('LinkedIn', L.linkedin) +
437          fLink('Curriculum Vitae', L.cv) +
438        '</ul></div>' +
439        '<div><h5>Contact</h5><ul>' +
440          '<li><a href="mailto:' + esc(P.email) + '">' + esc(P.email) + '</a></li>' +
441          '<li><a href="https://www.unist.ac.kr" target="_blank" rel="noopener">UNIST</a></li>' +
442        '</ul></div>' +
443      '</div>' +
444      '<div class="footer-bottom">' +
445        '<span>&copy; ' + new Date().getFullYear() + ' ' + esc(P.name) + '</span>' +
446        '<span>Ulsan, Republic of Korea</span>' +
447      '</div>' +
448    '</div>');
449
450  /* ==========================================================================
451   *  Behaviour
452   * ========================================================================*/
453
454  /* colour theme */
455  (function () {
456    var KEY = 'theme', root = document.documentElement;
457    function apply(v) { root.setAttribute('data-theme', v); try { localStorage.setItem(KEY, v); } catch (e) {} }
458    var saved = null;
459    try { saved = localStorage.getItem(KEY); } catch (e) {}
460    apply(saved === 'light' || saved === 'dark'
461      ? saved
462      : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
463    var btn = document.getElementById('themeBtn');
464    if (btn) btn.addEventListener('click', function () {
465      apply(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
466    });
467  })();
468
469  /* mobile menu */
470  (function () {
471    var toggle = document.getElementById('navToggle');
472    var menu = document.getElementById('navMenu');
473    if (!toggle || !menu) return;
474    toggle.addEventListener('click', function () {
475      var open = menu.classList.toggle('open');
476      toggle.setAttribute('aria-expanded', String(open));
477    });
478    menu.addEventListener('click', function (e) {
479      if (e.target.tagName === 'A') {
480        menu.classList.remove('open');
481        toggle.setAttribute('aria-expanded', 'false');
482      }
483    });
484  })();
485
486  /* scroll reveal + active nav link */
487  (function () {
488    var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
489    if (reduce || !('IntersectionObserver' in window)) {
490      document.querySelectorAll('.reveal').forEach(function (n) { n.classList.add('in'); });
491    } else {
492      var io = new IntersectionObserver(function (rows) {
493        rows.forEach(function (r) {
494          if (r.isIntersecting) { r.target.classList.add('in'); io.unobserve(r.target); }
495        });
496      }, { rootMargin: '0px 0px -8% 0px', threshold: .06 });
497      document.querySelectorAll('.reveal').forEach(function (n) { io.observe(n); });
498    }
499
500    var links = {};
501    document.querySelectorAll('[data-nav]').forEach(function (a) { links[a.dataset.nav] = a; });
502    var spy = new IntersectionObserver(function (rows) {
503      rows.forEach(function (r) {
504        var a = links[r.target.id];
505        if (!a) return;
506        if (r.isIntersecting) {
507          Object.keys(links).forEach(function (k) { links[k].classList.remove('active'); });
508          a.classList.add('active');
509        }
510      });
511    }, { rootMargin: '-45% 0px -50% 0px' });
512    NAV.forEach(function (n) {
513      var s = document.getElementById(n.id);
514      if (s) spy.observe(s);
515    });
516  })();
517
518})();

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.