@bezda/rhp-mcp 0.1.8 → 0.1.10
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.
- package/package.json +2 -2
- package/skill/SKILL.md +5 -5
- package/skill/recipes/area.html +349 -138
- package/skill/recipes/bar.html +349 -138
- package/skill/recipes/box-plot.html +349 -138
- package/skill/recipes/bubble.html +349 -138
- package/skill/recipes/bullet.html +349 -138
- package/skill/recipes/candlestick.html +349 -138
- package/skill/recipes/column.html +349 -138
- package/skill/recipes/dial.html +349 -138
- package/skill/recipes/diverging-bars.html +349 -138
- package/skill/recipes/donut.html +349 -138
- package/skill/recipes/dumbbell.html +349 -138
- package/skill/recipes/gantt.html +349 -138
- package/skill/recipes/grouped-bars.html +349 -138
- package/skill/recipes/heatmap.html +349 -138
- package/skill/recipes/histogram.html +349 -138
- package/skill/recipes/line.html +349 -138
- package/skill/recipes/live.html +349 -138
- package/skill/recipes/log-line.html +349 -138
- package/skill/recipes/lollipop.html +349 -138
- package/skill/recipes/multi-line.html +349 -138
- package/skill/recipes/pyramid.html +349 -138
- package/skill/recipes/race.html +363 -139
- package/skill/recipes/radial-bars.html +349 -138
- package/skill/recipes/scatter.html +349 -138
- package/skill/recipes/silhouettes.html +349 -138
- package/skill/recipes/slope.html +349 -138
- package/skill/recipes/sparklines.html +349 -138
- package/skill/recipes/stacked-100.html +349 -138
- package/skill/recipes/stacked-bars.html +349 -138
- package/skill/recipes/strip.html +349 -138
- package/skill/recipes/tape.html +415 -165
- package/skill/recipes/unit-stack.html +349 -138
- package/skill/recipes/violin.html +349 -138
- package/skill/recipes/waffle.html +349 -138
- package/skill/recipes/waterfall.html +349 -138
- package/skill/references/api.md +34 -6
- package/skill/references/design.md +18 -2
- package/skill/references/download.md +372 -140
- package/skill/references/environments.md +1 -1
- package/skill/references/interaction.md +2 -0
- package/skill/references/pitfalls.md +4 -3
- package/src/check/README.md +2 -1
- package/src/check/findings.js +2 -0
- package/src/check/inpage.js +9 -2
- package/src/guard/rhp.js +31 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bezda/rhp-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Make charts with rhp from any AI agent: an MCP server with rhp's guide, references and recipes, and a checker that renders a chart in a browser and reports what is wrong, with screenshots",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rhp",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@babel/core": "^7.29.7",
|
|
54
|
-
"@bezda/rhp": "^2.
|
|
54
|
+
"@bezda/rhp": "^2.2.0",
|
|
55
55
|
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
56
56
|
"babel-preset-solid": "^1.9.15",
|
|
57
57
|
"esbuild": "^0.28.2",
|
package/skill/SKILL.md
CHANGED
|
@@ -269,7 +269,7 @@ Download saves the full poster, including the headline and note, even when the v
|
|
|
269
269
|
- A request about the user's own data with none given ("chart my spending") gets illustrative numbers in one obvious data block at the top of the script, with the note's text in the same block, so the user changes both in one place.
|
|
270
270
|
The headline and the dek compute their numbers from that data, so they stay true for the user's own.
|
|
271
271
|
The handoff says exactly how to put their own numbers in, or offers to read a file they point to.
|
|
272
|
-
- Compute the scale from the data: `nice(
|
|
272
|
+
- Compute the scale from the data: `nice(...extent([0, ...values]))`, which skips missing values (rule 2 below).
|
|
273
273
|
- Compute the ranks, the leader and every number in the headline and the dek from a sorted copy of the data, never from its input order: a Plot's `order` sorts only what it draws.
|
|
274
274
|
- Text computed from the data (the headline, the dek, a readout, an `aria-label`) must stay true for any data it may get:
|
|
275
275
|
- A change from a zero or missing base prints "new" or "up from none", never ∞%, NaN% or "as many as"; a change in a rate is in percentage points ("down 0.8 points").
|
|
@@ -375,12 +375,12 @@ A complete chart in the plain HTML format (the format of every recipe), with a p
|
|
|
375
375
|
<body>
|
|
376
376
|
<div id="chart"></div>
|
|
377
377
|
<script type="module">
|
|
378
|
-
import { Chart, Plot, Bar, Label, Poster, slat, nice, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
378
|
+
import { Chart, Plot, Bar, Label, Poster, slat, nice, extent, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
379
379
|
|
|
380
380
|
// Data, and what depends on it
|
|
381
381
|
const fruit = ["Bananas", "Apples", "Cherries", "Pears", "Plums"];
|
|
382
382
|
const sold = [18, 12, 7, 5, 3];
|
|
383
|
-
const scale = nice(
|
|
383
|
+
const scale = nice(...extent([0, ...sold])); // 0 to 20, ticks every 5
|
|
384
384
|
const lead = sold.indexOf(Math.max(...sold)); // the fruit the headline names: lit until the reader picks another
|
|
385
385
|
|
|
386
386
|
// Slat type: one slat per row; its CSS reaches only these slats
|
|
@@ -449,7 +449,7 @@ Data:
|
|
|
449
449
|
|
|
450
450
|
1. **Data names must match.** A slat reads `d.sold` only if the Plot has a prop `sold`. A list of objects goes in `rows={[...]}`, never `data={...}`: `data` would just be a column named "data". Any prop a Plot does not know (`id`, `title`, `onClick`) is a column too, and an array is always a column, item i for row i: to give every slat the same list, pass a function of the row (`shown=${(d) => shown()}`).
|
|
451
451
|
2. **The scale must hold the data.** `scale={[min, max]}` with min below max covers every value: a Bar past it is cut off, and Dots, Ticks, Places and the points of an Area or a Line past it land outside or squeeze the outline.
|
|
452
|
-
Build it from the data, `const s = nice(
|
|
452
|
+
Build it from the data, `const s = nice(...extent([0, ...values]))`, which keeps 0, holds negative values and skips missing ones, and give the Chart `scale=${[s.min, s.max]} ticks=${s.ticks}`.
|
|
453
453
|
3. **Rows that come and go need a `key`** on the Plot (`key="city"`), or slats swap data instead of leaving. `d.index` can change then, so read it in a function: `data-row=${() => d.index}`.
|
|
454
454
|
4. **Format numbers** with `Intl.NumberFormat` (thousands, units, percentages, currency) and use `font-variant-numeric: tabular-nums` where numbers line up. In the JS version (`animate`), values in between are fractional: round what you print.
|
|
455
455
|
|
|
@@ -520,7 +520,7 @@ On the sketch path, read only what the request needs, at the step that needs it
|
|
|
520
520
|
- [design.md](references/design.md): art direction from the subject (idea, marks, composition, palette, type), the poster, color, type, layout, annotation, charts inside an app, a style the user asks for.
|
|
521
521
|
- [looks.md](references/looks.md): eight looks written out in full (tokens, fonts, texture, type), as examples of how a look is written, not a menu.
|
|
522
522
|
- [interaction.md](references/interaction.md): choosing interaction behavior and presentation for the reader's task, with complete implementation examples.
|
|
523
|
-
- [download.md](references/download.md): the fit-to-screen and download icons for standalone HTML posters, with a complete PNG export block.
|
|
523
|
+
- [download.md](references/download.md): the fit-to-screen and download icons for standalone HTML posters, with a complete PNG and GIF export block.
|
|
524
524
|
- [performance.md](references/performance.md): measured optimizations for an expensive chart, preserving its slats, styling, state, and interactions.
|
|
525
525
|
- [environments.md](references/environments.md): plain HTML, Solid, Astro, React, Next.js, Vue, Svelte and Angular, each verified, and the html template to JSX rules.
|
|
526
526
|
- [api.md](references/api.md): every component, prop, helper and CSS variable, exactly.
|
package/skill/recipes/area.html
CHANGED
|
@@ -154,7 +154,7 @@ render(() => html`<${PowerChart} />`, document.getElementById("chart"));
|
|
|
154
154
|
</script>
|
|
155
155
|
<style id="poster-download-styles">
|
|
156
156
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
157
|
-
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
157
|
+
.poster-actions { position: relative; display: flex; align-items: center; margin-left: auto; }
|
|
158
158
|
.poster-download, .poster-fit-toggle { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
|
|
159
159
|
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
160
160
|
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
@@ -165,7 +165,14 @@ render(() => html`<${PowerChart} />`, document.getElementById("chart"));
|
|
|
165
165
|
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
166
166
|
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
167
167
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
168
|
-
|
|
168
|
+
.poster-download-progress { font: 600 11px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
|
|
169
|
+
.poster-download-menu { position: absolute; top: calc(100% + 4px); right: 0; z-index: 20; display: grid; gap: 2px; min-width: 220px; padding: 6px; border: 1px solid color-mix(in srgb, currentColor 24%, transparent); border-radius: 8px; background: var(--poster-tools-ground, Canvas); box-shadow: 0 8px 24px rgb(0 0 0 / .28); }
|
|
170
|
+
.poster-download-menu[hidden] { display: none; }
|
|
171
|
+
.poster-download-menu button { display: grid; gap: 2px; width: 100%; padding: 8px 10px; border: 0; border-radius: 5px; background: none; color: inherit; font: 600 14px/1.3 system-ui, sans-serif; text-align: left; cursor: pointer; }
|
|
172
|
+
.poster-download-menu button span { font-weight: 400; font-size: 12.5px; opacity: .8; }
|
|
173
|
+
.poster-download-menu button:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
|
|
174
|
+
.poster-download-menu button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
175
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-menu, .poster-download-error { display: none !important; } }
|
|
169
176
|
</style>
|
|
170
177
|
<script type="module" id="poster-download-script">
|
|
171
178
|
function addPosterFit(poster, button, changed) {
|
|
@@ -243,6 +250,8 @@ function addPosterDownloads() {
|
|
|
243
250
|
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
244
251
|
}
|
|
245
252
|
}
|
|
253
|
+
const ground = getComputedStyle(document.body).backgroundColor;
|
|
254
|
+
if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(ground)) tools.style.setProperty('--poster-tools-ground', ground);
|
|
246
255
|
const button = document.createElement('button');
|
|
247
256
|
button.type = 'button';
|
|
248
257
|
button.className = 'poster-download';
|
|
@@ -288,154 +297,356 @@ function addPosterDownloads() {
|
|
|
288
297
|
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
289
298
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
290
299
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
300
|
+
// A poster that plays through its data gives itself a playback ({ duration, position, seek }): its download then
|
|
301
|
+
// offers the poster as it is now, a PNG, or the whole playback, an animated GIF
|
|
302
|
+
let exporter, encoder, retries = 0, busy = false, making = null, cancelled = false;
|
|
303
|
+
const playable = () => typeof poster.playback?.seek === 'function';
|
|
304
|
+
const icon = button.innerHTML;
|
|
305
|
+
const menu = document.createElement('div');
|
|
306
|
+
menu.className = 'poster-download-menu';
|
|
307
|
+
menu.setAttribute('role', 'menu');
|
|
308
|
+
menu.setAttribute('aria-label', 'Download poster');
|
|
309
|
+
menu.hidden = true;
|
|
310
|
+
menu.innerHTML = '<button type="button" role="menuitem" data-format="png">PNG image<span>The poster as it is now</span></button>'
|
|
311
|
+
+ '<button type="button" role="menuitem" data-format="gif">Animated GIF<span>The whole playback</span></button>';
|
|
312
|
+
const items = [...menu.querySelectorAll('[role="menuitem"]')];
|
|
313
|
+
actions.append(menu);
|
|
314
|
+
const label = text => { button.setAttribute('aria-label', text); button.title = text; };
|
|
315
|
+
const rest = () => {
|
|
316
|
+
label(playable() ? 'Download poster' : 'Download poster as PNG');
|
|
317
|
+
if (playable()) { button.setAttribute('aria-haspopup', 'menu'); button.setAttribute('aria-expanded', String(!menu.hidden)); }
|
|
318
|
+
};
|
|
319
|
+
rest();
|
|
320
|
+
const closeMenu = focus => {
|
|
321
|
+
if (menu.hidden) return;
|
|
322
|
+
menu.hidden = true;
|
|
323
|
+
rest();
|
|
324
|
+
if (focus) button.focus();
|
|
325
|
+
};
|
|
326
|
+
const openMenu = focus => {
|
|
327
|
+
menu.hidden = false;
|
|
328
|
+
rest();
|
|
329
|
+
if (focus) items[0].focus();
|
|
330
|
+
};
|
|
331
|
+
document.addEventListener('pointerdown', event => { if (!actions.contains(event.target)) closeMenu(false); }, true);
|
|
332
|
+
menu.addEventListener('keydown', event => {
|
|
333
|
+
const at = items.indexOf(document.activeElement);
|
|
334
|
+
const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[event.key];
|
|
335
|
+
if (to !== undefined) { event.preventDefault(); items[(to + items.length) % items.length].focus(); }
|
|
336
|
+
else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); }
|
|
337
|
+
else if (event.key === 'Tab') closeMenu(false);
|
|
338
|
+
});
|
|
339
|
+
menu.addEventListener('focusout', event => { if (!menu.contains(event.relatedTarget) && event.relatedTarget !== button) closeMenu(false); });
|
|
340
|
+
// The capture options, made once for a download and for every frame of a GIF
|
|
341
|
+
const loadExporter = () => (exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : '')));
|
|
342
|
+
async function captureOptions() {
|
|
343
|
+
await document.fonts.ready;
|
|
344
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
345
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
346
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
347
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
348
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
349
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
350
|
+
if (rule.selectorText && rule.style) {
|
|
351
|
+
const margins = [...rule.style].filter(isMargin);
|
|
352
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
353
|
+
const insets = [...rule.style].filter(isInset);
|
|
354
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
355
|
+
const layout = [...margins, ...insets];
|
|
356
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
357
|
+
}
|
|
358
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
359
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
360
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
361
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
362
|
+
}).join('\n');
|
|
363
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
364
|
+
const layout = sheets.map(sheet => {
|
|
365
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
366
|
+
}).join('\n');
|
|
367
|
+
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
368
|
+
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
369
|
+
const restoreLayout = (source, clone) => {
|
|
370
|
+
if (clone.style && source !== poster) {
|
|
371
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
372
|
+
const computed = getComputedStyle(source);
|
|
373
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
374
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
375
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
376
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
377
|
+
if (insets) {
|
|
378
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
379
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
380
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
381
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
382
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
386
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
387
|
+
};
|
|
388
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
389
|
+
const fontFiles = new Map();
|
|
390
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
391
|
+
let css;
|
|
392
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
393
|
+
catch (cause) {
|
|
394
|
+
if (!sheet.href) return '';
|
|
395
|
+
const response = await fetch(sheet.href);
|
|
396
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
397
|
+
css = await response.text();
|
|
398
|
+
}
|
|
399
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
400
|
+
return (await Promise.all(rules.map(async rule => {
|
|
401
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
402
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
403
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
404
|
+
const response = await fetch(source);
|
|
405
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
406
|
+
const blob = await response.blob();
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
const reader = new FileReader();
|
|
409
|
+
reader.onload = () => resolve(reader.result);
|
|
410
|
+
reader.onerror = reject;
|
|
411
|
+
reader.readAsDataURL(blob);
|
|
412
|
+
});
|
|
413
|
+
})());
|
|
414
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
415
|
+
}
|
|
416
|
+
return rule;
|
|
417
|
+
}))).join('\n');
|
|
418
|
+
}));
|
|
419
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
420
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
421
|
+
const textures = new Map();
|
|
422
|
+
const embedTexture = async (node) => {
|
|
423
|
+
if (!node.style) return;
|
|
424
|
+
let background = node.style.backgroundImage;
|
|
425
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
426
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
427
|
+
const image = new Image();
|
|
428
|
+
image.src = source;
|
|
429
|
+
await image.decode();
|
|
430
|
+
const canvas = document.createElement('canvas');
|
|
431
|
+
canvas.width = image.naturalWidth;
|
|
432
|
+
canvas.height = image.naturalHeight;
|
|
433
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
434
|
+
return canvas.toDataURL();
|
|
435
|
+
})());
|
|
436
|
+
background = background.replace(source, await textures.get(source));
|
|
437
|
+
}
|
|
438
|
+
node.style.backgroundImage = background;
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
...fit.dimensions(), style: { margin: '0' }, filter: includeNode,
|
|
442
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
443
|
+
onCloneNode: clone => {
|
|
444
|
+
restoreLayout(poster, clone);
|
|
445
|
+
const style = document.createElement('style');
|
|
446
|
+
style.textContent = layout;
|
|
447
|
+
clone.prepend(style);
|
|
448
|
+
},
|
|
449
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
450
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
451
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
let url;
|
|
456
|
+
const save = (blob, extension) => {
|
|
457
|
+
url = URL.createObjectURL(blob);
|
|
458
|
+
const link = document.createElement('a');
|
|
459
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
460
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.' + extension;
|
|
461
|
+
link.href = url;
|
|
462
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
463
|
+
document.body.append(link);
|
|
464
|
+
link.click();
|
|
465
|
+
link.remove();
|
|
466
|
+
};
|
|
467
|
+
const begin = kind => {
|
|
295
468
|
busy = true;
|
|
296
|
-
|
|
469
|
+
making = kind;
|
|
297
470
|
button.setAttribute('aria-busy', 'true');
|
|
298
471
|
fitButton.setAttribute('aria-disabled', 'true');
|
|
299
|
-
button
|
|
472
|
+
// A GIF takes a while: the button shows its progress and cancels it
|
|
473
|
+
if (kind === 'gif') label('Cancel the GIF');
|
|
474
|
+
else { button.setAttribute('aria-disabled', 'true'); label('Preparing poster PNG'); }
|
|
300
475
|
error.hidden = true;
|
|
301
|
-
|
|
476
|
+
};
|
|
477
|
+
const end = () => {
|
|
478
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
479
|
+
url = null;
|
|
480
|
+
busy = false;
|
|
481
|
+
making = null;
|
|
482
|
+
cancelled = false;
|
|
483
|
+
button.innerHTML = icon;
|
|
484
|
+
button.removeAttribute('aria-disabled');
|
|
485
|
+
button.removeAttribute('aria-busy');
|
|
486
|
+
fitButton.removeAttribute('aria-disabled');
|
|
487
|
+
rest();
|
|
488
|
+
};
|
|
489
|
+
const fail = message => {
|
|
490
|
+
exporter = null;
|
|
491
|
+
encoder = null;
|
|
492
|
+
retries++;
|
|
493
|
+
error.textContent = message;
|
|
494
|
+
error.hidden = false;
|
|
495
|
+
};
|
|
496
|
+
async function savePng() {
|
|
497
|
+
begin('png');
|
|
302
498
|
try {
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
344
|
-
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
499
|
+
const { domToBlob } = await loadExporter();
|
|
500
|
+
const blob = await domToBlob(poster, { ...(await captureOptions()), scale: 2 });
|
|
501
|
+
if (!blob) throw new Error('No PNG was created');
|
|
502
|
+
save(blob, 'png');
|
|
503
|
+
} catch (cause) {
|
|
504
|
+
fail('The PNG could not be saved. Check your connection and try again.');
|
|
505
|
+
} finally {
|
|
506
|
+
end();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// The GIF's clock: while it is made, rhp's motion (performance.now and animation frames) and the page's CSS
|
|
510
|
+
// animations run on a time of their own, which only moves on when a frame asks it to, so every frame shows the chart
|
|
511
|
+
// exactly as it is that many milliseconds into playing, its glides and slides included
|
|
512
|
+
const gifClock = () => {
|
|
513
|
+
const raf = window.requestAnimationFrame, caf = window.cancelAnimationFrame;
|
|
514
|
+
const queue = new Map(), started = new Map();
|
|
515
|
+
let time = performance.now(), id = 0, pump = 0;
|
|
516
|
+
const flush = () => {
|
|
517
|
+
const due = [...queue.values()];
|
|
518
|
+
queue.clear();
|
|
519
|
+
due.forEach(callback => { try { callback(time); } catch (cause) { reportError(cause); } });
|
|
520
|
+
};
|
|
521
|
+
// Callbacks still run every real frame, at the clock's time, so whatever waits for a frame is not held up
|
|
522
|
+
const loop = () => { flush(); pump = raf.call(window, loop); };
|
|
523
|
+
window.requestAnimationFrame = callback => { queue.set(++id, callback); return id; };
|
|
524
|
+
window.cancelAnimationFrame = n => queue.delete(n);
|
|
525
|
+
performance.now = () => time;
|
|
526
|
+
pump = raf.call(window, loop);
|
|
527
|
+
return {
|
|
528
|
+
// Two of the browser's frames: callbacks that were already waiting for one (rhp's ticker) come over to the clock
|
|
529
|
+
ready: new Promise(resolve => raf.call(window, () => raf.call(window, resolve))),
|
|
530
|
+
advance(ms, steps = 1) {
|
|
531
|
+
for (let i = 0; i < steps; i++) {
|
|
532
|
+
time += ms / steps;
|
|
533
|
+
// A frame's motion, then the style writes it schedules
|
|
534
|
+
flush(); flush(); flush();
|
|
535
|
+
for (const animation of document.getAnimations()) {
|
|
536
|
+
if (!started.has(animation)) started.set(animation, time - (animation.currentTime ?? 0));
|
|
537
|
+
animation.pause();
|
|
538
|
+
animation.currentTime = time - started.get(animation);
|
|
345
539
|
}
|
|
346
540
|
}
|
|
347
|
-
|
|
348
|
-
|
|
541
|
+
},
|
|
542
|
+
stop() {
|
|
543
|
+
caf.call(window, pump);
|
|
544
|
+
window.requestAnimationFrame = raf;
|
|
545
|
+
window.cancelAnimationFrame = caf;
|
|
546
|
+
delete performance.now;
|
|
547
|
+
for (const callback of queue.values()) raf.call(window, callback);
|
|
548
|
+
started.forEach((_, animation) => animation.play());
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
};
|
|
552
|
+
// The GIF: the playback at its own pace, 25 frames a second, at most 1200 pixels wide. One palette from the
|
|
553
|
+
// first and the last frame serves every frame; its last color is never drawn and marks the pixels a frame leaves as
|
|
554
|
+
// the frame before had them, so each frame stores only what changed.
|
|
555
|
+
async function saveGif() {
|
|
556
|
+
const playback = poster.playback;
|
|
557
|
+
const from = playback.position();
|
|
558
|
+
let clock = null;
|
|
559
|
+
begin('gif');
|
|
560
|
+
try {
|
|
561
|
+
encoder ||= import('https://cdn.jsdelivr.net/npm/gifenc@1.0.3/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
562
|
+
const [{ domToCanvas }, { GIFEncoder, quantize, applyPalette }] = await Promise.all([loadExporter(), encoder]);
|
|
563
|
+
const options = await captureOptions();
|
|
564
|
+
const scale = Math.min(1, 1200 / options.width);
|
|
565
|
+
// The GIF plays at the chart's own pace: its playback's duration, in frames 40 ms apart
|
|
566
|
+
const step = 40, count = Math.max(2, Math.round((playback.duration ?? 15000) / step) + 1);
|
|
567
|
+
const capture = async () => {
|
|
568
|
+
const canvas = await domToCanvas(poster, { ...options, scale });
|
|
569
|
+
return canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
|
|
349
570
|
};
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (!textures.has(source)) textures.set(source, (async () => {
|
|
389
|
-
const image = new Image();
|
|
390
|
-
image.src = source;
|
|
391
|
-
await image.decode();
|
|
392
|
-
const canvas = document.createElement('canvas');
|
|
393
|
-
canvas.width = image.naturalWidth;
|
|
394
|
-
canvas.height = image.naturalHeight;
|
|
395
|
-
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
396
|
-
return canvas.toDataURL();
|
|
397
|
-
})());
|
|
398
|
-
background = background.replace(source, await textures.get(source));
|
|
571
|
+
clock = gifClock();
|
|
572
|
+
await clock.ready;
|
|
573
|
+
// The end and the start, each given a second to settle, for the palette; the start is also the first frame
|
|
574
|
+
playback.seek(1);
|
|
575
|
+
clock.advance(1000, 25);
|
|
576
|
+
const last = await capture();
|
|
577
|
+
playback.seek(0);
|
|
578
|
+
clock.advance(1000, 25);
|
|
579
|
+
const first = await capture();
|
|
580
|
+
const both = new Uint8ClampedArray(first.data.length + last.data.length);
|
|
581
|
+
both.set(first.data);
|
|
582
|
+
both.set(last.data, first.data.length);
|
|
583
|
+
const palette = quantize(both, 255), kept = palette.length;
|
|
584
|
+
// A flat color that fills a part of the poster keeps its exact value: it takes the nearest palette entry
|
|
585
|
+
const counts = new Map(), taken = new Set();
|
|
586
|
+
for (let j = 0; j < both.length; j += 4) {
|
|
587
|
+
const color = (both[j] << 16) | (both[j + 1] << 8) | both[j + 2];
|
|
588
|
+
counts.set(color, (counts.get(color) ?? 0) + 1);
|
|
589
|
+
}
|
|
590
|
+
for (const [color] of [...counts].filter(([, n]) => n > both.length / 8000).sort((a, b) => b[1] - a[1])) {
|
|
591
|
+
const rgb = [color >> 16, (color >> 8) & 255, color & 255];
|
|
592
|
+
let nearest = -1, distance = Infinity;
|
|
593
|
+
palette.forEach((entry, k) => {
|
|
594
|
+
const d = (entry[0] - rgb[0]) ** 2 + (entry[1] - rgb[1]) ** 2 + (entry[2] - rgb[2]) ** 2;
|
|
595
|
+
if (!taken.has(k) && d < distance) { nearest = k; distance = d; }
|
|
596
|
+
});
|
|
597
|
+
if (nearest >= 0) { palette[nearest] = rgb; taken.add(nearest); }
|
|
598
|
+
}
|
|
599
|
+
palette.push([255, 0, 255]);
|
|
600
|
+
const gif = GIFEncoder();
|
|
601
|
+
let previous = null;
|
|
602
|
+
for (let i = 0; i < count; i++) {
|
|
603
|
+
if (cancelled) return;
|
|
604
|
+
if (i > 0) {
|
|
605
|
+
playback.seek(i / (count - 1));
|
|
606
|
+
clock.advance(step);
|
|
607
|
+
// The last frame is held: it shows the end settled
|
|
608
|
+
if (i === count - 1) clock.advance(1000, 25);
|
|
399
609
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}),
|
|
415
|
-
});
|
|
416
|
-
if (!blob) throw new Error('No PNG was created');
|
|
417
|
-
url = URL.createObjectURL(blob);
|
|
418
|
-
const link = document.createElement('a');
|
|
419
|
-
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
420
|
-
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
|
|
421
|
-
link.href = url;
|
|
422
|
-
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
423
|
-
document.body.append(link);
|
|
424
|
-
link.click();
|
|
425
|
-
link.remove();
|
|
610
|
+
const image = i === 0 ? first : await capture();
|
|
611
|
+
const index = applyPalette(image.data, palette.slice(0, kept));
|
|
612
|
+
const pixels = previous ? index.map((p, j) => (p === previous[j] ? kept : p)) : index;
|
|
613
|
+
gif.writeFrame(pixels, image.width, image.height, {
|
|
614
|
+
palette: i === 0 ? palette : undefined, delay: i === 0 ? 1000 : i === count - 1 ? 3000 : step,
|
|
615
|
+
transparent: i > 0, transparentIndex: kept, dispose: 1,
|
|
616
|
+
});
|
|
617
|
+
previous = index;
|
|
618
|
+
const done = Math.round(((i + 1) / count) * 100);
|
|
619
|
+
button.innerHTML = `<span class="poster-download-progress" aria-hidden="true">${done}%</span>`;
|
|
620
|
+
label(`Cancel the GIF, ${done}% made`);
|
|
621
|
+
}
|
|
622
|
+
gif.finish();
|
|
623
|
+
save(new Blob([gif.bytes()], { type: 'image/gif' }), 'gif');
|
|
426
624
|
} catch (cause) {
|
|
427
|
-
|
|
428
|
-
retries++;
|
|
429
|
-
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
430
|
-
error.hidden = false;
|
|
625
|
+
fail('The GIF could not be made. Check your connection and try again.');
|
|
431
626
|
} finally {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
627
|
+
clock?.stop();
|
|
628
|
+
playback.seek(from);
|
|
629
|
+
end();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
button.addEventListener('click', event => {
|
|
633
|
+
event.stopPropagation();
|
|
634
|
+
if (busy) {
|
|
635
|
+
if (making === 'gif') cancelled = true;
|
|
636
|
+
return;
|
|
438
637
|
}
|
|
638
|
+
if (!playable()) return savePng();
|
|
639
|
+
// A key press is a click with no pointer detail: it opens the menu on its first item
|
|
640
|
+
if (menu.hidden) openMenu(event.detail === 0);
|
|
641
|
+
else closeMenu(event.detail === 0);
|
|
642
|
+
});
|
|
643
|
+
menu.addEventListener('click', event => {
|
|
644
|
+
const item = event.target.closest('[data-format]');
|
|
645
|
+
if (!item) return;
|
|
646
|
+
event.stopPropagation();
|
|
647
|
+
closeMenu(event.detail === 0);
|
|
648
|
+
if (item.dataset.format === 'gif') saveGif();
|
|
649
|
+
else savePng();
|
|
439
650
|
});
|
|
440
651
|
tools.append(actions);
|
|
441
652
|
});
|