Skill Tracer
Skills › Visual Explainer › visual-explainer › kit.js

kit.js

129 lines · plugins/visual-explainer/kit/kit.js at 9d5f880

1// One block scope, so the kit's names never clash with a page's own script.
2{
3const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
4
5// Linked highlighting: hovering any [data-ref], or focusing a prose term or button, lights every element that shares a ref.
6// Hover wins while it lasts; when it ends, the focused element's selection comes back.
7const linked = [...document.querySelectorAll('[data-ref]')], refs = (el) => el.dataset.ref.split(' ');
8let hovered = null, focused = null;
9const paint = () => {
10 const keys = (hovered || focused)?.dataset.ref.split(' ') ?? [];
11 for (const t of linked) t.classList.toggle('is-lit', refs(t).some((k) => keys.includes(k)));
12 for (const svg of document.querySelectorAll('svg')) svg.classList.toggle('has-lit', !!svg.querySelector('.is-lit'));
13};
14for (const el of linked) {
15 el.addEventListener('pointerenter', () => { hovered = el; paint(); });
16 el.addEventListener('pointerleave', () => { hovered = null; paint(); });
17 el.addEventListener('focus', () => { focused = el; paint(); });
18 el.addEventListener('blur', () => { focused = null; paint(); });
21// Stepper / scene player. Range and Play are optional: a plain stepper needs only data-go buttons.
22for (const root of document.querySelectorAll('.ve-steps')) {
23 const parts = [...root.querySelectorAll('[data-s]')], caps = [...root.querySelectorAll('.ve-cap li')];
24 const range = root.querySelector('input[type=range]'), play = root.querySelector('[data-play]');
25 const n = caps.length; let i = 0, timer = null;
26 if (range) range.max = n - 1;
27 const show = (k) => {
28 i = Math.max(0, Math.min(n - 1, k));
29 root.classList.remove('is-still');
30 // data-s lists the steps a part takes part in: it appears at the first ("0" = from the start) and is .now on each.
31 for (const el of parts) { const s = el.dataset.s.trim().split(' ').map(Number); el.classList.toggle('on', s[0] <= i + 1); el.classList.toggle('now', s.includes(i + 1)); }
32 caps.forEach((c, j) => c.hidden = j !== i);
33 root.classList.toggle('is-full', i === n - 1);
34 root.dispatchEvent(new Event('ve:step'));
35 if (range) range.value = i;
36 };
37 const stop = () => { clearInterval(timer); timer = null; if (play) play.textContent = 'Play'; };
38 root.addEventListener('click', (e) => { const b = e.target.closest('[data-go]'); if (b) { stop(); show(i + +b.dataset.go); } });
39 range?.addEventListener('input', () => { stop(); show(+range.value); });
40 play?.addEventListener('click', () => {
41 if (timer) return stop();
42 if (i === n - 1) show(0);
43 play.textContent = 'Pause';
44 timer = setInterval(() => (i < n - 1 ? show(i + 1) : stop()), 3500);
45 });
46 show(n - 1); // open on the full picture; Play replays from step 1
47 root.classList.add('is-still'); // same task as show(), so the last token never starts until the reader steps
48 addEventListener('beforeprint', () => { show(n - 1); root.classList.add('is-still'); }); // print shows the end state
51// Section nav: the link of the section in view gets aria-current, and .pos counts it.
52for (const nav of document.querySelectorAll('.ve-toc')) {
53 const links = [...nav.querySelectorAll('a[href^="#"]')], pos = nav.querySelector('.pos');
54 const targets = links.map((a) => {
55 const t = document.getElementById(decodeURIComponent(a.hash.slice(1)));
56 if (!t) throw new Error(`the section nav links to ${a.hash}, but no element has that id`);
57 return t;
58 });
59 // every section in the band, so scrolling back re-marks the one still there; the lowest one in the band wins
60 const seen = new Set();
61 const io = new IntersectionObserver((entries) => {
62 for (const e of entries) e.isIntersecting ? seen.add(e.target) : seen.delete(e.target);
63 const i = targets.findLastIndex((t) => seen.has(t));
64 if (i >= 0) {
65 links.forEach((a, j) => (j === i ? a.setAttribute('aria-current', 'true') : a.removeAttribute('aria-current')));
66 if (pos) pos.textContent = `${i + 1} / ${links.length}`;
67 try { history.replaceState(null, '', links[i].hash); } catch {} // some browsers throw on file:// pages
68 }
69 }, { rootMargin: '-10% 0px -80% 0px' });
70 for (const t of targets) io.observe(t);
73if (!still) {
74 // Flow dots: each path[data-flow] spawns a dot every data-every ms; the ratio of intervals is the real traffic split.
75 // Runs only while the figure is on screen and, inside a stepper, only on the full picture.
76 for (const svg of document.querySelectorAll('svg:has([data-flow])')) {
77 const layer = svg.querySelector('.ve-dots'), steps = svg.closest('.ve-steps');
78 const routes = [...svg.querySelectorAll('[data-flow]')].map((p) => ({ p, len: p.getTotalLength(), every: +p.dataset.every, next: 0 }));
79 let dots = [], visible = false, running = false;
80 const live = () => visible && (!steps || steps.classList.contains('is-full'));
81 const tick = (t) => {
82 if (!live()) { running = false; for (const d of dots) d.c.remove(); dots = []; return; }
83 for (const r of routes) if (t >= r.next) {
84 r.next = t + r.every;
85 const c = document.createElementNS(svg.namespaceURI, 'circle');
86 c.setAttribute('r', 4); c.setAttribute('class', r.p.dataset.flow); layer.append(c);
87 dots.push({ c, r, t0: t });
88 }
89 dots = dots.filter((d) => {
90 const s = (t - d.t0) * .3; // 300 units per second
91 if (s > d.r.len) { d.c.remove(); return false; }
92 const pt = d.r.p.getPointAtLength(s);
93 d.c.setAttribute('cx', pt.x); d.c.setAttribute('cy', pt.y);
94 return true;
95 });
96 requestAnimationFrame(tick);
97 };
98 const start = () => { if (!running && live()) { running = true; requestAnimationFrame(tick); } };
99 steps?.addEventListener('ve:step', start);
100 new IntersectionObserver(([e]) => { visible = e.isIntersecting; start(); }).observe(svg);
101 }
103 // Entrance: each block rises in once; numbers count up and bars grow as it arrives.
104 // Without JS, in print, or under reduced motion, everything shows its final state.
105 document.documentElement.classList.add('js-motion');
106 const io = new IntersectionObserver((entries) => {
107 for (const e of entries) {
108 if (!e.isIntersecting) continue;
109 io.unobserve(e.target); e.target.classList.add('in');
110 for (const el of e.target.querySelectorAll('[data-count]')) {
111 const end = +el.dataset.count, dp = (el.dataset.count.split('.')[1] || '').length, t0 = performance.now();
112 const step = (t) => {
113 const k = Math.min(1, (t - t0) / 900);
114 el.textContent = (end * (1 - (1 - k) ** 3)).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp });
115 if (k < 1) requestAnimationFrame(step);
116 };
117 requestAnimationFrame(step);
118 }
119 }
120 }, { rootMargin: '0px 0px -10% 0px' });
121 for (const el of document.querySelectorAll('main > *')) io.observe(el);
124// reader replies (kit/reply.js, inlined before this); a page opened raw has none
125window.veReply?.page();
127// Last statement: kit/look.mjs reads this to tell that the whole script ran.
128document.documentElement.dataset.veKit = "ready";