@bezda/rhp-mcp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,1332 @@
1
+ // The code that runs in the checked page. install() is serialized by Playwright and run there, so it uses nothing from
2
+ // outside its own body. It puts window.__rhpProbe in the page: settle(), measure(), hiding text and marks for the
3
+ // screenshots the colors are read from, and what the interaction pass needs (its targets, a change recorder, where the
4
+ // controls are).
5
+ export function install() {
6
+
7
+ if (window.__rhpProbe) return;
8
+
9
+ const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
10
+ const BLOCK = { "rhp-bar": "Bar", "rhp-dot": "Dot", "rhp-tick": "Tick", "rhp-label": "Label", "rhp-cell": "Cell", "rhp-place": "Place", "rhp-area": "Area", "rhp-line": "Line" };
11
+ const MARKS = ".rhp-bar, .rhp-dot, .rhp-tick, .rhp-area, .rhp-line";
12
+ const CONTROLS = "button, input:not([type=hidden]), select, textarea, summary, [role=button], [role=switch], [role=tab], [role=radio], [role=checkbox], [role=slider]";
13
+ const SKIP_TEXT = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TITLE", "HEAD", "OPTION", "TEXTAREA"]);
14
+ const GENERIC = /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-serif|ui-sans-serif|ui-monospace|ui-rounded|emoji|math|fangsong|-apple-system|blinkmacsystemfont|inherit|initial)$/i;
15
+
16
+ let mutations = 0;
17
+ const quiet = new MutationObserver((list) => {
18
+ mutations += list.length;
19
+ });
20
+ quiet.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
21
+
22
+ // Waits for fonts, two frames, the animations that end (CSS transitions, a slat's own animations) and a DOM that
23
+ // stays still for two frames (the JS version moves numbers on its own clock), up to max ms. Says whether the page
24
+ // came to rest.
25
+ async function settle(max = 10000) {
26
+
27
+ const start = performance.now();
28
+ const left = () => max - (performance.now() - start);
29
+
30
+ let timer;
31
+ await Promise.race([document.fonts?.ready, new Promise((r) => { timer = setTimeout(r, Math.max(0, left())); })]).finally(() => clearTimeout(timer));
32
+ await frame();
33
+ await frame();
34
+
35
+ let still = 0;
36
+ while (left() > 0 && still < 2) {
37
+ const before = mutations;
38
+ const moving = document.getAnimations().some((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime ?? Infinity));
39
+ await frame();
40
+ still = !moving && mutations === before ? still + 1 : 0;
41
+ }
42
+
43
+ return { ms: Math.round(performance.now() - start), rest: still >= 2 && document.fonts?.status !== "loading" };
44
+ }
45
+
46
+ // A full-page screenshot lays the page out at another size for a moment (Chromium does, to draw what is past the
47
+ // window), so the page's resize and matchMedia handlers run: a chart whose orientation follows the width turns and
48
+ // turns back. calm(since) waits for the page to come to rest again when that made it change, since resized() said
49
+ // since.
50
+ let resizes = 0;
51
+ addEventListener("resize", () => resizes++);
52
+ async function calm([r, m], max = 10000) {
53
+
54
+ await frame();
55
+ const moving = document.getAnimations().some((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime ?? Infinity));
56
+ if (resizes !== r || mutations !== m || moving || document.fonts?.status === "loading") return settle(max);
57
+ return { rest: true };
58
+ }
59
+
60
+ const visible = (el) => !!el && el.checkVisibility?.({ opacityProperty: true, visibilityProperty: true }) !== false;
61
+
62
+ // The part of a box that its clipping ancestors leave visible, up to (not including) stop, and the first ancestor
63
+ // that cut it along each axis (and whether that one scrolls along it)
64
+ function clipped(box, from, stop) {
65
+
66
+ let { left, top, right, bottom } = box;
67
+ let byX = null;
68
+ let byY = null;
69
+
70
+ for (let a = from; a && a !== stop && a.nodeType === 1; a = a.parentElement) {
71
+ const s = getComputedStyle(a);
72
+ const paint = s.clipPath !== "none" || /paint|strict|content/.test(s.contain);
73
+ if (s.overflowX === "visible" && s.overflowY === "visible" && !paint) continue;
74
+ // overflow on the root or the body clips at the edges of the page, not at the body's box
75
+ const root = a === document.documentElement || a === document.body;
76
+ const k = root ? { left: -scrollX, top: -scrollY, right: document.documentElement.clientWidth - scrollX, bottom: document.documentElement.scrollHeight - scrollY } : a.getBoundingClientRect();
77
+ if (s.overflowX !== "visible" || paint) {
78
+ const [l, r] = [Math.max(left, k.left), Math.min(right, k.right)];
79
+ if (!byX && (l > left + 0.01 || r < right - 0.01)) byX = { el: a, scrolls: /auto|scroll/.test(s.overflowX) };
80
+ left = l;
81
+ right = r;
82
+ }
83
+ if (s.overflowY !== "visible" || paint) {
84
+ const [t, b] = [Math.max(top, k.top), Math.min(bottom, k.bottom)];
85
+ if (!byY && (t > top + 0.01 || b < bottom - 0.01)) byY = { el: a, scrolls: /auto|scroll/.test(s.overflowY) };
86
+ top = t;
87
+ bottom = b;
88
+ }
89
+ }
90
+
91
+ return { left, top, right, bottom, byX, byY, empty: right - left < 0.5 || bottom - top < 0.5 };
92
+ }
93
+
94
+ // Whether what clipping leaves of a text's box shows it (2px or more both ways): screen-reader text, cut to 1px or to
95
+ // nothing by its own box, does not
96
+ const shows = (v) => !v.empty && v.right - v.left >= 2 && v.bottom - v.top >= 2;
97
+
98
+ const describe = (el) => {
99
+ if (!el || el.nodeType !== 1) return "";
100
+ const cls = [...el.classList].filter((c) => !/^rhp-s/.test(c)).slice(0, 3).map((c) => "." + c).join("");
101
+ const block = [...el.classList].map((c) => BLOCK[c]).find(Boolean);
102
+ return block ? `${block}${cls ? ` (${cls})` : ""}` : `<${el.localName}${el.id ? "#" + el.id : ""}>${cls ? ` (${cls})` : ""}`;
103
+ };
104
+
105
+ const matches = (el, selector) => {
106
+ try {
107
+ return el.matches(selector);
108
+ } catch {
109
+ return false;
110
+ }
111
+ };
112
+
113
+ // The style rules of some sheets that apply now (their @media and @supports conditions met), with the cascade layer
114
+ // each is in and the properties it declares (custom properties left out)
115
+ function styleRules(sheets) {
116
+
117
+ const out = [];
118
+ const walk = (rules, layer) => {
119
+ for (const r of rules) {
120
+ if (r instanceof CSSLayerBlockRule) walk(r.cssRules, layer ? layer + "." + r.name : r.name);
121
+ else if (r instanceof CSSMediaRule && matchMedia(r.media.mediaText).matches) walk(r.cssRules, layer);
122
+ else if (r instanceof CSSSupportsRule && CSS.supports(r.conditionText)) walk(r.cssRules, layer);
123
+ else if (r instanceof CSSStyleRule) {
124
+ const props = [];
125
+ for (let i = 0; i < r.style.length; i++) {
126
+ if (!r.style[i].startsWith("--")) props.push(r.style[i]);
127
+ }
128
+ out.push({ rule: r, selector: r.selectorText, layer: layer ?? "", props });
129
+ }
130
+ }
131
+ };
132
+
133
+ for (const sheet of sheets) {
134
+ try {
135
+ walk(sheet.cssRules, null);
136
+ } catch {
137
+ // a sheet from another origin can't be read
138
+ }
139
+ }
140
+
141
+ return out;
142
+ }
143
+
144
+ // rhp's own rules: its sheets adopted by the document, each rule in one of its layers (rhp.core, rhp.slat, rhp.place).
145
+ // Every declaration in them is !important, so no page rule beats them.
146
+ const ownRules = () => styleRules(document.adoptedStyleSheets ?? []).filter((r) => /^rhp(\.|$)/.test(r.layer));
147
+
148
+ // The text of an element that a reader sees, with a space between its parts ("English" and "Anglais" in two spans
149
+ // read "English Anglais"): not what is hidden (display: none, a phone's short name beside the long one) nor
150
+ // screen-reader text (cut to 1px by its own box)
151
+ const seenText = (el) => {
152
+ const parts = [];
153
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
154
+ const range = document.createRange();
155
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
156
+ if (!node.data.trim() || !visible(node.parentElement)) continue;
157
+ range.selectNodeContents(node);
158
+ if ([...range.getClientRects()].some((r) => shows(clipped(r, node.parentElement, null)))) parts.push(node.data.trim());
159
+ }
160
+ return parts.join(" ");
161
+ };
162
+
163
+ const snippet = (text) => {
164
+ const t = text.replace(/\s+/g, " ").trim();
165
+ return t.length > 40 ? t.slice(0, 39) + "…" : t;
166
+ };
167
+
168
+ // The text a screen reader reads in an element: its text and its images' alt, but not what is hidden from it
169
+ // (aria-hidden, hidden, display: none) nor the controls inside a label; an element with an aria-label reads as that,
170
+ // and parts that are not inline are read apart
171
+ function spoken(root) {
172
+
173
+ const parts = [];
174
+ const walk = (node) => {
175
+ if (node.nodeType === 3) parts.push(node.data);
176
+ if (node.nodeType !== 1) return;
177
+ if (node !== root && (node.getAttribute("aria-hidden") === "true" || /^(input|select|textarea|button)$/.test(node.localName) || node.checkVisibility?.({ visibilityProperty: true }) === false)) return;
178
+ const label = node !== root && node.getAttribute("aria-label")?.trim();
179
+ const apart = node !== root && !/^inline/.test(getComputedStyle(node).display) ? " " : "";
180
+ parts.push(apart);
181
+ if (label || node.localName === "img") parts.push(" ", label || node.alt || "", " ");
182
+ else node.childNodes.forEach(walk);
183
+ parts.push(apart);
184
+ };
185
+
186
+ walk(root);
187
+
188
+ return parts.join("").replace(/\s+/g, " ").trim();
189
+ }
190
+
191
+ // A control's accessible name, as a screen reader gets it (simplified): aria-labelledby, aria-label, its labels, its
192
+ // own text (a button), its value (an input button), title, placeholder
193
+ function nameOf(el) {
194
+
195
+ const ids = (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).map((id) => id && document.getElementById(id)).filter(Boolean);
196
+ const own = /^(button|summary)$/.test(el.localName) || /^(button|switch|tab|radio|checkbox)$/.test(el.getAttribute("role") ?? "");
197
+ const names = [
198
+ ids.map(spoken).join(" "),
199
+ el.getAttribute("aria-label"),
200
+ [...(el.labels ?? [])].map(spoken).join(" "),
201
+ own ? spoken(el) : "",
202
+ el.localName === "input" && /^(button|submit|reset)$/.test(el.type) ? el.value : "",
203
+ el.localName === "input" && el.type === "image" ? el.alt : "",
204
+ el.getAttribute("title"),
205
+ el.getAttribute("placeholder"),
206
+ ];
207
+
208
+ return names.map((n) => (n ?? "").replace(/\s+/g, " ").trim()).find(Boolean) ?? "";
209
+ }
210
+
211
+ // A CSS color as [r, g, b, a] in sRGB (0 to 255, a 0 to 1), through a canvas so every color space works
212
+ const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
213
+ const colors = new Map();
214
+ function rgba(css) {
215
+
216
+ if (colors.has(css)) return colors.get(css);
217
+
218
+ ink.clearRect(0, 0, 1, 1);
219
+ ink.fillStyle = "#000";
220
+ ink.fillStyle = css;
221
+ ink.fillRect(0, 0, 1, 1);
222
+ // (ImageData is not premultiplied: its channels are the color's own)
223
+ const [r, g, b, a] = ink.getImageData(0, 0, 1, 1).data;
224
+ const out = a ? [r, g, b, a / 255] : [0, 0, 0, 0];
225
+ colors.set(css, out);
226
+
227
+ return out;
228
+ }
229
+
230
+ const opacityOf = (el) => {
231
+ let o = 1;
232
+ for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
233
+ o *= parseFloat(getComputedStyle(a).opacity) || 0;
234
+ }
235
+ return o;
236
+ };
237
+
238
+ // How tall a font's line box and its glyphs are, from canvas metrics (cached per font and text)
239
+ const metrics = new Map();
240
+ function fontBox(style, text) {
241
+
242
+ const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
243
+ const key = font + "\n" + text;
244
+ if (metrics.has(key)) return metrics.get(key);
245
+
246
+ ink.font = font;
247
+ const m = ink.measureText(text);
248
+ const out = { ascent: m.fontBoundingBoxAscent, descent: m.fontBoundingBoxDescent, inkAscent: m.actualBoundingBoxAscent, inkDescent: m.actualBoundingBoxDescent };
249
+ metrics.set(key, out);
250
+
251
+ return out;
252
+ }
253
+
254
+ // How an element is turned and scaled by its own and its ancestors' transforms (kept per element)
255
+ const turns = new Map();
256
+ function turnOf(el) {
257
+
258
+ if (!el || el.nodeType !== 1) return { turned: false, scale: 1 };
259
+ if (turns.has(el)) return turns.get(el);
260
+
261
+ const up = turnOf(el.parentElement);
262
+ const s = getComputedStyle(el);
263
+ let turned = up.turned || /vertical|sideways/.test(s.writingMode);
264
+ let scale = up.scale;
265
+
266
+ if (s.transform !== "none") {
267
+ const m = new DOMMatrixReadOnly(s.transform);
268
+ turned ||= Math.abs(m.b) > 1e-3 || Math.abs(m.c) > 1e-3;
269
+ scale *= Math.hypot(m.a, m.b);
270
+ }
271
+ if (s.rotate !== "none" && !/^0(deg|rad|grad|turn)?$/.test(s.rotate)) turned = true;
272
+ if (s.scale !== "none") scale *= parseFloat(s.scale) || 1;
273
+
274
+ const out = { turned, scale };
275
+ turns.set(el, out);
276
+
277
+ return out;
278
+ }
279
+
280
+ // The box of each character of a text (a turned text's bounding boxes are much larger than its glyphs)
281
+ function characters(node) {
282
+
283
+ const out = [];
284
+ const range = document.createRange();
285
+
286
+ for (let i = 0; i < Math.min(node.data.length, 300); i++) {
287
+ if (!node.data[i].trim()) continue;
288
+ range.setStart(node, i);
289
+ range.setEnd(node, i + 1);
290
+ const r = range.getBoundingClientRect();
291
+ if (r.width > 0 && r.height > 0) out.push(r);
292
+ }
293
+
294
+ return out;
295
+ }
296
+
297
+ // Every visible text in the page: its fragments (one per line, or one per character when it is turned), the part of
298
+ // each its clipping ancestors leave, and the box its glyphs fill
299
+ function texts() {
300
+
301
+ const out = [];
302
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
303
+
304
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
305
+ const text = node.data;
306
+ if (!text.trim()) continue;
307
+ const el = node.parentElement;
308
+ if (!el || SKIP_TEXT.has(el.tagName) || el.closest("svg, select, textarea")) continue;
309
+ if (!visible(el)) continue;
310
+
311
+ const range = document.createRange();
312
+ range.selectNodeContents(node);
313
+ const style = getComputedStyle(el);
314
+ const size = parseFloat(style.fontSize);
315
+ const fb = fontBox(style, text.trim());
316
+ const content = fb.ascent + fb.descent;
317
+ const turn = turnOf(el);
318
+ const fragments = [];
319
+
320
+ for (const r of turn.turned ? characters(node) : range.getClientRects()) {
321
+ if (r.width < 0.5 || r.height < 0.5) continue;
322
+ const v = clipped(r, el, null);
323
+ const scale = turn.turned || !(content > 0) ? turn.scale : r.height / content;
324
+ const w = r.right - r.left;
325
+ const h = r.bottom - r.top;
326
+ const glyphs = turn.turned
327
+ ? { left: r.left + w * 0.15, right: r.right - w * 0.15, top: r.top + h * 0.15, bottom: r.bottom - h * 0.15 }
328
+ : { left: r.left, right: r.right, top: r.top + Math.max(0, fb.ascent - fb.inkAscent) * scale, bottom: Math.min(r.bottom, r.top + (fb.ascent + fb.inkDescent) * scale) };
329
+ fragments.push({ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, visible: v, glyphs, scale });
330
+ }
331
+
332
+ if (!fragments.length) continue;
333
+ const shown = fragments.filter((f) => shows(f.visible));
334
+ out.push({ node, el, text: snippet(text), size, effective: size * Math.min(...fragments.map((f) => f.scale)), fragments, shown, style, turned: turn.turned });
335
+ }
336
+
337
+ return out;
338
+ }
339
+
340
+ const inside = (a, b) => a === b || a.contains(b) || b.contains(a);
341
+
342
+ // What the page shows at this width: the charts, and every problem the probes can see in the DOM
343
+ function measure() {
344
+
345
+ const found = [];
346
+ const add = (f) => found.push(f);
347
+ const doc = document.documentElement;
348
+ const all = [...document.querySelectorAll(".rhp-chart")];
349
+ const charts = all.filter((c) => !c.parentElement?.closest(".rhp-chart"));
350
+
351
+ const summary = charts.map((c) => {
352
+ const box = c.getBoundingClientRect();
353
+ const plots = [...c.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale"));
354
+ const blocks = {};
355
+ for (const el of c.querySelectorAll(Object.keys(BLOCK).map((k) => "." + k).join(","))) {
356
+ if (el.closest(".rhp-scale") || el.closest("[hidden]")) continue;
357
+ const name = BLOCK[[...el.classList].find((k) => BLOCK[k])].toLowerCase();
358
+ blocks[name] = (blocks[name] ?? 0) + 1;
359
+ }
360
+ const slats = plots.reduce((n, p) => n + [...p.children].filter((s) => !s.hidden).length, 0);
361
+ return { slats, blocks, box: { width: Math.round(box.width), height: Math.round(box.height) }, orientation: c.dataset.rhpO === "v" ? "vertical" : "horizontal" };
362
+ });
363
+
364
+ // Plots drawn outside any Chart get no scale and none of rhp's CSS
365
+ const lost = [...document.querySelectorAll(".rhp-plot")].filter((p) => !p.closest(".rhp-chart"));
366
+ if (lost.length) add({ code: "plot-outside-chart", count: lost.length });
367
+ if (!charts.length && !lost.length) add({ code: "no-chart" });
368
+
369
+ // A chart in a box that shrinks to its content (a flex item, an inline-block, a float, width: fit-content) is only
370
+ // as wide as its room, with nothing left for the plot. What is drawn in it then is no use to measure.
371
+ const collapsed = new Set();
372
+ for (const c of charts) {
373
+ const plots = [...c.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale"));
374
+ if (!plots.length || !plots.every((p) => p.getBoundingClientRect().width < 2)) continue;
375
+ collapsed.add(c);
376
+ // the box that shrinks to fit: an item of a flex row, an inline-block or a float
377
+ let why = "";
378
+ for (let a = c; a && a !== document.body && !why; a = a.parentElement) {
379
+ const s = getComputedStyle(a);
380
+ if (/flex/.test(getComputedStyle(a.parentElement ?? a).display)) why = a === c ? `it is an item of the flex row ${describe(a.parentElement)}` : `it sits in ${describe(a)}, an item of a flex row`;
381
+ else if (/inline/.test(s.display)) why = `it sits in ${describe(a)}, an inline-block`;
382
+ else if (s.float !== "none") why = `it sits in ${describe(a)}, a float`;
383
+ }
384
+ add({ code: "collapsed-chart", width: Math.round(c.getBoundingClientRect().width), why });
385
+ }
386
+
387
+ for (const c of charts) {
388
+ if (collapsed.has(c)) continue;
389
+ const plots = [...c.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale"));
390
+ // an overlap Plot may be empty (a pointer's readout, drawn only while pointing); a chart with nothing in any Plot,
391
+ // or a Plot of slats with none, draws no data
392
+ const empty = (p) => ![...p.children].some((s) => !s.hidden);
393
+ if (plots.length && (plots.every(empty) || plots.some((p) => empty(p) && !p.hasAttribute("data-rhp-overlap")))) add({ code: "empty-plot", chart: describe(c) });
394
+ for (const p of plots) {
395
+ const shown = [...p.children].filter((s) => !s.hidden);
396
+ if (!shown.length) continue;
397
+ const r = p.getBoundingClientRect();
398
+ const vertical = p.dataset.rhpO === "v";
399
+ const length = vertical ? r.height : r.width;
400
+ const first = shown[0].getBoundingClientRect();
401
+ const band = p.hasAttribute("data-rhp-overlap") ? Infinity : vertical ? first.width : first.height;
402
+ // (slats as thin as 3px are fine: a histogram's many bins, or the per-year slats a line chart is pointed through)
403
+ if (length < 40 || band < 2) {
404
+ add({ code: "small-plot", vertical, thin: band < 2, length: Math.round(length), band: Math.round(band * 10) / 10, chart: describe(c) });
405
+ } else if (!vertical && band < 18 && shown.filter((s) => [...s.querySelectorAll(".rhp-label")].some((l) => visible(l) && l.textContent.trim())).length * 2 > shown.length) {
406
+ // a horizontal chart whose slats are too thin for the text most of them hold (one tag on one slat fits)
407
+ add({ code: "cramped", vertical: false, band: Math.round(band * 10) / 10, chart: describe(c) });
408
+ }
409
+ }
410
+ }
411
+
412
+ // CSS variables that came out as NaN or undefined, and values past the Chart's scale
413
+ const VALUE = { "--rhp-from": "from", "--rhp-to": "to", "--rhp-at": "at", "--rhp-value": "value", "--rhp-size": "size", "--rhp-across": "across", "--rhp-cross": "cross", "--rhp-min": "scale", "--rhp-max": "scale", "--rhp-thick": "thick" };
414
+ for (const c of all) {
415
+ for (const el of [c, ...c.querySelectorAll("[style]")]) {
416
+ for (let i = 0; i < el.style.length; i++) {
417
+ const name = el.style[i];
418
+ if (!name.startsWith("--rhp-")) continue;
419
+ const value = el.style.getPropertyValue(name).trim();
420
+ if (/(^|[^\w-])(NaN|undefined|Infinity)([^\w-]|$)/.test(value)) {
421
+ const block = el === c ? "Chart" : [...el.classList].map((k) => BLOCK[k]).find(Boolean) ?? describe(el);
422
+ add({ code: "nan-value", block, prop: VALUE[name] ?? name, value });
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ // Values outside the Chart's scale (or cross scale), Labels placed with at included. They are compared whatever
429
+ // the block's size: a Bar the scale cuts to nothing has none. Left out: a block with no box (display: none), and
430
+ // one with a size that clipping hides (a slat may show a window of its marks, and clip the rest).
431
+ const past = new Map();
432
+ const outside = (key, f) => {
433
+ const off = (p) => Math.abs(p.value - (p.value > p.max ? p.max : p.min));
434
+ const had = past.get(key);
435
+ past.set(key, { ...(had && off(had) >= off(f) ? had : f), count: (had?.count ?? 0) + 1 });
436
+ };
437
+ for (const el of document.querySelectorAll(".rhp-bar, .rhp-dot, .rhp-tick, .rhp-place, .rhp-area, .rhp-line, .rhp-label[data-rhp-at]")) {
438
+ if (el.closest("[hidden], .rhp-scale") || !el.getClientRects().length) continue;
439
+ const c = el.closest(".rhp-chart");
440
+ if (!c) continue;
441
+ const box = el.getBoundingClientRect();
442
+ if (box.width * box.height > 0 && clipped(box, el.parentElement, null).empty) continue;
443
+ const num = (e, k) => parseFloat(e.style.getPropertyValue(k));
444
+ const block = BLOCK[[...el.classList].find((k) => BLOCK[k])];
445
+ const vertical = el.dataset.rhpO === "v";
446
+ // (a reversed or equal scale is reported as bad-scale, and no value is inside it)
447
+ const min = num(c, "--rhp-min");
448
+ const max = num(c, "--rhp-max");
449
+ if (!(min < max)) continue;
450
+ const eps = (max - min) * 1e-6;
451
+ // (a Bar wholly past one end of the scale is cut to nothing; a Label is kept at the end a horizontal chart ends
452
+ // at, or a vertical one starts at, and runs out of the plot at the other)
453
+ const ends = [num(el, "--rhp-from"), num(el, "--rhp-to")];
454
+ const gone = block === "Bar" && (Math.max(...ends) <= min + eps || Math.min(...ends) >= max - eps);
455
+ for (const v of ["--rhp-from", "--rhp-to", "--rhp-at"].map((k) => num(el, k)).filter(Number.isFinite)) {
456
+ if (v < min - eps || v > max + eps) outside(block, { block, value: v, min, max, gone, kept: block === "Label" && (vertical ? v < min : v > max) });
457
+ }
458
+ const cmin = num(c, "--rhp-cross-min");
459
+ const cmax = num(c, "--rhp-cross-max");
460
+ if (cmin < cmax) {
461
+ const ceps = (cmax - cmin) * 1e-6;
462
+ for (const v of ["--rhp-cross", "--rhp-cross-from", "--rhp-cross-to"].map((k) => num(el, k)).filter(Number.isFinite)) {
463
+ if (v < cmin - ceps || v > cmax + ceps) outside(block + " cross", { block, value: v, min: cmin, max: cmax, cross: true, kept: block === "Label" && (vertical ? v > cmax : v < cmin) });
464
+ }
465
+ }
466
+ }
467
+ // (an Area or a Line is squeezed into the scale rather than cut, so it has a code of its own)
468
+ for (const p of past.values()) {
469
+ add({ code: /Area|Line/.test(p.block) ? "points-past-scale" : "value-past-scale", ...p });
470
+ }
471
+
472
+ const list = texts();
473
+ const inCollapsed = (el) => [...collapsed].some((c) => c.contains(el));
474
+
475
+ // Marks and labels outside their chart's box (only the part clipping leaves visible counts), and every text in the
476
+ // chart by its glyphs (axis numbers included), since a text can run past its own element
477
+ for (const c of charts) {
478
+ if (collapsed.has(c)) continue;
479
+ const box = c.getBoundingClientRect();
480
+ let worst = null;
481
+ const out = new Set();
482
+ const beyond = (v, el, text) => {
483
+ const by = { right: v.right - box.right, left: box.left - v.left, bottom: v.bottom - box.bottom, top: box.top - v.top };
484
+ const side = Object.keys(by).reduce((a, k) => (by[k] > by[a] ? k : a));
485
+ if (by[side] <= 1) return false;
486
+ out.add(el);
487
+ if (!worst || by[side] > worst.by) worst = { by: Math.round(by[side] * 10) / 10, side, what: describe(el), text: snippet(text), axis: !!el.closest(".rhp-axis"), label: !!el.closest(".rhp-label"), edge: !!el.closest(".rhp-label[data-rhp-edge]"), vertical: c.dataset.rhpO === "v" };
488
+ return true;
489
+ };
490
+ for (const e of c.querySelectorAll("*")) {
491
+ const b = e.getBoundingClientRect();
492
+ if (!b.width && !b.height) continue;
493
+ if (!visible(e) || getComputedStyle(e).position === "fixed") continue;
494
+ const v = clipped(b, e.parentElement, c);
495
+ if (v.right < v.left || v.bottom < v.top) continue;
496
+ beyond(v, e, e.textContent ?? "");
497
+ }
498
+ for (const t of list) {
499
+ if (!c.contains(t.el)) continue;
500
+ for (const f of t.shown) {
501
+ const v = clipped(f.rect, t.el, c);
502
+ const g = { left: Math.max(f.glyphs.left, v.left), right: Math.min(f.glyphs.right, v.right), top: Math.max(f.glyphs.top, v.top), bottom: Math.min(f.glyphs.bottom, v.bottom) };
503
+ if (g.right - g.left >= 0.5 && g.bottom - g.top >= 0.5 && beyond(g, t.el, t.node.data)) break;
504
+ }
505
+ }
506
+ if (worst) add({ code: "sticks-out", ...worst, count: out.size, chart: describe(c) });
507
+ }
508
+
509
+ // NaN, undefined or Infinity printed in a chart: a value that was missing, or worked out from nothing
510
+ for (const t of list) {
511
+ if (t.el.closest(".rhp-chart") && /(^|[^\w$])(NaN|undefined|Infinity)(?![\w$])/.test(t.node.data)) add({ code: "nan-value", printed: true, text: t.text, what: describe(t.el) });
512
+ }
513
+
514
+ // Text outside its poster
515
+ for (const poster of document.querySelectorAll(".poster")) {
516
+ const box = poster.getBoundingClientRect();
517
+ for (const t of list) {
518
+ if (!poster.contains(t.el)) continue;
519
+ for (const f of t.fragments) {
520
+ const v = clipped(f.rect, t.el, poster);
521
+ if (v.empty) continue;
522
+ const by = Math.max(v.right - box.right, box.left - v.left, v.bottom - box.bottom, box.top - v.top);
523
+ if (by > 1) {
524
+ add({ code: "text-outside-poster", text: t.text, by: Math.round(by), what: describe(t.el) });
525
+ break;
526
+ }
527
+ }
528
+ }
529
+ }
530
+
531
+ // Text cut off by an ancestor's overflow (an ellipsis, a box too small): not by a box that scrolls that way, and
532
+ // not the little a tight line-height takes off the space above and below the glyphs. With text-overflow: ellipsis
533
+ // any overflow cuts letters (the browser draws the ellipsis over them), and the line comes as two fragments, the
534
+ // whole and what is shown.
535
+ // (text with no part shown is hidden on purpose, for screen readers or until it is needed: not cut off)
536
+ for (const t of list) {
537
+ if (inCollapsed(t.el) || !t.shown.length) continue;
538
+ for (const f of t.fragments) {
539
+ const v = f.visible;
540
+ const ellipsis = !!v.byX && getComputedStyle(v.byX.el).textOverflow === "ellipsis";
541
+ const lostX = v.byX && !v.byX.scrolls ? Math.max(0, v.left - f.rect.left) + Math.max(0, f.rect.right - v.right) : 0;
542
+ const lostY = v.byY && !v.byY.scrolls ? Math.max(0, v.top - f.glyphs.top) + Math.max(0, f.glyphs.bottom - v.bottom) : 0;
543
+ if (lostX > (ellipsis ? 0.05 : 1) || lostY > Math.max(1.5, t.size * 0.15)) {
544
+ const by = lostX > (ellipsis ? 0.05 : 1) ? v.byX.el : v.byY.el;
545
+ const width = f.rect.right - f.rect.left;
546
+ const shownWidth = ellipsis && t.fragments.length > 1 ? Math.min(...t.fragments.map((g) => g.rect.right - g.rect.left)) : width - lostX;
547
+ const lost = by === v.byX?.el ? width - shownWidth : lostY;
548
+ add({ code: "text-cut-off", text: t.text, lost: Math.max(1, Math.round(lost)), ellipsis: ellipsis && by === v.byX.el, by: describe(by), edge: !!by.closest(".rhp-label[data-rhp-edge]"), label: !!by.closest(".rhp-label"), vertical: by.closest("[data-rhp-o]")?.dataset.rhpO === "v" });
549
+ break;
550
+ }
551
+ }
552
+ }
553
+
554
+ // The block a text's lines belong to: its nearest ancestor that is not inline (a heading, a paragraph, a label). Two
555
+ // texts of one block are its lines, which a tight line-height brings close without the letters touching.
556
+ const blocks = new Map();
557
+ const blockOf = (el) => {
558
+ if (!blocks.has(el)) blocks.set(el, !el.parentElement || !/^(inline|ruby|contents)/.test(getComputedStyle(el).display) ? el : blockOf(el.parentElement));
559
+ return blocks.get(el);
560
+ };
561
+ const apart = (a, b) => a !== b && !inside(a, b) && blockOf(a) !== blockOf(b);
562
+
563
+ // Text over other text: the glyph boxes of two blocks' texts
564
+ const boxes = [];
565
+ for (const t of list) {
566
+ for (const f of t.shown) {
567
+ const g = { left: Math.max(f.glyphs.left, f.visible.left), right: Math.min(f.glyphs.right, f.visible.right), top: Math.max(f.glyphs.top, f.visible.top), bottom: Math.min(f.glyphs.bottom, f.visible.bottom) };
568
+ if (g.right - g.left > 0.5 && g.bottom - g.top > 0.5) boxes.push({ t, g });
569
+ }
570
+ }
571
+ boxes.sort((a, b) => a.g.left - b.g.left);
572
+ const pairs = new Set();
573
+ for (let i = 0; i < boxes.length; i++) {
574
+ const a = boxes[i];
575
+ for (let j = i + 1; j < boxes.length && boxes[j].g.left < a.g.right; j++) {
576
+ const b = boxes[j];
577
+ if (!apart(a.t.el, b.t.el) || inCollapsed(a.t.el) || inCollapsed(b.t.el)) continue;
578
+ const x = Math.min(a.g.right, b.g.right) - Math.max(a.g.left, b.g.left);
579
+ const y = Math.min(a.g.bottom, b.g.bottom) - Math.max(a.g.top, b.g.top);
580
+ if (x <= 1 || y <= 1) continue;
581
+ const key = [a.t.text, b.t.text].sort().join("\n");
582
+ if (pairs.has(key)) continue;
583
+ pairs.add(key);
584
+ add({ code: "text-overlap", a: a.t.text, b: b.t.text, x: Math.round(x), y: Math.round(y), whatA: describe(a.t.el), whatB: describe(b.t.el) });
585
+ }
586
+ }
587
+
588
+ // Names in an overlap Plot on a cross axis (a scatter, a bubble chart) that a reader may take for another slat's: a
589
+ // Label's text drawn over another slat's Dot by more than 1px, or another slat's Dot nearer the middle line of the
590
+ // text than the Label's own Dot is to the text (a Dot beside the text on its line is read as what it names; one
591
+ // past a corner of the text is a neighbor)
592
+ const gap = (g, d) => Math.hypot(Math.max(g.left, Math.min(d.x, g.right)) - d.x, Math.max(g.top, Math.min(d.y, g.bottom)) - d.y) - d.r;
593
+ const fromLine = (g, d) => Math.hypot(Math.max(g.left, Math.min(d.x, g.right)) - d.x, (g.top + g.bottom) / 2 - d.y) - d.r;
594
+ for (const c of charts) {
595
+ if (collapsed.has(c) || !(parseFloat(c.style.getPropertyValue("--rhp-cross-min")) < parseFloat(c.style.getPropertyValue("--rhp-cross-max")))) continue;
596
+ let worst = null;
597
+ let count = 0;
598
+ for (const p of c.querySelectorAll(":scope > .rhp-body > .rhp-plot[data-rhp-overlap]:not(.rhp-scale)")) {
599
+ const slats = [...p.children].filter((s) => !s.hidden);
600
+ const dots = slats.flatMap((s) => [...s.querySelectorAll(".rhp-dot")].filter(visible).map((el) => {
601
+ const r = el.getBoundingClientRect();
602
+ return { s, x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2, r: Math.min(r.width, r.height) / 2 };
603
+ })).filter((d) => d.r > 0);
604
+ for (const s of slats) {
605
+ const own = dots.filter((d) => d.s === s);
606
+ if (!own.length) continue;
607
+ for (const label of s.querySelectorAll(".rhp-label")) {
608
+ const glyphs = boxes.filter((x) => label.contains(x.t.el)).map((x) => x.g);
609
+ if (!glyphs.length) continue;
610
+ const mine = Math.min(...glyphs.flatMap((g) => own.map((d) => gap(g, d))));
611
+ let found = null;
612
+ for (const d of dots) {
613
+ if (d.s === s) continue;
614
+ const over = -Math.min(...glyphs.map((g) => gap(g, d)));
615
+ const near = Math.min(...glyphs.map((g) => fromLine(g, d)));
616
+ // drawn over it first, the deepest; then the nearest past its own
617
+ const score = over > 1 ? 1000 + over : near < mine ? mine - near : -1;
618
+ if (score > (found?.score ?? 0)) found = { score, over: over > 1, d, near };
619
+ }
620
+ if (!found) continue;
621
+ count++;
622
+ if (!worst || found.score > worst.score) worst = { ...found, text: snippet(seenText(label)), other: snippet(seenText([...found.d.s.querySelectorAll(".rhp-label")].find((l) => seenText(l)) ?? found.d.s)), mine };
623
+ }
624
+ }
625
+ }
626
+ if (worst) add({ code: "ambiguous-label", text: worst.text, other: worst.other, over: worst.over, near: Math.max(0, Math.round(worst.near)), own: Math.round(worst.mine), count });
627
+ }
628
+
629
+ // A vertical chart left at the default height (240px) whose slats' labels crowd each other
630
+ for (const c of charts) {
631
+ if (collapsed.has(c) || c.dataset.rhpO !== "v" || c.hasAttribute("data-rhp-aspect") || c.style.getPropertyValue("--rhp-height").trim() !== "240px") continue;
632
+ // the labels' glyph boxes, each with the slat of the chart's own Plot it is in (stacked parts are slats of a Plot
633
+ // inside it)
634
+ const labels = boxes.filter((x) => c.contains(x.t.el) && x.t.el.closest(".rhp-label")).map((x) => ({ ...x, slat: x.t.el.closest(".rhp-body > .rhp-plot > *") }));
635
+ const crowded = labels.some((a, i) => labels.some((b, j) => j > i && a.slat && a.slat === b.slat && apart(a.t.el, b.t.el)
636
+ && Math.min(a.g.right, b.g.right) - Math.max(a.g.left, b.g.left) > 1 && Math.min(a.g.bottom, b.g.bottom) - Math.max(a.g.top, b.g.top) > 1));
637
+ if (crowded) add({ code: "cramped", vertical: true, chart: describe(c) });
638
+ }
639
+
640
+ const own = ownRules();
641
+
642
+ // Colors series-n past the theme's series: rhp has no color for them, and they fall back to series-1
643
+ for (const c of charts) {
644
+ const cs = getComputedStyle(c);
645
+ let have = 0;
646
+ while (cs.getPropertyValue(`--rhp-series-${have + 1}`).trim()) {
647
+ have++;
648
+ }
649
+ const past = new Map();
650
+ for (const el of c.querySelectorAll("[style*='--rhp-color']")) {
651
+ const n = +(el.style.getPropertyValue("--rhp-color").match(/var\(--rhp-series-(\d+)\)/)?.[1] ?? 0);
652
+ if (n > have && !getComputedStyle(el).getPropertyValue(`--rhp-series-${n}`).trim()) past.set(n, (past.get(n) ?? 0) + 1);
653
+ }
654
+ if (past.size) add({ code: "series-past-theme", keys: [...past.keys()].sort((a, b) => a - b), count: [...past.values()].reduce((a, b) => a + b, 0), have });
655
+ }
656
+
657
+ // Page CSS that reaches into a chart and sets what rhp's own CSS sets there: it loses, so it changes nothing
658
+ const byProp = new Map();
659
+ for (const r of own) {
660
+ for (const p of r.props) {
661
+ if (!byProp.has(p)) byProp.set(p, []);
662
+ byProp.get(p).push(r.selector);
663
+ }
664
+ }
665
+ const pageSheets = [...document.styleSheets].filter((s) => !s.ownerNode?.matches?.("[data-rhp-layers], [data-rhp-server]"));
666
+ for (const r of styleRules(pageSheets)) {
667
+ if (/^rhp(\.|$)/.test(r.layer) || !r.props.length) continue;
668
+ let matched = [];
669
+ try {
670
+ matched = [...document.querySelectorAll(r.selector)];
671
+ } catch {
672
+ continue;
673
+ }
674
+ const reached = matched.filter((el) => el.closest(".rhp-chart")).slice(0, 60);
675
+ if (!reached.length) continue;
676
+ // only a rule aimed at the chart: one that names rhp's classes, or one with a class, an id or an attribute on its
677
+ // subject that reaches only elements in charts (a reset such as svg { display: block } is not aimed at it)
678
+ const subjects = r.selector.split(/,(?![^(]*\))/).map((x) => x.trim().split(/\s*[\s>+~]\s*(?![^(]*\))/).pop());
679
+ if (!/\.rhp-/.test(r.selector) && !(reached.length === matched.length && subjects.every((x) => /[.#[]/.test(x)))) continue;
680
+ const dead = r.props.filter((p) => byProp.has(p) && reached.every((el) => byProp.get(p).some((sel) => matches(el, sel))));
681
+ if (!dead.length) continue;
682
+ const family = (p) => p.match(/^(background|border|margin|padding|font|outline|text-decoration|transition|animation|inset|overflow|grid|flex|columns?|list-style)/)?.[1] ?? p;
683
+ add({ code: "page-css-ignored", selector: snippet(r.selector), props: [...new Set(dead.map(family))], what: describe(reached[0]), count: reached.length });
684
+ }
685
+
686
+ // Text too small to read
687
+ let tiny = null;
688
+ let tinyCount = 0;
689
+ for (const t of list) {
690
+ if (!t.shown.length || t.effective >= 9) continue;
691
+ tinyCount++;
692
+ if (!tiny || t.effective < tiny.size) tiny = { text: t.text, size: Math.round(t.effective * 10) / 10, what: describe(t.el) };
693
+ }
694
+ if (tiny) add({ code: "tiny-text", ...tiny, count: tinyCount });
695
+
696
+ // The page scrolling sideways, and the outermost element that reaches furthest past it
697
+ const overflow = doc.scrollWidth - doc.clientWidth;
698
+ if (overflow > 1) {
699
+ let widest = null;
700
+ for (const e of document.body.querySelectorAll("*")) {
701
+ const r = e.getBoundingClientRect();
702
+ if (r.right > doc.clientWidth + 1 && visible(e) && (!widest || r.right > widest.right + 0.5)) widest = { el: e, right: r.right, width: r.width };
703
+ }
704
+ add({ code: "page-scrolls-sideways", by: Math.round(overflow), what: widest ? describe(widest.el) : "", width: widest ? Math.round(widest.width) : 0, viewport: doc.clientWidth });
705
+ }
706
+
707
+ // Controls a screen reader can only call "button" or "slider": no text, aria-label, aria-labelledby or label
708
+ const unnamed = [...document.querySelectorAll(CONTROLS)].filter((el) => visible(el) && !nameOf(el));
709
+ if (unnamed.length) add({ code: "unnamed-control", what: describe(unnamed[0]), count: unnamed.length });
710
+
711
+ // Fonts the page asks for that are not there: a web font none of whose faces loaded (a family split by
712
+ // unicode-range, as Google Fonts serves CJK ones, loads only the faces its text needs, and document.fonts.check()
713
+ // says false while another face is unused), or a font named in a stack that this machine doesn't have (it measures
714
+ // like the fallbacks)
715
+ const familyOf = (f) => f.family.replace(/^["']|["']$/g, "");
716
+ const faces = [...(document.fonts ?? [])];
717
+ const declared = new Set(faces.map(familyOf));
718
+ const loaded = new Set(faces.filter((f) => f.status === "loaded").map(familyOf));
719
+ const missing = new Map();
720
+ const tried = new Set();
721
+ for (const t of list) {
722
+ const family = t.style.fontFamily.split(",")[0].trim().replace(/^["']|["']$/g, "");
723
+ if (!family || GENERIC.test(family) || loaded.has(family)) continue;
724
+ const font = `${t.style.fontStyle} ${t.style.fontWeight} 16px "${family}"`;
725
+ if (tried.has(font)) continue;
726
+ tried.add(font);
727
+ if (declared.has(family)) {
728
+ missing.set(family, faces.some((f) => familyOf(f) === family && f.status === "error"));
729
+ continue;
730
+ }
731
+ const probe = "mmmmmmmmmmlli1WW@#";
732
+ const absent = ["monospace", "serif"].every((fallback) => {
733
+ ink.font = `${t.style.fontStyle} ${t.style.fontWeight} 72px ${fallback}`;
734
+ const base = ink.measureText(probe).width;
735
+ ink.font = `${t.style.fontStyle} ${t.style.fontWeight} 72px "${family}", ${fallback}`;
736
+ return ink.measureText(probe).width === base;
737
+ });
738
+ if (absent) missing.set(family, false);
739
+ }
740
+ for (const [family, failed] of missing) {
741
+ add({ code: "font-not-loaded", family, failed });
742
+ }
743
+
744
+ return { found, charts: summary };
745
+ }
746
+
747
+ // A transition the slat's CSS sets on a block itself replaces rhp's (left and width, or bottom and height, or an
748
+ // outline's d), so the block jumps to new values. Not in the JS version (nothing is transitioned there), not with
749
+ // transition: none (a choice), and not when the slat's CSS places the block itself (a radial layout). It is read
750
+ // with motion on: with reduced motion, rhp turns every block's transition off.
751
+ function transitions() {
752
+
753
+ const found = [];
754
+ const own = ownRules();
755
+ const PLACE = /^(left|right|top|bottom|width|height|inset)/;
756
+ const jumps = new Map();
757
+
758
+ for (const el of document.querySelectorAll(".rhp-bar, .rhp-dot, .rhp-tick, .rhp-label[data-rhp-at], .rhp-place, .rhp-area, .rhp-line, .rhp-cell, .rhp-area path, .rhp-line path")) {
759
+ if (!el.closest(".rhp-chart") || el.closest("[data-rhp-animate='js'], [data-rhp-turning], .rhp-scale, [hidden]")) continue;
760
+ const s = getComputedStyle(el);
761
+ const names = s.transitionProperty.split(/\s*,\s*/);
762
+ const times = s.transitionDuration.split(/\s*,\s*/).map((x) => parseFloat(x) || 0);
763
+ const live = names.filter((n, i) => n !== "none" && times[i % times.length] > 0);
764
+ if (!live.length || live.includes("all")) continue;
765
+ const path = el.localName === "path";
766
+ const block = BLOCK[[...(path ? el.closest(".rhp-area, .rhp-line") : el).classList].find((k) => BLOCK[k])];
767
+ const vertical = el.closest("[data-rhp-o]")?.dataset.rhpO === "v";
768
+ const span = block === "Bar" || block === "Area" || block === "Line";
769
+ const needs = path ? ["d"] : block === "Cell" ? ["background-color"] : vertical ? (span ? ["bottom", "height"] : ["bottom"]) : span ? ["left", "width"] : ["left"];
770
+ const dropped = needs.filter((p) => !live.includes(p));
771
+ if (!dropped.length) continue;
772
+ if (!path && own.some((r) => r.layer === "rhp.slat" && r.props.some((p) => PLACE.test(p)) && matches(el, r.selector))) continue;
773
+ const key = block + (path ? " path" : "") + "\n" + s.transitionProperty;
774
+ jumps.set(key, { block, path, set: live.join(", "), dropped, what: describe(path ? el.parentElement : el), count: (jumps.get(key)?.count ?? 0) + 1 });
775
+ }
776
+
777
+ for (const j of jumps.values()) {
778
+ found.push({ code: "block-transition", ...j });
779
+ }
780
+
781
+ return found;
782
+ }
783
+
784
+ // A box-shadow's layers that reach out of the box (not inset), with their colors
785
+ function shadows(value) {
786
+
787
+ const out = [];
788
+ if (!value || value === "none") return out;
789
+
790
+ for (const layer of value.split(/,(?![^(]*\))/)) {
791
+ if (/\binset\b/.test(layer)) continue;
792
+ const color = layer.match(/(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)|#[0-9a-f]{3,8}\b/i)?.[0];
793
+ const [x = 0, y = 0, blur = 0, spread = 0] = [...layer.replace(color ?? "", "").matchAll(/-?[\d.]+px/g)].map((m) => parseFloat(m[0]));
794
+ if (color && Math.max(Math.abs(x), Math.abs(y)) + spread + blur / 2 >= 1) out.push({ kind: "shadow", color: rgba(color) });
795
+ }
796
+
797
+ return out;
798
+ }
799
+
800
+ // What draws a mark's edge: its borders, its outline and its box-shadows
801
+ function edges(s) {
802
+
803
+ const out = [];
804
+
805
+ for (const side of ["Top", "Right", "Bottom", "Left"]) {
806
+ const style = s[`border${side}Style`];
807
+ if (parseFloat(s[`border${side}Width`]) >= 1 && style !== "none" && style !== "hidden") out.push({ kind: "border", color: rgba(s[`border${side}Color`]) });
808
+ }
809
+ if (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) >= 1) out.push({ kind: "outline", color: rgba(s.outlineColor) });
810
+
811
+ return [...out, ...shadows(s.boxShadow)];
812
+ }
813
+
814
+ // The texts and marks whose colors are read from screenshots, in page px. The text's own color is kept here; the
815
+ // screenshot with the glyphs hidden gives what is behind each.
816
+ function colorItems() {
817
+
818
+ const items = [];
819
+ const slats = new Map();
820
+
821
+ for (const t of texts()) {
822
+ if (!t.shown.length || t.el.closest(":disabled, [aria-disabled='true'], [inert]")) continue;
823
+ const color = rgba(t.style.webkitTextFillColor || t.style.color);
824
+ if (color[3] === 0) continue;
825
+ const weight = parseInt(t.style.fontWeight, 10) || 400;
826
+ items.push({
827
+ kind: "text",
828
+ text: t.text,
829
+ what: describe(t.el),
830
+ color,
831
+ opacity: opacityOf(t.el),
832
+ size: t.effective,
833
+ bold: weight >= 700,
834
+ boxes: t.shown.map((f) => ({ left: Math.max(f.glyphs.left, f.visible.left), top: Math.max(f.glyphs.top, f.visible.top), right: Math.min(f.glyphs.right, f.visible.right), bottom: Math.min(f.glyphs.bottom, f.visible.bottom) })),
835
+ });
836
+ }
837
+
838
+ for (const el of document.querySelectorAll(MARKS)) {
839
+ if (el.closest("[hidden], .rhp-scale") || !visible(el)) continue;
840
+ const c = el.closest(".rhp-chart");
841
+ const r = el.getBoundingClientRect();
842
+ const v = clipped(r, el.parentElement, null);
843
+ if (v.empty || r.width * r.height < 4) continue;
844
+ const block = BLOCK[[...el.classList].find((k) => BLOCK[k])];
845
+ const s = getComputedStyle(el);
846
+ let fill = s.backgroundColor;
847
+ if (block === "Area") fill = s.stroke;
848
+ if (block === "Line") fill = getComputedStyle(el.querySelector(".rhp-stroke") ?? el).stroke;
849
+ const color = /^(rgb|color|oklch|oklab|lab|lch|hsl|hwb|#)/.test(fill) ? rgba(fill) : [0, 0, 0, 0];
850
+ // a Bar across the whole scale is a track behind the values, which is meant to be faint
851
+ const plot = el.closest(".rhp-plot");
852
+ const pr = plot?.getBoundingClientRect();
853
+ const vertical = el.dataset.rhpO === "v";
854
+ const track = block === "Bar" && pr && (vertical ? r.height >= pr.height * 0.99 : r.width >= pr.width * 0.99);
855
+ // the labels that name what a mark shows: words (not numbers) in the slat it is drawn in, as the reader sees them
856
+ // (screen-reader text names nothing on screen)
857
+ const slat = el.parentElement?.closest(".rhp-plot > *");
858
+ const names = slat && slat !== el
859
+ ? [...slat.querySelectorAll(".rhp-label")].filter((l) => !l.contains(el) && visible(l)).map(seenText).filter((x) => x && !/^[\s\d.,%+\-\u2212\u2013$\u20ac\u00a3\u00a5:/()kKMmBb]*$/.test(x))
860
+ : [];
861
+ if (slat && !slats.has(slat)) slats.set(slat, slats.size);
862
+ items.push({
863
+ kind: "mark", block, what: describe(el), color, opacity: opacityOf(el), track, names, chart: all().indexOf(c), slat: slat ? slats.get(slat) : null,
864
+ edges: /Area|Line/.test(block) ? [] : edges(s), boxes: [{ left: v.left, top: v.top, right: v.right, bottom: v.bottom }],
865
+ });
866
+ }
867
+
868
+ return items;
869
+ }
870
+
871
+ const all = () => [...document.querySelectorAll(".rhp-chart")];
872
+
873
+ // Hiding the glyphs (for the screenshot of what is behind text) or the marks (for what is behind marks), and
874
+ // putting them back. Inline !important beats rhp's layered rules; transitions this starts are finished at once.
875
+ // Only the properties set here are put back, so what the page changed in the meantime stays.
876
+ let undo = [];
877
+ const sheet = document.createElement("style");
878
+ const set = (el, name, value) => {
879
+ const had = el.hasAttribute("style");
880
+ const was = el.style.getPropertyValue(name);
881
+ const priority = el.style.getPropertyPriority(name);
882
+ el.style.setProperty(name, value, "important");
883
+ undo.push(() => {
884
+ if (was) el.style.setProperty(name, was, priority);
885
+ else el.style.removeProperty(name);
886
+ if (!had && !el.getAttribute("style")) el.removeAttribute("style");
887
+ });
888
+ };
889
+ function hide(what) {
890
+
891
+ show();
892
+
893
+ if (what === "text") {
894
+ // a text's shadow stays: a halo is what the glyphs are read against
895
+ sheet.textContent = "*, *::before, *::after { -webkit-text-stroke-color: transparent !important; text-decoration-color: transparent !important; caret-color: transparent !important; } ::marker { -webkit-text-fill-color: transparent !important; color: transparent !important; }";
896
+ document.head.append(sheet);
897
+ undo.push(() => sheet.remove());
898
+ // on each element that holds text (rhp's chart body resets what it would inherit), and on the root for the rest
899
+ const holders = new Set([document.documentElement, ...document.querySelectorAll(".rhp-body")]);
900
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
901
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
902
+ if (node.data.trim() && node.parentElement) holders.add(node.parentElement);
903
+ }
904
+ for (const el of holders) {
905
+ set(el, "-webkit-text-fill-color", "transparent");
906
+ set(el, "-webkit-text-stroke-color", "transparent");
907
+ set(el, "text-decoration-color", "transparent");
908
+ }
909
+ }
910
+
911
+ if (what === "marks") {
912
+ for (const el of document.querySelectorAll(MARKS)) {
913
+ set(el, "visibility", "hidden");
914
+ }
915
+ }
916
+
917
+ finish();
918
+ }
919
+
920
+ function show() {
921
+
922
+ for (const f of undo.splice(0).reverse()) {
923
+ f();
924
+ }
925
+
926
+ finish();
927
+ }
928
+
929
+ const finish = () => {
930
+ for (const a of document.getAnimations()) {
931
+ if (a instanceof CSSTransition && /text-fill-color|visibility|color|text-shadow|text-decoration/.test(a.transitionProperty)) {
932
+ try {
933
+ a.finish();
934
+ } catch {
935
+ // an animation that can't finish is left to run
936
+ }
937
+ }
938
+ }
939
+ };
940
+
941
+ // The content's box, for the screenshots (what is drawn, not the empty page around it)
942
+ function contentBox() {
943
+
944
+ let left = Infinity;
945
+ let top = Infinity;
946
+ let right = -Infinity;
947
+ let bottom = -Infinity;
948
+
949
+ for (const e of document.body.querySelectorAll("*")) {
950
+ const r = e.getBoundingClientRect();
951
+ if (r.width < 1 || r.height < 1 || !visible(e)) continue;
952
+ left = Math.min(left, r.left);
953
+ top = Math.min(top, r.top);
954
+ right = Math.max(right, r.right);
955
+ bottom = Math.max(bottom, r.bottom);
956
+ }
957
+
958
+ if (!Number.isFinite(left)) return null;
959
+
960
+ return { left: left + scrollX, top: top + scrollY, right: right + scrollX, bottom: bottom + scrollY };
961
+ }
962
+
963
+ // The slats of the main Plot, in screen order: the largest chart's (a size key drawn as a chart of its own comes before
964
+ // the chart it explains) first Plot with slats that takes the pointer (an overlay of guides or a readout takes none)
965
+ function mainSlats() {
966
+
967
+ const area = (el) => el.getBoundingClientRect().width * el.getBoundingClientRect().height;
968
+ const chart = all().filter((c) => visible(c) && !c.parentElement?.closest(".rhp-chart")).sort((a, b) => area(b) - area(a))[0];
969
+ const plots = chart ? [...chart.querySelectorAll(":scope > .rhp-body > .rhp-plot")].filter((p) => !p.classList.contains("rhp-scale") && [...p.children].some((s) => !s.hidden)) : [];
970
+ const plot = plots.find((p) => getComputedStyle(p).pointerEvents !== "none") ?? plots[0];
971
+ if (!plot) return [];
972
+ const vertical = plot.dataset.rhpO === "v";
973
+ const slats = [...plot.children].filter((s) => !s.hidden && visible(s));
974
+ slats.sort((a, b) => (vertical ? a.getBoundingClientRect().left - b.getBoundingClientRect().left : a.getBoundingClientRect().top - b.getBoundingClientRect().top));
975
+ return slats;
976
+ }
977
+
978
+ // The slats the sweep picks in turn (see sweep() in index.js): every slat of the main Plot, or max of them spread
979
+ // evenly from the first to the last
980
+ function sweepTargets(max = 32) {
981
+
982
+ const slats = mainSlats();
983
+ const list = (window.__rhpTargets ??= []);
984
+ const picks = slats.length <= max ? slats.map((_, i) => i) : [...new Set(Array.from({ length: max }, (_, k) => Math.round((k * (slats.length - 1)) / (max - 1))))];
985
+ return picks.map((i) => ({ index: i + 1, of: slats.length, name: snippet([...slats[i].querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? ""), target: list.push(slats[i]) - 1 }));
986
+ }
987
+
988
+ // The window widths at which the page's own media queries switch: the last width of one layout and the first of the
989
+ // next (for max-width: N, N and N + 1; for min-width: N, N - 1 and N; the range forms likewise). An em counts 16px.
990
+ function breakpoints() {
991
+
992
+ const out = new Set();
993
+ const px = (v, unit) => (unit === "px" ? +v : +v * 16);
994
+ // the first width of the wider layout, from a bound and whether the narrow layout includes it
995
+ const add = (n, narrowHasIt) => {
996
+ const first = narrowHasIt ? Math.floor(n) + 1 : Math.ceil(n);
997
+ out.add(first - 1);
998
+ out.add(first);
999
+ };
1000
+ const walk = (rules) => {
1001
+ for (const r of rules ?? []) {
1002
+ const text = r.media?.mediaText ?? "";
1003
+ for (const m of text.matchAll(/(min|max)-width\s*:\s*([\d.]+)(px|r?em)/g)) add(px(m[2], m[3]), m[1] === "max");
1004
+ for (const m of text.matchAll(/width\s*(<=|<|>=|>)\s*([\d.]+)(px|r?em)/g)) add(px(m[2], m[3]), m[1] === "<=" || m[1] === ">");
1005
+ for (const m of text.matchAll(/([\d.]+)(px|r?em)\s*(<=|<|>=|>)\s*width/g)) add(px(m[1], m[2]), m[3] === ">=" || m[3] === "<");
1006
+ if (r.cssRules) walk(r.cssRules);
1007
+ }
1008
+ };
1009
+ for (const sheet of [...document.styleSheets, ...(document.adoptedStyleSheets ?? [])]) {
1010
+ try {
1011
+ walk(sheet.cssRules);
1012
+ } catch {
1013
+ // a style sheet from another origin (a font's CSS) cannot be read, and holds no layout
1014
+ }
1015
+ }
1016
+ return [...out].filter((w) => w >= 200).sort((a, b) => b - a);
1017
+ }
1018
+
1019
+ // What the interaction pass works on: slats to point at (the first, the middle and the last of the largest chart, and
1020
+ // the one whose edge Label is widest: lit, a name that gets bolder can make room: "auto" measure again), buttons and
1021
+ // toggles, ranges, selects, a slider for the arrow keys, Plots with keyboard. Controls go by their accessible name.
1022
+ function targets() {
1023
+
1024
+ const list = (window.__rhpTargets = []);
1025
+ const keep = (el) => list.push(el) - 1;
1026
+ const out = { slats: [], widest: null, buttons: [], ranges: [], selects: [], slider: null, keyboard: null };
1027
+ const label = (el) => (nameOf(el) ? `"${snippet(nameOf(el))}"` : describe(el));
1028
+
1029
+ const slats = mainSlats();
1030
+ if (slats.length) {
1031
+ const range = document.createRange();
1032
+ const nameWidth = (s) => Math.max(0, ...[...s.querySelectorAll(".rhp-label[data-rhp-edge]")].map((l) => {
1033
+ range.selectNodeContents(l);
1034
+ return range.getBoundingClientRect().width;
1035
+ }));
1036
+ const slatOf = (i) => ({ index: i + 1, of: slats.length, name: snippet([...slats[i].querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? ""), target: keep(slats[i]) });
1037
+ const picks = [...new Set([0, Math.floor((slats.length - 1) / 2), slats.length - 1])].filter((i) => slats[i]);
1038
+ out.slats = picks.map(slatOf);
1039
+ const widths = slats.map(nameWidth);
1040
+ const widest = widths.indexOf(Math.max(...widths));
1041
+ if (widths[widest] > Math.max(...picks.map((i) => widths[i])) + 0.5) out.widest = slatOf(widest);
1042
+ }
1043
+
1044
+ const usable = (el) => visible(el) && !el.disabled && el.getAttribute("aria-disabled") !== "true";
1045
+ const buttons = [...document.querySelectorAll("button, [role=button], [role=switch], [role=tab], [role=radio], [role=checkbox], input[type=checkbox], input[type=radio], summary")]
1046
+ .filter((b) => usable(b) && !(b.type === "submit" && b.form) && !b.closest("a[href]"));
1047
+ for (const b of buttons.slice(0, 12)) {
1048
+ out.buttons.push({ label: label(b), role: b.getAttribute("role") ?? b.type ?? b.localName, target: keep(b) });
1049
+ }
1050
+
1051
+ for (const r of [...document.querySelectorAll("input[type=range]")].filter(usable).slice(0, 6)) {
1052
+ const min = parseFloat(r.min || "0");
1053
+ const max = parseFloat(r.max || "100");
1054
+ const now = parseFloat(r.value);
1055
+ out.ranges.push({ label: label(r), value: now <= (min + max) / 2 ? max : min, target: keep(r) });
1056
+ }
1057
+
1058
+ for (const s of [...document.querySelectorAll("select")].filter(usable).slice(0, 6)) {
1059
+ if (s.options.length > 1) out.selects.push({ label: label(s), value: s.options[(s.selectedIndex + 1) % s.options.length].value, target: keep(s) });
1060
+ }
1061
+
1062
+ // a slider of the page's own (role=slider) first: nothing else tries it
1063
+ const slider = [...document.querySelectorAll("[role=slider]"), ...document.querySelectorAll("input[type=range]")].find(usable);
1064
+ if (slider) out.slider = { label: label(slider), target: keep(slider) };
1065
+
1066
+ const focusable = [...document.querySelectorAll(".rhp-plot > [tabindex]")].filter(visible);
1067
+ if (focusable.length) out.keyboard = { count: focusable.length, vertical: focusable[0].parentElement.dataset.rhpO === "v" };
1068
+
1069
+ return out;
1070
+ }
1071
+
1072
+ // A point in the window on a mark of a slat (a target), where a reader would point or tap. The slat is scrolled into
1073
+ // view, and the point is the first of a few that reaches the slat: on its marks (a bubble sits away from the slat's
1074
+ // center), inside an SVG shape in it (a donut's wedge fills a small part of its box), then a Label's center. The
1075
+ // marks with a pointer cursor come first, in the page's order (the one shown at rest is often drawn larger), then the
1076
+ // largest. The first point on a mark is across it (a share of its width: the middle, or one side so that two taps in
1077
+ // a row land on different values), and on a Line it is on the line itself (a sloped line crosses its box). When no
1078
+ // point reaches it and something in the slat takes the pointer, something covers it: { covered } says what (another
1079
+ // slat of its Plot, or an element). When nothing in it takes the pointer (the page reads the pointer around the
1080
+ // chart), the first point on its first mark, or its center.
1081
+ function pointAt(n, across = 0.5) {
1082
+
1083
+ const slat = window.__rhpTargets[n];
1084
+ slat.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1085
+ const marks = [...slat.querySelectorAll(MARKS + ", .rhp-cell")].filter(visible)
1086
+ .map((m) => ({ m, r: m.getBoundingClientRect(), pointer: getComputedStyle(m).cursor === "pointer" }))
1087
+ .filter(({ r }) => r.width * r.height > 0);
1088
+ marks.sort((a, b) => b.pointer - a.pointer || (a.pointer ? 0 : b.r.width * b.r.height - a.r.width * a.r.height));
1089
+ const steps = [0.1, 0.3, 0.5, 0.7, 0.9];
1090
+ const grid = steps.flatMap((x) => steps.map((y) => [x, y]));
1091
+ const pointsOn = ({ m, r }) => {
1092
+ const line = m.matches(".rhp-line") && m.querySelector(".rhp-stroke");
1093
+ if (line) {
1094
+ const length = line.getTotalLength();
1095
+ return [across, 0.5, 0.1, 0.3, 0.7, 0.9].map((f) => line.getPointAtLength(length * f).matrixTransform(line.getScreenCTM()));
1096
+ }
1097
+ return [[across, 0.5], ...grid].map(([fx, fy]) => ({ x: r.left + r.width * fx, y: r.top + r.height * fy }));
1098
+ };
1099
+ // the points of a finer grid on an SVG shape's box that are inside its fill or on its stroke
1100
+ const inShape = (g) => {
1101
+ const r = g.getBoundingClientRect();
1102
+ const toShape = g.getScreenCTM()?.inverse();
1103
+ if (!toShape || r.width * r.height === 0) return [];
1104
+ const fine = [across, 0.5, 0.2, 0.8, 0.05, 0.35, 0.65, 0.95];
1105
+ return fine.flatMap((fx) => fine.map((fy) => ({ x: r.left + r.width * fx, y: r.top + r.height * fy }))).filter(({ x, y }) => {
1106
+ const p = new DOMPoint(x, y).matrixTransform(toShape);
1107
+ return g.isPointInFill(p) || g.isPointInStroke(p);
1108
+ });
1109
+ };
1110
+ const shapes = [...slat.querySelectorAll("path, circle, ellipse, rect, polygon, polyline, line")].filter((g) => g instanceof SVGGeometryElement && visible(g));
1111
+ const labels = [...slat.querySelectorAll(".rhp-label")].map((l) => l.getBoundingClientRect()).filter((r) => r.width * r.height > 0);
1112
+ const reaches = ({ x, y }) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && slat.contains(document.elementFromPoint(x, y));
1113
+
1114
+ for (const mark of marks.slice(0, 3)) {
1115
+ const hit = pointsOn(mark).find(reaches);
1116
+ if (hit) return hit;
1117
+ }
1118
+ for (const g of shapes.slice(0, 3)) {
1119
+ const hit = inShape(g).find(reaches);
1120
+ if (hit) return hit;
1121
+ }
1122
+ const hit = labels.map((r) => ({ x: (r.left + r.right) / 2, y: (r.top + r.bottom) / 2 })).find(reaches);
1123
+ if (hit) return hit;
1124
+
1125
+ const r = slat.getBoundingClientRect();
1126
+ const { x, y } = marks.length ? pointsOn(marks[0])[0] : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1127
+ const takes = (e) => getComputedStyle(e).pointerEvents !== "none" && e.checkVisibility({ visibilityProperty: true }) && e.getBoundingClientRect().width * e.getBoundingClientRect().height > 0;
1128
+ if ([slat, ...slat.querySelectorAll("*")].some(takes)) {
1129
+ const on = document.elementFromPoint(x, y);
1130
+ return { covered: on && slat.parentElement.contains(on) && !slat.contains(on) ? "slat" : on ? describe(on) : "nothing in the window" };
1131
+ }
1132
+
1133
+ return { x, y };
1134
+ }
1135
+
1136
+ // Where the controls and the charts are on the page (in page px), before an interaction. moved(n, lit) then says
1137
+ // which of them the interaction moved by more than 2px: a control other than the one it used (a target), or a
1138
+ // chart's top; and when it lit a slat (lit: pointing, a tap, the keyboard, which change no data), the plot inside a
1139
+ // chart whose room "auto" for names changed (a lit name that gets bolder is measured again). A control in a slat
1140
+ // moves with it (rows sorted or removed), so only what it moves apart from its slat counts. What is fixed or sticky
1141
+ // moves with scrolling, so it is left out.
1142
+ let placed = [];
1143
+ let sections = [];
1144
+ const at = (el) => {
1145
+ const r = el.getBoundingClientRect();
1146
+ return { el, x: r.left + scrollX, y: r.top + scrollY };
1147
+ };
1148
+ // The room "auto" gives names, in px: the chart body's first and last grid tracks (columns, or rows when vertical)
1149
+ const gutters = (chart) => {
1150
+ const o = chart.dataset.rhpGutters;
1151
+ const body = chart.querySelector(":scope > .rhp-body");
1152
+ if (!o || !body) return null;
1153
+ const s = getComputedStyle(body);
1154
+ const tracks = (o === "h" ? s.gridTemplateColumns : s.gridTemplateRows).split(/\s+/).map(parseFloat).filter(Number.isFinite);
1155
+ if (tracks.length < 3) return null;
1156
+ return o === "h" ? { o, start: tracks[0], end: tracks.at(-1) } : { o, start: tracks.at(-1), end: tracks[0] };
1157
+ };
1158
+ function place() {
1159
+
1160
+ const pinned = (el) => {
1161
+ for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
1162
+ if (/fixed|sticky/.test(getComputedStyle(a).position)) return true;
1163
+ }
1164
+ return false;
1165
+ };
1166
+ const charts = all().filter((c) => !c.parentElement?.closest(".rhp-chart"));
1167
+ placed = [...document.querySelectorAll(CONTROLS), ...charts].filter((el) => visible(el) && !pinned(el)).map((el) => {
1168
+ const chart = charts.includes(el);
1169
+ const slat = chart ? null : el.closest(".rhp-plot > *");
1170
+ return { ...at(el), chart, slat: slat && at(slat), gutters: chart ? gutters(el) : null };
1171
+ });
1172
+ // the end of each chart's section (its outermost box inside the body): a readout after the chart that takes
1173
+ // another line makes it longer, even on a page shorter than the window
1174
+ sections = [...new Set(charts.map((c) => {
1175
+ let a = c;
1176
+ while (a.parentElement && a.parentElement !== document.body && a.parentElement !== document.documentElement) a = a.parentElement;
1177
+ return a;
1178
+ }))].map((el) => ({ el, end: el.getBoundingClientRect().bottom + scrollY }));
1179
+ }
1180
+
1181
+ function moved(n, lit = false) {
1182
+
1183
+ const used = window.__rhpTargets[n];
1184
+ const out = [];
1185
+ const shift = (p) => {
1186
+ const now = at(p.el);
1187
+ return { dx: now.x - p.x, dy: now.y - p.y };
1188
+ };
1189
+ const far = (v) => Math.abs(v) > 2;
1190
+
1191
+ for (const p of placed) {
1192
+ if (p.el === used || !p.el.isConnected || !visible(p.el)) continue;
1193
+ const d = shift(p);
1194
+ const by = p.slat?.el.isConnected ? shift(p.slat) : { dx: 0, dy: 0 };
1195
+ const dx = p.chart ? 0 : Math.round(d.dx - by.dx);
1196
+ const dy = Math.round(d.dy - by.dy);
1197
+ if (far(dx) || far(dy)) {
1198
+ const name = p.chart ? "" : snippet(nameOf(p.el));
1199
+ out.push({ what: p.chart ? "the chart" : name ? `"${name}"` : describe(p.el), dx, dy });
1200
+ }
1201
+ // the plot's edge at a side whose room for names grew or shrank
1202
+ const now = lit && p.gutters ? gutters(p.el) : null;
1203
+ if (!now || now.o !== p.gutters.o) continue;
1204
+ const start = Math.round(now.start - p.gutters.start);
1205
+ const end = Math.round(now.end - p.gutters.end);
1206
+ if (now.o === "h" && far(start)) out.push({ what: "the plot's left edge", dx: start, dy: 0, plot: true });
1207
+ if (now.o === "h" && far(end)) out.push({ what: "the plot's right edge", dx: -end, dy: 0, plot: true });
1208
+ if (now.o === "v" && far(start)) out.push({ what: "the plot's bottom edge", dx: 0, dy: -start, plot: true });
1209
+ if (now.o === "v" && far(end)) out.push({ what: "the plot's top edge", dx: 0, dy: end, plot: true });
1210
+ }
1211
+ // lighting a slat makes no section longer or shorter: a readout after the chart that takes another line moves
1212
+ // whatever follows it, which no control may show
1213
+ for (const { el, end } of lit ? sections : []) {
1214
+ const grew = Math.round(el.getBoundingClientRect().bottom + scrollY - end);
1215
+ if (el.isConnected && far(grew)) out.push({ what: "the end of the chart's section", dx: 0, dy: grew });
1216
+ }
1217
+
1218
+ return out;
1219
+ }
1220
+
1221
+ // A change recorder for one interaction: what changed in the DOM (text, attributes, inline styles, elements), and,
1222
+ // when it is a tap (texts), the texts it wrote and the texts shown before it (kept until the next interaction, for
1223
+ // unseen())
1224
+ let recorder = null;
1225
+ let wrote = new Set();
1226
+ let before = null;
1227
+ function record(keepTexts = false) {
1228
+
1229
+ const seen = { text: 0, attributes: 0, styles: 0, elements: 0 };
1230
+ wrote = new Set();
1231
+ before = keepTexts ? new Set(texts().filter((t) => t.shown.length).map((t) => t.node)) : null;
1232
+ const count = (m) => {
1233
+ if (m.type === "characterData") {
1234
+ seen.text++;
1235
+ wrote.add(m.target);
1236
+ } else if (m.type === "childList") {
1237
+ seen.elements += m.addedNodes.length + m.removedNodes.length;
1238
+ } else if (m.attributeName === "style") {
1239
+ seen.styles++;
1240
+ } else {
1241
+ seen.attributes++;
1242
+ }
1243
+ };
1244
+ recorder = new MutationObserver((list) => list.forEach(count));
1245
+ recorder.observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true });
1246
+ recorder.seen = seen;
1247
+ recorder.count = count;
1248
+ }
1249
+
1250
+ function stop() {
1251
+
1252
+ if (!recorder) return null;
1253
+
1254
+ recorder.takeRecords().forEach(recorder.count);
1255
+ recorder.disconnect();
1256
+ const seen = recorder.seen;
1257
+ recorder = null;
1258
+
1259
+ return seen;
1260
+ }
1261
+
1262
+ // After a tap on a slat (a target) that is in the window: when the tap changed text the reader can see (wrote it, or
1263
+ // showed it), but all of that text lies outside the window, the nearest of it and how far it is; else null. A style
1264
+ // the tap changed in view (the tapped bar lit) does not say what a readout out of view says.
1265
+ function unseen(n) {
1266
+
1267
+ const slat = window.__rhpTargets[n];
1268
+ const inView = (r) => r.right > 0 && r.bottom > 0 && r.left < innerWidth && r.top < innerHeight;
1269
+ if (!before || !slat?.isConnected || !inView(slat.getBoundingClientRect())) return null;
1270
+
1271
+ let nearest = null;
1272
+
1273
+ for (const t of texts()) {
1274
+ if (before.has(t.node) && !wrote.has(t.node)) continue;
1275
+ for (const { visible: r } of t.shown) {
1276
+ if (inView(r)) return null;
1277
+ const away = [[-r.bottom, "above"], [r.top - innerHeight, "below"], [-r.right, "left of"], [r.left - innerWidth, "right of"]].find(([by]) => by >= 0);
1278
+ if (away && (!nearest || away[0] < nearest.by)) nearest = { what: `"${t.text}"`, by: Math.round(away[0]), side: away[1] };
1279
+ }
1280
+ }
1281
+
1282
+ return nearest;
1283
+ }
1284
+
1285
+ // The name of a focused element: a slat's (its Label's text) or a control's
1286
+ const focusName = (el) => {
1287
+ const plot = el.parentElement?.classList.contains("rhp-plot") ? el.parentElement : null;
1288
+ if (plot) {
1289
+ const name = [...el.querySelectorAll(".rhp-label")].map(seenText).find((x) => x);
1290
+ return `slat ${[...plot.children].indexOf(el) + 1}${name ? ` (${snippet(name)})` : ""}`;
1291
+ }
1292
+ const role = el.getAttribute("role") ?? (el.localName === "a" ? "link" : el.localName === "input" ? el.type : el.localName);
1293
+ const name = nameOf(el) || (el.localName === "a" ? seenText(el) : "");
1294
+ return name ? `${role} "${snippet(name)}"` : describe(el);
1295
+ };
1296
+
1297
+ // Where the focus is: which slat of which Plot, if it is on one
1298
+ function focused() {
1299
+
1300
+ const el = document.activeElement;
1301
+ if (!el || el === document.body) return null;
1302
+ const plot = el.parentElement?.classList.contains("rhp-plot") ? el.parentElement : null;
1303
+ if (!plot) return { slat: false };
1304
+
1305
+ return { slat: true, index: [...plot.children].indexOf(el) + 1, name: snippet([...el.querySelectorAll(".rhp-label")].map(seenText).find((x) => x) ?? "") };
1306
+ }
1307
+
1308
+ // A stop of Tab through the page, for the focus check: the focused element's name, and its box in the window with
1309
+ // room around it for an outline (none when it has no box in the window); again when Tab came back to an element it
1310
+ // reached before; null when nothing has focus. focusStop(true) starts a new walk.
1311
+ let stops = new Set();
1312
+ function focusStop(fresh = false) {
1313
+
1314
+ if (fresh) stops = new Set();
1315
+ const el = document.activeElement;
1316
+ if (!el || el === document.body || el === document.documentElement) return null;
1317
+ if (stops.has(el)) return { again: true };
1318
+ stops.add(el);
1319
+
1320
+ const room = 12;
1321
+ const r = el.getBoundingClientRect();
1322
+ const left = Math.max(0, Math.floor(r.left - room));
1323
+ const top = Math.max(0, Math.floor(r.top - room));
1324
+ const right = Math.min(innerWidth, Math.ceil(r.right + room));
1325
+ const bottom = Math.min(innerHeight, Math.ceil(r.bottom + room));
1326
+ const box = r.width * r.height > 0 && right - left >= 1 && bottom - top >= 1 ? { x: left, y: top, width: right - left, height: bottom - top } : null;
1327
+
1328
+ return { what: focusName(el), clip: box };
1329
+ }
1330
+
1331
+ window.__rhpProbe = { settle, calm, resized: () => [resizes, mutations], measure, transitions, colorItems, hide, show, contentBox, targets, sweepTargets, breakpoints, pointAt, place, moved, record, stop, unseen, focused, focusStop };
1332
+ }