PageSourceSearch

https://davidserra9.github.io/assets/js/site.js

js davidserra9.github.io collected 2026-10-03 08:55:57 UTC 29,587 bytes, 677 lines download raw bytes

1/* Site behaviour: news, publications, and the photo playground. No dependencies. */
2(function () {
3  "use strict";
4
5  var S = window.SITE;
6  var root = document.documentElement;
7  var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
9
10  function $(id) { return document.getElementById(id); }
11  function el(tag, attrs, children) {
12    var n = document.createElement(tag);
13    if (attrs) for (var k in attrs) {
14      if (k === "class") n.className = attrs[k];
15      else if (k === "html") n.innerHTML = attrs[k];
16      else if (k === "text") n.textContent = attrs[k];
17      else n.setAttribute(k, attrs[k]);
18    }
19    (children || []).forEach(function (c) { if (c) n.appendChild(typeof c === "string" ? document.createTextNode(c) : c); });
20    return n;
21  }
22  function setAccent(h, c) {
23    root.style.setProperty("--acc-h", h);
24    root.style.setProperty("--acc-c", c);
25  }
26
27  /* Radio-style groups built from buttons: click + arrow keys. */
28  function radioGroup(container, onPick) {
29    container.addEventListener("click", function (e) {
30      var b = e.target.closest("[role=radio]");
31      if (b && container.contains(b)) pick(b);
32    });
33    container.addEventListener("keydown", function (e) {
34      var items = [].slice.call(container.querySelectorAll("[role=radio]"));
35      var i = items.indexOf(document.activeElement);
36      if (i < 0) return;
37      var d = (e.key === "ArrowRight" || e.key === "ArrowDown") ? 1 : (e.key === "ArrowLeft" || e.key === "ArrowUp") ? -1 : 0;
38      if (!d) return;
39      e.preventDefault();
40      var next = items[(i + d + items.length) % items.length];
41      next.focus();
42      pick(next);
43    });
44    function pick(b) {
45      container.querySelectorAll("[role=radio]").forEach(function (x) {
46        var on = x === b;
47        x.setAttribute("aria-checked", on ? "true" : "false");
48        x.tabIndex = on ? 0 : -1;
49      });
50      onPick(b);
51    }
52    container.querySelectorAll("[role=radio]").forEach(function (x) {
53      x.tabIndex = x.getAttribute("aria-checked") === "true" ? 0 : -1;
54    });
55    return { pick: pick };
56  }
57
58  /* ----------------------------------------------------------------
59     News
60  ---------------------------------------------------------------- */
61  (function news() {
62    var list = $("news-list"), more = $("news-more");
63    var SHOW = 6;
64    S.news.forEach(function (n, i) {
65      var p = n.date.split("-");
66      var li = el("li", { class: i >= SHOW ? "is-extra" : "" }, [
67        el("time", { datetime: n.date, text: MONTHS[+p[1] - 1] + " " + p[0] }),
68        el("div", { class: "text", html: n.html })
69      ]);
70      list.appendChild(li);
71    });
72    if (S.news.length > SHOW) {
73      more.hidden = false;
74      var label = function (open) { more.textContent = open ? "Show fewer" : "Show all " + S.news.length + " items"; };
75      label(false);
76      more.addEventListener("click", function () {
77        var open = list.classList.toggle("open");
78        more.setAttribute("aria-expanded", open ? "true" : "false");
79        label(open);
80      });
81    }
82  })();
83
84  /* ----------------------------------------------------------------
85     Publications
86  ---------------------------------------------------------------- */
87  (function pubs() {
88    var box = $("pubs");
89    var canHover = window.matchMedia("(hover: hover)").matches;
90    var videos = [];
91
92    function name(a) {
93      var p = a.split(", ");
94      return p.length === 2 ? p[1] + " " + p[0] : a;
95    }
96
97    var years = [];
98    S.papers.forEach(function (p) { if (years.indexOf(p.year) < 0) years.push(p.year); });
99
100    years.forEach(function (y) {
101      var group = el("div", { class: "year-group" }, [el("p", { class: "year", text: String(y) })]);
102      S.papers.filter(function (p) { return p.year === y; }).forEach(function (p) {
103        var first = p.authors[0] === S.me;
104        var href = p.links.project || p.links.paper || p.links.arXiv;
105
106        var thumb = el("div", { class: "thumb" });
107        if (p.teaser && /\.mp4$/.test(p.teaser)) {
108          var v = el("video", { muted: "", loop: "", playsinline: "", preload: "none", poster: p.poster || "", "aria-hidden": "true" });
109          v.muted = true;
110          v.appendChild(el("source", { src: p.teaser, type: "video/mp4" }));
111          thumb.appendChild(v);
112          thumb.appendChild(el("span", { class: "play-dot", "aria-hidden": "true" }));
113          videos.push({ v: v, t: thumb });
114          if (canHover) {
115            thumb.addEventListener("pointerenter", function () { playV(v, thumb); });
116            thumb.addEventListener("pointerleave", function () { pauseV(v, thumb); });
117          }
118        } else if (p.teaser) {
119          thumb.appendChild(el("img", { src: p.teaser, alt: "", loading: "lazy" }));
120        } else {
121          thumb.classList.add("thumb-empty");
122          thumb.appendChild(el("span", { text: p.venue }));
123        }
124
125        var authors = el("p", { class: "authors" });
126        p.authors.forEach(function (a, i) {
127          if (i) authors.appendChild(document.createTextNode(", "));
128          authors.appendChild(a === S.me ? el("span", { class: "me", text: name(a) }) : document.createTextNode(name(a)));
129        });
130
131        var links = el("ul", { class: "links" });
132        Object.keys(p.links).forEach(function (k) {
133          links.appendChild(el("li", {}, [el("a", { href: p.links[k], text: k })]));
134        });
135
136        var art = el("article", { class: "pub", "data-first": first ? "1" : "0" }, [
137          thumb,
138          el("div", { class: "pub-body" }, [
139            el("h3", {}, [href ? el("a", { href: href, text: p.title }) : p.title]),
140            authors,
141            el("p", { class: "venue" }, [
142              p.short ? el("span", { class: "short", text: p.short + " · " }) : null,
143              p.venue
144            ]),
145            links
146          ])
147        ]);
148        group.appendChild(art);
149      });
150      box.appendChild(group);
151    });
152
153    function playV(v, t) {
154      if (reduceMotion) return;
155      var pr = v.play();
156      if (pr && pr.catch) pr.catch(function () {});
157      t.classList.add("playing");
158    }
159    function pauseV(v, t) { v.pause(); t.classList.remove("playing"); }
160
161    // Touch screens: play teasers while they are on screen.
162    if (!canHover && "IntersectionObserver" in window && !reduceMotion) {
163      var io = new IntersectionObserver(function (entries) {
164        entries.forEach(function (e) {
165          var item = videos.filter(function (x) { return x.t === e.target; })[0];
166          if (!item) return;
167          if (e.isIntersecting && e.intersectionRatio > 0.6) playV(item.v, item.t);
168          else pauseV(item.v, item.t);
169        });
170      }, { threshold: [0, 0.6, 1] });
171      videos.forEach(function (x) { io.observe(x.t); });
172    }
173
174    // Filter
175    var filter = $("pub-filter");
176    var total = S.papers.length;
177    var firsts = S.papers.filter(function (p) { return p.authors[0] === S.me; }).length;
178    filter.querySelector('[data-f="all"]').innerHTML = 'All <span class="n">' + total + "</span>";
179    filter.querySelector('[data-f="first"]').innerHTML = 'First author <span class="n">' + firsts + "</span>";
180    radioGroup(filter, function (b) {
181      var onlyFirst = b.getAttribute("data-f") === "first";
182      box.querySelectorAll(".year-group").forEach(function (g) {
183        var shown = 0;
184        g.querySelectorAll(".pub").forEach(function (a) {
185          var show = !onlyFirst || a.getAttribute("data-first") === "1";
186          a.hidden = !show;
187          if (show) shown++;
188        });
189        g.hidden = shown === 0;
190      });
191    });
192  })();
193
194  /* ----------------------------------------------------------------
195     Playground
196  ---------------------------------------------------------------- */
197  var H = S.hero;
198  var play = $("play"), stage = $("stage");
199  var frameA = $("frame-a"), original = $("original");
200  var standin = $("standin");
201  var mode = "nc";
202
203  var capText = $("cap-text"), capLink = $("cap-link");
204  function caption(cfg) {
205    capText.innerHTML = "<b>" + H.caption.replace(/^(Fig\. \d+\.)/, "$1</b>") + " " + cfg.caption;
206    if (cfg.link) { capLink.href = cfg.link; capLink.hidden = false; } else capLink.hidden = true;
207  }
208
209  /* Hold to compare with the original ------------------------------ */
210  (function hold() {
211    var btn = $("hold");
212    function on(e) { if (e) e.preventDefault(); stage.classList.add("comparing"); }
213    function off() { stage.classList.remove("comparing"); }
214    btn.addEventListener("pointerdown", function (e) { btn.setPointerCapture && btn.setPointerCapture(e.pointerId); on(e); });
215    ["pointerup", "pointercancel", "lostpointercapture"].forEach(function (t) { btn.addEventListener(t, off); });
216    btn.addEventListener("keydown", function (e) { if (e.key === " " || e.key === "Enter") on(e); });
217    btn.addEventListener("keyup", off);
218    btn.addEventListener("blur", off);
219    btn.addEventListener("contextmenu", function (e) { e.preventDefault(); });
220  })();
221
222  /* Precomputed frames (SyncLight, UniSlider) ----------------------
223     Frames are drawn on one canvas as a weighted blend of the nearest
224     renders, so the sliders move smoothly between them. */
225  var fx = $("fx-canvas"), fctx = fx.getContext("2d");
226  var cache = {};
227  function frame(url) {
228    var im = cache[url];
229    if (!im) {
230      im = cache[url] = new Image();
231      im.onload = function () { im.ok = true; redraw(); };
232      im.src = url;
233    }
234    return im.ok ? im : null;
235  }
236  function preload(urls) {
237    var i = 0;
238    (function next() {
239      for (var n = 0; n < 4 && i < urls.length; n++, i++) frame(urls[i]);
240      if (i < urls.length) setTimeout(next, 60);
241    })();
242  }
243  // parts: [[url, weight], ...]. Weighted average via successive "over" blends.
244  function blend(parts) {
245    var ready = [];
246    parts.forEach(function (p) { if (p[1] > 0.001) { var im = frame(p[0]); if (im) ready.push([im, p[1]]); } });
247    if (!ready.length) return;
248    var acc = 0;
249    ready.forEach(function (p, i) {
250      acc += p[1];
251      fctx.globalAlpha = i === 0 ? 1 : p[1] / acc;
252      fctx.drawImage(p[0], 0, 0, fx.width, fx.height);
253    });
254    fctx.globalAlpha = 1;
255    fx.classList.add("ready");
256  }
257  function redraw() {
258    if (mode === "sl") renderSL();
259    else if (mode === "us") renderUS();
260  }
261  function hexMix(a, b, f) {
262    var pa = [1, 3, 5].map(function (i) { return parseInt(a.substr(i, 2), 16); });
263    var pb = [1, 3, 5].map(function (i) { return parseInt(b.substr(i, 2), 16); });
264    return "rgb(" + pa.map(function (v, i) { return Math.round(v + (pb[i] - v) * f); }).join(",") + ")";
265  }
266
267  /* SyncLight: chromaticity x intensity ---------------------------- */
268  var SL = H.synclight;
269  var slC = $("sl-c"), slI = $("sl-i");
270  var colorIdx = SL.colors.map(function (c) { return c.id; });
271  function slUrl(ci, ii) {
272    if (SL.intensities[ii] === 0 && SL.off) return SL.off; // lamps off: one shared frame
273    return "assets/hero/synclight/" + SL.colors[ci].id + "_" + ii + ".jpg";
274  }
275  // Both sliders snap: one color and one intensity level at a time, one image shown.
276  slC.max = SL.colors.length - 1;
277  slC.value = Math.max(0, colorIdx.indexOf(SL.startColor));
278  slI.max = SL.intensities.length - 1;
279  slI.value = Math.max(0, SL.intensities.indexOf(SL.startIntensity));
280  // Track drawn as one block per stop, each block centred on the thumb's stop position.
281  function segments(colors) {
282    var n = colors.length, half = 9, at = function (f) { return "calc(" + half + "px + (100% - " + 2 * half + "px) * " + f.toFixed(4) + ")"; };
283    return "linear-gradient(90deg," + colors.map(function (c, i) {
284      return c + " " + (i === 0 ? "0px" : at((i - 0.5) / (n - 1))) + " " + (i === n - 1 ? "100%" : at((i + 0.5) / (n - 1)));
285    }).join(",") + ")";
286  }
287  slC.style.setProperty("--track", segments(SL.colors.map(function (c) { return c.swatch; })));
288  function renderSL() {
289    var ci = Math.round(+slC.value), ii = Math.round(+slI.value), C = SL.colors[ci];
290    blend([[slUrl(ci, ii), 1]]);
291    $("sl-dot").style.background = C.swatch;
292    $("sl-c-name").textContent = C.label;
293    slI.style.setProperty("--track", segments(SL.intensities.map(function (v) { return hexMix("#1b1c1e", C.swatch, v); })));
294    $("sl-i-out").textContent = Math.round(SL.intensities[ii] * 100) + "%";
295    setAccent(C.hue, C.chroma);
296  }
297  function slAll() {
298    // nearest frames first, then the rest
299    var c = Math.round(+slC.value), urls = [];
300    for (var ii = 0; ii < SL.intensities.length; ii++) urls.push(slUrl(c, ii));
301    for (var ci = 0; ci < SL.colors.length; ci++) if (ci !== c) for (ii = 0; ii < SL.intensities.length; ii++) urls.push(slUrl(ci, ii));
302    return urls;
303  }
304  slC.addEventListener("input", renderSL);
305  slI.addEventListener("input", renderSL);
306
307  /* UniSlider ------------------------------------------------------ */
308  var US = H.unislider, usPrompt = US.start;
309  var range = $("us-s");
310  var promptText = $("us-prompt-text"), typing = 0;
311  function usUrl(id, k) { return "assets/hero/unislider/" + id + "/" + k + ".jpg"; }
312  function usCfg(id) { return US.prompts.filter(function (p) { return p.id === id; })[0]; }
313  function usSteps(id) { return usCfg(id).steps || US.steps; }
314  function usFrames(id) { var a = []; for (var k = 0; k <= usSteps(id); k++) a.push(usUrl(id, k)); return a; }
315  (function buildPrompts() {
316    var box = $("prompts");
317    US.prompts.forEach(function (P) {
318      box.appendChild(el("button", {
319        type: "button", role: "radio", class: "prompt",
320        "aria-checked": P.id === US.start ? "true" : "false",
321        "data-id": P.id, text: P.label || P.text, title: P.text
322      }));
323    });
324    radioGroup(box, function (b) { setPrompt(b.getAttribute("data-id"), true); });
325  })();
326  // Show the instruction; typed out when the visitor picks a new one.
327  function showPrompt(text, animate) {
328    var token = ++typing;
329    if (!animate || reduceMotion) { promptText.textContent = text; promptText.classList.remove("typing"); return; }
330    var i = 0;
331    promptText.classList.add("typing");
332    (function tick() {
333      if (token !== typing) return;
334      promptText.textContent = text.slice(0, ++i);
335      if (i < text.length) setTimeout(tick, 28);
336      else promptText.classList.remove("typing");
337    })();
338  }
339  function setPrompt(id, animate) {
340    var changed = id !== usPrompt;
341    usPrompt = id;
342    range.max = usSteps(id);
343    preload(usFrames(id));
344    showPrompt(usCfg(id).text, animate && changed);
345    renderUS();
346  }
347  // The slider snaps to the rendered frames (equal perceptual steps), one image at a time.
348  function renderUS() {
349    var P = usCfg(usPrompt), n = usSteps(usPrompt);
350    var k = Math.max(0, Math.min(n, Math.round(+range.value)));
351    blend([[usUrl(usPrompt, k), 1]]);
352    setAccent(P.hue, P.chroma);
353  }
354  range.max = usSteps(usPrompt);
355  range.value = US.startStep != null ? US.startStep : Math.round(usSteps(usPrompt) / 2);
356  range.addEventListener("input", renderUS);
357
358  /* Mode switching ------------------------------------------------- */
359  var MODE = { unislider: "us", synclight: "sl", namedcurves: "nc" };
360  var order = (H.order || ["unislider", "synclight", "namedcurves"]).map(function (k) { return MODE[k]; });
361  var tablist = play.querySelector("[role=tablist]");
362  order.forEach(function (m) { tablist.appendChild(tablist.querySelector('[data-mode="' + m + '"]')); });
363  var tabs = [].slice.call(tablist.querySelectorAll("[role=tab]"));
364  function setMode(m, focus) {
365    mode = m;
366    play.setAttribute("data-mode", m);
367    tabs.forEach(function (t) {
368      var on = t.getAttribute("data-mode") === m;
369      t.setAttribute("aria-selected", on ? "true" : "false");
370      t.tabIndex = on ? 0 : -1;
371      if (on && focus) t.focus();
372    });
373    ["nc", "sl", "us"].forEach(function (k) {
374      var pn = $("panel-" + k), off = k !== m;
375      pn.classList.toggle("is-off", off);
376      if (off) pn.setAttribute("inert", ""); else pn.removeAttribute("inert");
377    });
378    fx.classList.remove("ready");
379    if (m === "nc") NC.activate();
380    else {
381      NC.deactivate();
382      if (m === "sl") { renderSL(); preload(slAll()); }
383      if (m === "us") setPrompt(usPrompt, false);
384    }
385    var cfg = m === "sl" ? SL : m === "us" ? US : H.namedcurves;
386    standin.hidden = !cfg.placeholder;
387    caption(cfg);
388  }
389  tabs.forEach(function (t) {
390    t.addEventListener("click", function () { setMode(t.getAttribute("data-mode")); });
391    t.addEventListener("keydown", function (e) {
392      var i = tabs.indexOf(t), d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
393      if (!d) return;
394      e.preventDefault();
395      setMode(tabs[(i + d + tabs.length) % tabs.length].getAttribute("data-mode"), true);
396    });
397  });
398
399  /* ----------------------------------------------------------------
400     NamedCurves, live
401     Colour naming: van de Weijer et al. (2009), 11 names grouped into
402     the 6 NamedCurves categories. Each category has one Bezier tone curve
403     per RGB channel; the output is the curve responses blended by the
404     per-pixel colour-name probabilities.
405  ---------------------------------------------------------------- */
406  var NC = (function () {
407    var CATS = [
408      { name: "orange · brown · yellow", sw: "linear-gradient(90deg,#e38b2c 0 33%,#8b5a2b 33% 66%,#f2d03b 66%)", hue: 62, chroma: 0.12 },
409      { name: "achromatic", sw: "linear-gradient(90deg,#161616 0 33%,#8a8a8a 33% 66%,#fbfbfb 66%)", hue: 250, chroma: 0 },
410      { name: "pink · purple", sw: "linear-gradient(90deg,#f29bbf 0 50%,#8b4fb0 50%)", hue: 330, chroma: 0.13 },
411      { name: "red", sw: "#d62f2d", hue: 27, chroma: 0.15 },
412      { name: "green", sw: "#3f9b46", hue: 145, chroma: 0.13 },
413      { name: "blue", sw: "#2f5fd0", hue: 262, chroma: 0.14 }
414    ];
415    var NPTS = 5;
416    var ID = [0, 0.25, 0.5, 0.75, 1];
417    var BIN = [1, 4, 6, 4, 1];
418
419    // curves[cat][channel] = control-point heights; x positions are uniform.
420    var curves = CATS.map(function () { return [ID.slice(), ID.slice(), ID.slice()]; });
421    // Start with a small warm lift on orange-brown-yellow so the edit is visible at rest.
422    curves[0] = [[0, 0.31, 0.6, 0.82, 1], [0, 0.28, 0.55, 0.79, 1], [0, 0.22, 0.46, 0.72, 1]];
423
424    var cat = 0, chan = "rgb";
425    var lut = null; // Uint8Array(32768 * 6)
426    var canvas = $("nc-canvas"), ctx = canvas.getContext("2d", { willReadFrequently: true });
427    var src = null, outImg = null, probs = null, N = 0;
428    var ownPhoto = false, pending = false, showMap = false, ready = false;
429
430    function decodeLut() {
431      if (lut || !window.W2C6) return;
432      var bin = atob(window.W2C6);
433      lut = new Uint8Array(bin.length);
434      for (var i = 0; i < bin.length; i++) lut[i] = bin.charCodeAt(i);
435    }
436
437    function curveDelta(ys) {
438      var d = new Float32Array(256);
439      for (var v = 0; v < 256; v++) {
440        var t = v / 255, u = 1 - t, y = 0;
441        for (var j = 0; j < NPTS; j++) y += BIN[j] * Math.pow(t, j) * Math.pow(u, NPTS - 1 - j) * ys[j];
442        d[v] = Math.max(0, Math.min(255, y * 255)) - v;
443      }
444      return d;
445    }
446    function isIdentity(ys) { for (var j = 0; j < NPTS; j++) if (Math.abs(ys[j] - ID[j]) > 1e-4) return false; return true; }
447    function catEdited(k) { return !(isIdentity(curves[k][0]) && isIdentity(curves[k][1]) && isIdentity(curves[k][2])); }
448
449    function loadImage(img) {
450      decodeLut();
451      var maxW = 720, w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
452      if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; }
453      canvas.width = w; canvas.height = h;
454      ctx.drawImage(img, 0, 0, w, h);
455      src = ctx.getImageData(0, 0, w, h);
456      outImg = ctx.createImageData(w, h);
457      N = w * h;
458      probs = new Uint8Array(N * 6);
459      var d = src.data;
460      for (var i = 0, p = 0, q = 0; i < N; i++, p += 4, q += 6) {
461        var b = ((d[p] >> 3) + ((d[p + 1] >> 3) << 5) + ((d[p + 2] >> 3) << 10)) * 6;
462        probs[q] = lut[b]; probs[q + 1] = lut[b + 1]; probs[q + 2] = lut[b + 2];
463        probs[q + 3] = lut[b + 3]; probs[q + 4] = lut[b + 4]; probs[q + 5] = lut[b + 5];
464      }
465      ready = true;
466      render();
467      canvas.classList.add("ready");
468    }
469
470    function render() {
471      pending = false;
472      if (!ready) return;
473      var active = [];
474      for (var k = 0; k < 6; k++) if (catEdited(k)) active.push({ k: k, r: curveDelta(curves[k][0]), g: curveDelta(curves[k][1]), b: curveDelta(curves[k][2]) });
475      var d = src.data, o = outImg.data, na = active.length;
476      var bg = getComputedStyle(root).getPropertyValue("color-scheme").indexOf("dark") >= 0 ? 34 : 236;
477      for (var i = 0, p = 0, q = 0; i < N; i++, p += 4, q += 6) {
478        var r = d[p], g = d[p + 1], b = d[p + 2], dr = 0, dg = 0, db = 0;
479        for (var a = 0; a < na; a++) {
480          var A = active[a], w = probs[q + A.k];
481          if (w) { dr += w * A.r[r]; dg += w * A.g[g]; db += w * A.b[b]; }
482        }
483        r += dr / 255; g += dg / 255; b += db / 255;
484        if (showMap) {
485          var m = probs[q + cat] / 255, y = (0.3 * r + 0.59 * g + 0.11 * b) * 0.3 + bg * 0.7;
486          r = r * m + y * (1 - m); g = g * m + y * (1 - m); b = b * m + y * (1 - m);
487        }
488        o[p] = r; o[p + 1] = g; o[p + 2] = b; o[p + 3] = 255;
489      }
490      ctx.putImageData(outImg, 0, 0);
491    }
492    function schedule() { if (!pending) { pending = true; requestAnimationFrame(render); } }
493
494    /* Color-name chips */
495    var catBox = $("cats"), catName = $("cat-name");
496    CATS.forEach(function (c, k) {
497      catBox.appendChild(el("button", {
498        type: "button", role: "radio", class: "cat", "data-k": k,
499        "aria-checked": k === cat ? "true" : "false",
500        "aria-label": c.name, title: c.name,
501        style: "background:" + c.sw
502      }, [el("span", { class: "edited", "aria-hidden": "true" })]));
503    });
504    function showName() { catName.innerHTML = CATS[cat].name + (catEdited(cat) ? " <span>· edited</span>" : ""); }
505    function markEdited() {
506      catBox.querySelectorAll(".cat").forEach(function (b) { b.classList.toggle("is-edited", catEdited(+b.getAttribute("data-k"))); });
507      showName();
508    }
509    radioGroup(catBox, function (b) {
510      cat = +b.getAttribute("data-k");
511      setAccent(CATS[cat].hue, CATS[cat].chroma);
512      drawCurve();
513      if (showMap) schedule();
514    });
515    radioGroup($("chan"), function (b) { chan = b.getAttribute("data-ch"); drawCurve(); });
516
517    /* Curve editor */
518    var cv = $("curve"), cctx = cv.getContext("2d");
519    var sel = 2, dragging = false, hasFocus = false;
520    function sizeCurve() {
521      var css = cv.getBoundingClientRect().width || 224, dpr = window.devicePixelRatio || 1;
522      cv.width = Math.round(css * dpr); cv.height = Math.round(css * dpr);
523      cctx.setTransform(dpr, 0, 0, dpr, 0, 0);
524      drawCurve();
525    }
526    function tok(name) { return getComputedStyle(root).getPropertyValue(name).trim(); }
527    function shownYs() {
528      var c = curves[cat];
529      if (chan !== "rgb") return c[+chan];
530      return ID.map(function (_, j) { return (c[0][j] + c[1][j] + c[2][j]) / 3; });
531    }
532    function drawCurve() {
533      var s = cv.getBoundingClientRect().width || 224, pad = 8, W = s - 2 * pad;
534      var X = function (x) { return pad + x * W; }, Y = function (y) { return pad + (1 - y) * W; };
535      cctx.clearRect(0, 0, s, s);
536      cctx.fillStyle = tok("--surface"); cctx.fillRect(0, 0, s, s);
537      cctx.strokeStyle = tok("--rule"); cctx.lineWidth = 1;
538      for (var i = 0; i <= 4; i++) {
539        cctx.beginPath(); cctx.moveTo(X(i / 4), Y(0)); cctx.lineTo(X(i / 4), Y(1)); cctx.stroke();
540        cctx.beginPath(); cctx.moveTo(X(0), Y(i / 4)); cctx.lineTo(X(1), Y(i / 4)); cctx.stroke();
541      }
542      cctx.setLineDash([3, 3]); cctx.strokeStyle = tok("--rule-2");
543      cctx.beginPath(); cctx.moveTo(X(0), Y(0)); cctx.lineTo(X(1), Y(1)); cctx.stroke();
544      cctx.setLineDash([]);
545
546      var c = curves[cat];
547      var same = isIdentity(c[0].map(function (v, j) { return v - c[1][j] + ID[j]; })) && isIdentity(c[0].map(function (v, j) { return v - c[2][j] + ID[j]; }));
548      function bez(ys, color, width) {
549        cctx.strokeStyle = color; cctx.lineWidth = width; cctx.beginPath();
550        for (var v = 0; v <= 64; v++) {
551          var t = v / 64, u = 1 - t, y = 0;
552          for (var j = 0; j < NPTS; j++) y += BIN[j] * Math.pow(t, j) * Math.pow(u, NPTS - 1 - j) * ys[j];
553          v ? cctx.lineTo(X(t), Y(y)) : cctx.moveTo(X(t), Y(y));
554        }
555        cctx.stroke();
556      }
557      var CH = ["#d8453b", "#3a9a4a", "#3d6bd6"];
558      if (chan === "rgb") {
559        if (same) bez(c[0], tok("--ink"), 2);
560        else [0, 1, 2].forEach(function (k) { bez(c[k], CH[k], 1.75); });
561      } else {
562        [0, 1, 2].forEach(function (k) { if (k !== +chan) bez(c[k], tok("--rule-2"), 1.25); });
563        bez(c[+chan], CH[+chan], 2);
564      }
565      // control polygon + points
566      var ys = shownYs();
567      cctx.setLineDash([2, 3]); cctx.strokeStyle = tok("--ink-3"); cctx.lineWidth = 1;
568      cctx.beginPath(); ys.forEach(function (y, j) { j ? cctx.lineTo(X(ID[j]), Y(y)) : cctx.moveTo(X(ID[j]), Y(y)); }); cctx.stroke();
569      cctx.setLineDash([]);
570      ys.forEach(function (y, j) {
571        var active = (dragging || hasFocus) && j === sel;
572        cctx.beginPath(); cctx.arc(X(ID[j]), Y(y), active ? 6 : 4.5, 0, Math.PI * 2);
573        cctx.fillStyle = active ? tok("--ink") : tok("--surface"); cctx.fill();
574        cctx.strokeStyle = tok("--ink"); cctx.lineWidth = 1.5; cctx.stroke();
575      });
576      markEdited();
577    }
578    function setPoint(j, y) {
579      y = Math.max(0, Math.min(1, y));
580      var c = curves[cat];
581      if (chan === "rgb") {
582        var dy = y - shownYs()[j];
583        for (var k = 0; k < 3; k++) c[k][j] = Math.max(0, Math.min(1, c[k][j] + dy));
584      } else c[+chan][j] = y;
585      drawCurve(); schedule();
586    }
587    function local(e) {
588      var r = cv.getBoundingClientRect(), pad = 8, W = r.width - 2 * pad;
589      return { x: (e.clientX - r.left - pad) / W, y: 1 - (e.clientY - r.top - pad) / W };
590    }
591    cv.addEventListener("pointerdown", function (e) {
592      var p = local(e);
593      sel = Math.max(0, Math.min(NPTS - 1, Math.round(p.x * (NPTS - 1))));
594      dragging = true;
595      cv.setPointerCapture(e.pointerId);
596      setPoint(sel, p.y);
597      e.preventDefault();
598    });
599    cv.addEventListener("pointermove", function (e) { if (dragging) setPoint(sel, local(e).y); });
600    ["pointerup", "pointercancel"].forEach(function (t) { cv.addEventListener(t, function () { dragging = false; drawCurve(); }); });
601    cv.addEventListener("focus", function () { hasFocus = true; drawCurve(); });
602    cv.addEventListener("blur", function () { hasFocus = false; drawCurve(); });
603    cv.addEventListener("keydown", function (e) {
604      var step = e.shiftKey ? 0.1 : 0.02;
605      if (e.key === "ArrowLeft") sel = Math.max(0, sel - 1);
606      else if (e.key === "ArrowRight") sel = Math.min(NPTS - 1, sel + 1);
607      else if (e.key === "ArrowUp") { setPoint(sel, shownYs()[sel] + step); }
608      else if (e.key === "ArrowDown") { setPoint(sel, shownYs()[sel] - step); }
609      else return;
610      e.preventDefault(); drawCurve();
611    });
612
613    $("nc-reset").addEventListener("click", function () {
614      curves = CATS.map(function () { return [ID.slice(), ID.slice(), ID.slice()]; });
615      drawCurve(); schedule();
616    });
617    $("show-map").addEventListener("change", function (e) { showMap = e.target.checked; schedule(); });
618
619    /* Own photo */
620    var mine = $("my-photo");
621    $("own-photo").addEventListener("change", function (e) {
622      var f = e.target.files && e.target.files[0];
623      if (!f) return;
624      var url = URL.createObjectURL(f), img = new Image();
625      img.onload = function () {
626        ownPhoto = true;
627        stage.classList.add("own");
628        original.src = url;
629        loadImage(img);
630        mine.hidden = false;
631      };
632      img.src = url;
633      e.target.value = "";
634    });
635    function backToMine() {
636      ownPhoto = false;
637      stage.classList.remove("own");
638      original.src = H.photo;
639      mine.hidden = true;
640      loadPhoto();
641    }
642    mine.addEventListener("click", backToMine);
643
644    var photoImg = null;
645    function loadPhoto() {
646      if (photoImg && photoImg.complete && photoImg.naturalWidth) { loadImage(photoImg); return; }
647      photoImg = new Image();
648      photoImg.onload = function () { if (!ownPhoto) loadImage(photoImg); };
649      photoImg.src = H.photo;
650    }
651
652    // Re-render the curve (and the map background) when the theme flips.
653    var mq = window.matchMedia("(prefers-color-scheme: dark)");
654    (mq.addEventListener ? mq.addEventListener.bind(mq, "change") : mq.addListener.bind(mq))(function () { drawCurve(); if (showMap) schedule(); });
655    new MutationObserver(function () { drawCurve(); if (showMap) schedule(); }).observe(root, { attributes: true, attributeFilter: ["data-theme"] });
656    window.addEventListener("resize", sizeCurve);
657
658    sizeCurve();
659    loadPhoto();
660
661    return {
662      activate: function () { setAccent(CATS[cat].hue, CATS[cat].chroma); },
663      deactivate: function () { if (ownPhoto) backToMine(); }
664    };
665  })();
666
667  setMode(order[0]);
668
669  /* Header shows the name only once the big one has scrolled away. */
670  (function headerName() {
671    var bar = $("topbar"), h1 = $("name");
672    if (!("IntersectionObserver" in window) || !h1) return;
673    new IntersectionObserver(function (e) {
674      bar.classList.toggle("name-off", e[0].isIntersecting);
675    }, { rootMargin: "-60px 0px 0px 0px" }).observe(h1);
676  })();
677})();

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.