@bezda/rhp-mcp 0.1.8 → 0.1.9

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 (42) hide show
  1. package/package.json +1 -1
  2. package/skill/SKILL.md +1 -1
  3. package/skill/recipes/area.html +349 -138
  4. package/skill/recipes/bar.html +349 -138
  5. package/skill/recipes/box-plot.html +349 -138
  6. package/skill/recipes/bubble.html +349 -138
  7. package/skill/recipes/bullet.html +349 -138
  8. package/skill/recipes/candlestick.html +349 -138
  9. package/skill/recipes/column.html +349 -138
  10. package/skill/recipes/dial.html +349 -138
  11. package/skill/recipes/diverging-bars.html +349 -138
  12. package/skill/recipes/donut.html +349 -138
  13. package/skill/recipes/dumbbell.html +349 -138
  14. package/skill/recipes/gantt.html +349 -138
  15. package/skill/recipes/grouped-bars.html +349 -138
  16. package/skill/recipes/heatmap.html +349 -138
  17. package/skill/recipes/histogram.html +349 -138
  18. package/skill/recipes/line.html +349 -138
  19. package/skill/recipes/live.html +349 -138
  20. package/skill/recipes/log-line.html +349 -138
  21. package/skill/recipes/lollipop.html +349 -138
  22. package/skill/recipes/multi-line.html +349 -138
  23. package/skill/recipes/pyramid.html +349 -138
  24. package/skill/recipes/race.html +363 -139
  25. package/skill/recipes/radial-bars.html +349 -138
  26. package/skill/recipes/scatter.html +349 -138
  27. package/skill/recipes/silhouettes.html +349 -138
  28. package/skill/recipes/slope.html +349 -138
  29. package/skill/recipes/sparklines.html +349 -138
  30. package/skill/recipes/stacked-100.html +349 -138
  31. package/skill/recipes/stacked-bars.html +349 -138
  32. package/skill/recipes/strip.html +349 -138
  33. package/skill/recipes/tape.html +415 -165
  34. package/skill/recipes/unit-stack.html +349 -138
  35. package/skill/recipes/violin.html +349 -138
  36. package/skill/recipes/waffle.html +349 -138
  37. package/skill/recipes/waterfall.html +349 -138
  38. package/skill/references/design.md +13 -0
  39. package/skill/references/download.md +372 -140
  40. package/skill/references/environments.md +1 -1
  41. package/skill/references/interaction.md +2 -0
  42. package/skill/references/pitfalls.md +1 -0
@@ -1,4 +1,4 @@
1
- # Download a poster as PNG
1
+ # Download a poster as PNG, or its playback as a GIF
2
2
 
3
3
  Every standalone HTML poster page includes an icon button above the poster, aligned with its right edge.
4
4
  Copy the following block before `</body>`, after the chart's module script.
@@ -12,12 +12,33 @@ The exporter loads only when clicked, waits for fonts, and saves the current pos
12
12
  It preserves authored margins and absolute-position rules, including adopted chart stylesheets, because Safari's computed offsets can be wrong inside subgrids.
13
13
  Activating the download control preserves the poster's selection, including keyboard focus transitions, and does not bubble into outside-click handlers.
14
14
  Like the chart's CDN import, the first download needs a connection.
15
+
16
+ A chart that plays through its data (a race, a year slider with Play) gives its poster a `playback`, and its download icon then opens a menu of two: **PNG image**, the poster as it is now, and **Animated GIF**, the whole playback.
17
+ The GIF plays the playback from start to end at the chart's own pace (its `duration`), at 25 frames a second, at the poster's size up to 1200 pixels wide, holds the first frame for a second and the last for three, and loops; a longer playback makes a longer and larger GIF.
18
+ While it is made, rhp's motion and the page's CSS animations run on a clock of the GIF's own that moves 40 ms a frame, so each frame shows the chart exactly as it is at that moment of playing, its glides and slides included.
19
+ Each frame is captured as the PNG is, with one palette for all in which the poster's flat colors keep their exact values, and stores only the pixels that changed, so a poster of flat colors stays a few megabytes.
20
+ While it is made, the icon shows the percentage done and a click on it cancels; either way the chart returns to where it was.
21
+ The menu works with a pointer, a finger and the keyboard (Enter or Space opens it on its first item, the arrow keys move, Escape closes it).
22
+ Give the poster its playback once the chart is mounted, with the playback's length in milliseconds, where it is (0 at the start, 1 at the end) and a way to show it paused at any point:
23
+
24
+ ```js
25
+ onMount(() => {
26
+ stage.closest(".poster").playback = {
27
+ duration: (LAST - FIRST) * YEAR_MS, // how long it takes to play
28
+ position: () => (t() - FIRST) / (LAST - FIRST), // where it is now, from 0 to 1
29
+ seek: (at) => { auto = false; pause(); setT(FIRST + at * (LAST - FIRST)); }, // paused at `at`, between steps too
30
+ };
31
+ });
32
+ ```
33
+
34
+ `seek` sets the playhead itself, between steps too, rather than going through a slider that snaps to them, so the GIF glides as the chart plays.
35
+ A chart with no `playback` keeps the single icon that saves the PNG.
15
36
  For an embedded chart inside an existing app, follow the app's export controls instead of adding page navigation.
16
37
 
17
38
  ```html
18
39
  <style id="poster-download-styles">
19
40
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
20
- .poster-actions { display: flex; align-items: center; margin-left: auto; }
41
+ .poster-actions { position: relative; display: flex; align-items: center; margin-left: auto; }
21
42
  .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; }
22
43
  .poster-actions svg { display: block; width: 20px; height: 20px; }
23
44
  .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
@@ -28,7 +49,14 @@ For an embedded chart inside an existing app, follow the app's export controls i
28
49
  .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
29
50
  .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
30
51
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
31
- @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
52
+ .poster-download-progress { font: 600 11px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
53
+ .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); }
54
+ .poster-download-menu[hidden] { display: none; }
55
+ .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; }
56
+ .poster-download-menu button span { font-weight: 400; font-size: 12.5px; opacity: .8; }
57
+ .poster-download-menu button:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
58
+ .poster-download-menu button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
59
+ @media print { .poster-download-tools, .poster-actions, .poster-download-menu, .poster-download-error { display: none !important; } }
32
60
  </style>
33
61
  <script type="module" id="poster-download-script">
34
62
  function addPosterFit(poster, button, changed) {
@@ -106,6 +134,8 @@ function addPosterDownloads() {
106
134
  tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
107
135
  }
108
136
  }
137
+ const ground = getComputedStyle(document.body).backgroundColor;
138
+ if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(ground)) tools.style.setProperty('--poster-tools-ground', ground);
109
139
  const button = document.createElement('button');
110
140
  button.type = 'button';
111
141
  button.className = 'poster-download';
@@ -151,154 +181,356 @@ function addPosterDownloads() {
151
181
  for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
152
182
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
153
183
  });
154
- let exporter, retries = 0, busy = false;
155
- button.addEventListener('click', async event => {
156
- event.stopPropagation();
157
- if (busy) return;
184
+ // A poster that plays through its data gives itself a playback ({ duration, position, seek }): its download then
185
+ // offers the poster as it is now, a PNG, or the whole playback, an animated GIF
186
+ let exporter, encoder, retries = 0, busy = false, making = null, cancelled = false;
187
+ const playable = () => typeof poster.playback?.seek === 'function';
188
+ const icon = button.innerHTML;
189
+ const menu = document.createElement('div');
190
+ menu.className = 'poster-download-menu';
191
+ menu.setAttribute('role', 'menu');
192
+ menu.setAttribute('aria-label', 'Download poster');
193
+ menu.hidden = true;
194
+ menu.innerHTML = '<button type="button" role="menuitem" data-format="png">PNG image<span>The poster as it is now</span></button>'
195
+ + '<button type="button" role="menuitem" data-format="gif">Animated GIF<span>The whole playback</span></button>';
196
+ const items = [...menu.querySelectorAll('[role="menuitem"]')];
197
+ actions.append(menu);
198
+ const label = text => { button.setAttribute('aria-label', text); button.title = text; };
199
+ const rest = () => {
200
+ label(playable() ? 'Download poster' : 'Download poster as PNG');
201
+ if (playable()) { button.setAttribute('aria-haspopup', 'menu'); button.setAttribute('aria-expanded', String(!menu.hidden)); }
202
+ };
203
+ rest();
204
+ const closeMenu = focus => {
205
+ if (menu.hidden) return;
206
+ menu.hidden = true;
207
+ rest();
208
+ if (focus) button.focus();
209
+ };
210
+ const openMenu = focus => {
211
+ menu.hidden = false;
212
+ rest();
213
+ if (focus) items[0].focus();
214
+ };
215
+ document.addEventListener('pointerdown', event => { if (!actions.contains(event.target)) closeMenu(false); }, true);
216
+ menu.addEventListener('keydown', event => {
217
+ const at = items.indexOf(document.activeElement);
218
+ const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[event.key];
219
+ if (to !== undefined) { event.preventDefault(); items[(to + items.length) % items.length].focus(); }
220
+ else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); }
221
+ else if (event.key === 'Tab') closeMenu(false);
222
+ });
223
+ menu.addEventListener('focusout', event => { if (!menu.contains(event.relatedTarget) && event.relatedTarget !== button) closeMenu(false); });
224
+ // The capture options, made once for a download and for every frame of a GIF
225
+ const loadExporter = () => (exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : '')));
226
+ async function captureOptions() {
227
+ await document.fonts.ready;
228
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
229
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
230
+ const autoMargins = new Set(), positioned = new Set();
231
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
232
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
233
+ const layoutRules = rules => [...rules].map(rule => {
234
+ if (rule.selectorText && rule.style) {
235
+ const margins = [...rule.style].filter(isMargin);
236
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
237
+ const insets = [...rule.style].filter(isInset);
238
+ if (insets.length) positioned.add(rule.selectorText);
239
+ const layout = [...margins, ...insets];
240
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
241
+ }
242
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
243
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
244
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
245
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
246
+ }).join('\n');
247
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
248
+ const layout = sheets.map(sheet => {
249
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
250
+ }).join('\n');
251
+ // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
252
+ const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
253
+ const restoreLayout = (source, clone) => {
254
+ if (clone.style && source !== poster) {
255
+ const margins = [...autoMargins].some(selector => source.matches(selector));
256
+ const computed = getComputedStyle(source);
257
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
258
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
259
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
260
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
261
+ if (insets) {
262
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
263
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
264
+ const axis = source.getAttribute('data-rhp-o');
265
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
266
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
267
+ }
268
+ }
269
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
270
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
271
+ };
272
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
273
+ const fontFiles = new Map();
274
+ const fontRules = await Promise.all(sheets.map(async sheet => {
275
+ let css;
276
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
277
+ catch (cause) {
278
+ if (!sheet.href) return '';
279
+ const response = await fetch(sheet.href);
280
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
281
+ css = await response.text();
282
+ }
283
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
284
+ return (await Promise.all(rules.map(async rule => {
285
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
286
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
287
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
288
+ const response = await fetch(source);
289
+ if (!response.ok) throw new Error('Font could not be loaded');
290
+ const blob = await response.blob();
291
+ return new Promise((resolve, reject) => {
292
+ const reader = new FileReader();
293
+ reader.onload = () => resolve(reader.result);
294
+ reader.onerror = reject;
295
+ reader.readAsDataURL(blob);
296
+ });
297
+ })());
298
+ rule = rule.replace(match[1], await fontFiles.get(source));
299
+ }
300
+ return rule;
301
+ }))).join('\n');
302
+ }));
303
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
304
+ // This keeps SVG filter fragment references local to their own image.
305
+ const textures = new Map();
306
+ const embedTexture = async (node) => {
307
+ if (!node.style) return;
308
+ let background = node.style.backgroundImage;
309
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
310
+ if (!textures.has(source)) textures.set(source, (async () => {
311
+ const image = new Image();
312
+ image.src = source;
313
+ await image.decode();
314
+ const canvas = document.createElement('canvas');
315
+ canvas.width = image.naturalWidth;
316
+ canvas.height = image.naturalHeight;
317
+ canvas.getContext('2d').drawImage(image, 0, 0);
318
+ return canvas.toDataURL();
319
+ })());
320
+ background = background.replace(source, await textures.get(source));
321
+ }
322
+ node.style.backgroundImage = background;
323
+ };
324
+ return {
325
+ ...fit.dimensions(), style: { margin: '0' }, filter: includeNode,
326
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
327
+ onCloneNode: clone => {
328
+ restoreLayout(poster, clone);
329
+ const style = document.createElement('style');
330
+ style.textContent = layout;
331
+ clone.prepend(style);
332
+ },
333
+ // CSS content's alternative text follows a slash and must not be painted.
334
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
335
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
336
+ }),
337
+ };
338
+ }
339
+ let url;
340
+ const save = (blob, extension) => {
341
+ url = URL.createObjectURL(blob);
342
+ const link = document.createElement('a');
343
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
344
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.' + extension;
345
+ link.href = url;
346
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
347
+ document.body.append(link);
348
+ link.click();
349
+ link.remove();
350
+ };
351
+ const begin = kind => {
158
352
  busy = true;
159
- button.setAttribute('aria-disabled', 'true');
353
+ making = kind;
160
354
  button.setAttribute('aria-busy', 'true');
161
355
  fitButton.setAttribute('aria-disabled', 'true');
162
- button.setAttribute('aria-label', 'Preparing poster PNG');
356
+ // A GIF takes a while: the button shows its progress and cancels it
357
+ if (kind === 'gif') label('Cancel the GIF');
358
+ else { button.setAttribute('aria-disabled', 'true'); label('Preparing poster PNG'); }
163
359
  error.hidden = true;
164
- let url;
360
+ };
361
+ const end = () => {
362
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
363
+ url = null;
364
+ busy = false;
365
+ making = null;
366
+ cancelled = false;
367
+ button.innerHTML = icon;
368
+ button.removeAttribute('aria-disabled');
369
+ button.removeAttribute('aria-busy');
370
+ fitButton.removeAttribute('aria-disabled');
371
+ rest();
372
+ };
373
+ const fail = message => {
374
+ exporter = null;
375
+ encoder = null;
376
+ retries++;
377
+ error.textContent = message;
378
+ error.hidden = false;
379
+ };
380
+ async function savePng() {
381
+ begin('png');
165
382
  try {
166
- exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
167
- const { domToBlob } = await exporter;
168
- await document.fonts.ready;
169
- // Preserve author layout rules where computed styles lose the grid's used geometry.
170
- // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
171
- const autoMargins = new Set(), positioned = new Set();
172
- const isMargin = name => name === 'margin' || name.startsWith('margin-');
173
- const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
174
- const layoutRules = rules => [...rules].map(rule => {
175
- if (rule.selectorText && rule.style) {
176
- const margins = [...rule.style].filter(isMargin);
177
- if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
178
- const insets = [...rule.style].filter(isInset);
179
- if (insets.length) positioned.add(rule.selectorText);
180
- const layout = [...margins, ...insets];
181
- return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
182
- }
183
- if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
184
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
185
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
186
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
187
- }).join('\n');
188
- const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
189
- const layout = sheets.map(sheet => {
190
- try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
191
- }).join('\n');
192
- // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
193
- const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
194
- const restoreLayout = (source, clone) => {
195
- if (clone.style && source !== poster) {
196
- const margins = [...autoMargins].some(selector => source.matches(selector));
197
- const computed = getComputedStyle(source);
198
- const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
199
- const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
200
- [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
201
- [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
202
- if (insets) {
203
- // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
204
- // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
205
- const axis = source.getAttribute('data-rhp-o');
206
- const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
207
- fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
383
+ const { domToBlob } = await loadExporter();
384
+ const blob = await domToBlob(poster, { ...(await captureOptions()), scale: 2 });
385
+ if (!blob) throw new Error('No PNG was created');
386
+ save(blob, 'png');
387
+ } catch (cause) {
388
+ fail('The PNG could not be saved. Check your connection and try again.');
389
+ } finally {
390
+ end();
391
+ }
392
+ }
393
+ // The GIF's clock: while it is made, rhp's motion (performance.now and animation frames) and the page's CSS
394
+ // animations run on a time of their own, which only moves on when a frame asks it to, so every frame shows the chart
395
+ // exactly as it is that many milliseconds into playing, its glides and slides included
396
+ const gifClock = () => {
397
+ const raf = window.requestAnimationFrame, caf = window.cancelAnimationFrame;
398
+ const queue = new Map(), started = new Map();
399
+ let time = performance.now(), id = 0, pump = 0;
400
+ const flush = () => {
401
+ const due = [...queue.values()];
402
+ queue.clear();
403
+ due.forEach(callback => { try { callback(time); } catch (cause) { reportError(cause); } });
404
+ };
405
+ // Callbacks still run every real frame, at the clock's time, so whatever waits for a frame is not held up
406
+ const loop = () => { flush(); pump = raf.call(window, loop); };
407
+ window.requestAnimationFrame = callback => { queue.set(++id, callback); return id; };
408
+ window.cancelAnimationFrame = n => queue.delete(n);
409
+ performance.now = () => time;
410
+ pump = raf.call(window, loop);
411
+ return {
412
+ // Two of the browser's frames: callbacks that were already waiting for one (rhp's ticker) come over to the clock
413
+ ready: new Promise(resolve => raf.call(window, () => raf.call(window, resolve))),
414
+ advance(ms, steps = 1) {
415
+ for (let i = 0; i < steps; i++) {
416
+ time += ms / steps;
417
+ // A frame's motion, then the style writes it schedules
418
+ flush(); flush(); flush();
419
+ for (const animation of document.getAnimations()) {
420
+ if (!started.has(animation)) started.set(animation, time - (animation.currentTime ?? 0));
421
+ animation.pause();
422
+ animation.currentTime = time - started.get(animation);
208
423
  }
209
424
  }
210
- const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
211
- [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
425
+ },
426
+ stop() {
427
+ caf.call(window, pump);
428
+ window.requestAnimationFrame = raf;
429
+ window.cancelAnimationFrame = caf;
430
+ delete performance.now;
431
+ for (const callback of queue.values()) raf.call(window, callback);
432
+ started.forEach((_, animation) => animation.play());
433
+ },
434
+ };
435
+ };
436
+ // The GIF: the playback at its own pace, 25 frames a second, at most 1200 pixels wide. One palette from the
437
+ // first and the last frame serves every frame; its last color is never drawn and marks the pixels a frame leaves as
438
+ // the frame before had them, so each frame stores only what changed.
439
+ async function saveGif() {
440
+ const playback = poster.playback;
441
+ const from = playback.position();
442
+ let clock = null;
443
+ begin('gif');
444
+ try {
445
+ encoder ||= import('https://cdn.jsdelivr.net/npm/gifenc@1.0.3/+esm' + (retries ? `?retry=${retries}` : ''));
446
+ const [{ domToCanvas }, { GIFEncoder, quantize, applyPalette }] = await Promise.all([loadExporter(), encoder]);
447
+ const options = await captureOptions();
448
+ const scale = Math.min(1, 1200 / options.width);
449
+ // The GIF plays at the chart's own pace: its playback's duration, in frames 40 ms apart
450
+ const step = 40, count = Math.max(2, Math.round((playback.duration ?? 15000) / step) + 1);
451
+ const capture = async () => {
452
+ const canvas = await domToCanvas(poster, { ...options, scale });
453
+ return canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
212
454
  };
213
- // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
214
- const fontFiles = new Map();
215
- const fontRules = await Promise.all(sheets.map(async sheet => {
216
- let css;
217
- try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
218
- catch (cause) {
219
- if (!sheet.href) return '';
220
- const response = await fetch(sheet.href);
221
- if (!response.ok) throw new Error('Font stylesheet could not be loaded');
222
- css = await response.text();
455
+ clock = gifClock();
456
+ await clock.ready;
457
+ // The end and the start, each given a second to settle, for the palette; the start is also the first frame
458
+ playback.seek(1);
459
+ clock.advance(1000, 25);
460
+ const last = await capture();
461
+ playback.seek(0);
462
+ clock.advance(1000, 25);
463
+ const first = await capture();
464
+ const both = new Uint8ClampedArray(first.data.length + last.data.length);
465
+ both.set(first.data);
466
+ both.set(last.data, first.data.length);
467
+ const palette = quantize(both, 255), kept = palette.length;
468
+ // A flat color that fills a part of the poster keeps its exact value: it takes the nearest palette entry
469
+ const counts = new Map(), taken = new Set();
470
+ for (let j = 0; j < both.length; j += 4) {
471
+ const color = (both[j] << 16) | (both[j + 1] << 8) | both[j + 2];
472
+ counts.set(color, (counts.get(color) ?? 0) + 1);
473
+ }
474
+ for (const [color] of [...counts].filter(([, n]) => n > both.length / 8000).sort((a, b) => b[1] - a[1])) {
475
+ const rgb = [color >> 16, (color >> 8) & 255, color & 255];
476
+ let nearest = -1, distance = Infinity;
477
+ palette.forEach((entry, k) => {
478
+ const d = (entry[0] - rgb[0]) ** 2 + (entry[1] - rgb[1]) ** 2 + (entry[2] - rgb[2]) ** 2;
479
+ if (!taken.has(k) && d < distance) { nearest = k; distance = d; }
480
+ });
481
+ if (nearest >= 0) { palette[nearest] = rgb; taken.add(nearest); }
482
+ }
483
+ palette.push([255, 0, 255]);
484
+ const gif = GIFEncoder();
485
+ let previous = null;
486
+ for (let i = 0; i < count; i++) {
487
+ if (cancelled) return;
488
+ if (i > 0) {
489
+ playback.seek(i / (count - 1));
490
+ clock.advance(step);
491
+ // The last frame is held: it shows the end settled
492
+ if (i === count - 1) clock.advance(1000, 25);
223
493
  }
224
- const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
225
- return (await Promise.all(rules.map(async rule => {
226
- for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
227
- const source = new URL(match[1], sheet.href || document.baseURI).href;
228
- if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
229
- const response = await fetch(source);
230
- if (!response.ok) throw new Error('Font could not be loaded');
231
- const blob = await response.blob();
232
- return new Promise((resolve, reject) => {
233
- const reader = new FileReader();
234
- reader.onload = () => resolve(reader.result);
235
- reader.onerror = reject;
236
- reader.readAsDataURL(blob);
237
- });
238
- })());
239
- rule = rule.replace(match[1], await fontFiles.get(source));
240
- }
241
- return rule;
242
- }))).join('\n');
243
- }));
244
- // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
245
- // This keeps SVG filter fragment references local to their own image.
246
- const textures = new Map();
247
- const embedTexture = async (node) => {
248
- if (!node.style) return;
249
- let background = node.style.backgroundImage;
250
- for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
251
- if (!textures.has(source)) textures.set(source, (async () => {
252
- const image = new Image();
253
- image.src = source;
254
- await image.decode();
255
- const canvas = document.createElement('canvas');
256
- canvas.width = image.naturalWidth;
257
- canvas.height = image.naturalHeight;
258
- canvas.getContext('2d').drawImage(image, 0, 0);
259
- return canvas.toDataURL();
260
- })());
261
- background = background.replace(source, await textures.get(source));
262
- }
263
- node.style.backgroundImage = background;
264
- };
265
- const blob = await domToBlob(poster, {
266
- ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
267
- font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
268
- onCloneNode: clone => {
269
- restoreLayout(poster, clone);
270
- const style = document.createElement('style');
271
- style.textContent = layout;
272
- clone.prepend(style);
273
- },
274
- // CSS content's alternative text follows a slash and must not be painted.
275
- onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
276
- style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
277
- }),
278
- });
279
- if (!blob) throw new Error('No PNG was created');
280
- url = URL.createObjectURL(blob);
281
- const link = document.createElement('a');
282
- const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
283
- link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
284
- link.href = url;
285
- link.addEventListener('click', event => event.stopPropagation(), { once: true });
286
- document.body.append(link);
287
- link.click();
288
- link.remove();
494
+ const image = i === 0 ? first : await capture();
495
+ const index = applyPalette(image.data, palette.slice(0, kept));
496
+ const pixels = previous ? index.map((p, j) => (p === previous[j] ? kept : p)) : index;
497
+ gif.writeFrame(pixels, image.width, image.height, {
498
+ palette: i === 0 ? palette : undefined, delay: i === 0 ? 1000 : i === count - 1 ? 3000 : step,
499
+ transparent: i > 0, transparentIndex: kept, dispose: 1,
500
+ });
501
+ previous = index;
502
+ const done = Math.round(((i + 1) / count) * 100);
503
+ button.innerHTML = `<span class="poster-download-progress" aria-hidden="true">${done}%</span>`;
504
+ label(`Cancel the GIF, ${done}% made`);
505
+ }
506
+ gif.finish();
507
+ save(new Blob([gif.bytes()], { type: 'image/gif' }), 'gif');
289
508
  } catch (cause) {
290
- exporter = null;
291
- retries++;
292
- error.textContent = 'The PNG could not be saved. Check your connection and try again.';
293
- error.hidden = false;
509
+ fail('The GIF could not be made. Check your connection and try again.');
294
510
  } finally {
295
- if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
296
- busy = false;
297
- button.removeAttribute('aria-disabled');
298
- button.removeAttribute('aria-busy');
299
- fitButton.removeAttribute('aria-disabled');
300
- button.setAttribute('aria-label', 'Download poster as PNG');
511
+ clock?.stop();
512
+ playback.seek(from);
513
+ end();
301
514
  }
515
+ }
516
+ button.addEventListener('click', event => {
517
+ event.stopPropagation();
518
+ if (busy) {
519
+ if (making === 'gif') cancelled = true;
520
+ return;
521
+ }
522
+ if (!playable()) return savePng();
523
+ // A key press is a click with no pointer detail: it opens the menu on its first item
524
+ if (menu.hidden) openMenu(event.detail === 0);
525
+ else closeMenu(event.detail === 0);
526
+ });
527
+ menu.addEventListener('click', event => {
528
+ const item = event.target.closest('[data-format]');
529
+ if (!item) return;
530
+ event.stopPropagation();
531
+ closeMenu(event.detail === 0);
532
+ if (item.dataset.format === 'gif') saveGif();
533
+ else savePng();
302
534
  });
303
535
  tools.append(actions);
304
536
  });
@@ -319,4 +551,4 @@ else {
319
551
  The block above is the maintained source for the recipe controls.
320
552
  After changing it, run `node scripts/sync-poster-download.mjs` from the rhp repository to update every recipe and existing example poster.
321
553
  Test an actual PNG download on a desktop and a phone, including a poster taller than the viewport, and verify that the image includes the bottom note and excludes the controls.
322
- The implementation uses [modern-screenshot](https://github.com/qq15725/modern-screenshot) to preserve computed styles, pseudo-elements, images and embedded fonts.
554
+ The implementation uses [modern-screenshot](https://github.com/qq15725/modern-screenshot) to preserve computed styles, pseudo-elements, images and embedded fonts, and [gifenc](https://github.com/mattdesl/gifenc) for the GIF.
@@ -236,7 +236,7 @@ For a poster page, copy the complete [PNG download block](download.md) before `<
236
236
  - Keep the module script inline.
237
237
  A page that loads its own module file (`<script type="module" src="chart.js">`) does not run from the disk in Chrome (a blocked cross-origin request) or in Safari; it runs in Firefox, and in every browser when the folder is served (`npx serve`).
238
238
  - `@bezda/rhp@2` in the URL takes the latest rhp 2 release.
239
- Pin the version (`@bezda/rhp@2.0.1`) for a page that must never change.
239
+ For a page that must never change, replace the `2` with the latest release's exact version, which `npm view @bezda/rhp version` prints.
240
240
 
241
241
  ## 4. Solid
242
242
 
@@ -738,6 +738,8 @@ render(() => html`<${League} />`, document.getElementById("chart"));
738
738
  The output keeps one width (`min-width` in the page's CSS): when "Round 9" became "Round 10", a wider output would shorten the slider and the handle would jump back.
739
739
  - The button's text says what it will do (Play or Pause), so it has no `aria-pressed`.
740
740
  - **Reduced motion:** rhp stops its own motion when the reader asks for less (values jump), and the playhead moves a whole round at a time, each round kept on screen for two seconds; your own CSS animations need `@media (prefers-reduced-motion: reduce) { … }`.
741
+ - **Tip, numbers outside the chart:** a year, a count or a readout that changes on every frame of the playback shifts the words beside it as its digits change width; consider tabular figures, or digits in equal-width slots when the font's tabular figures do not suit it (design.md, section 5).
742
+ - **Download:** give the poster a `playback` (`duration`, `position`, `seek`) once the chart is mounted, and its download icon offers the whole playback as an animated GIF beside the PNG; download.md has the lines, and the `race` recipe uses them.
741
743
 
742
744
  **Option: snap the slider to important points.**
743
745
  Snap only when the timeline has points worth reading exactly all along it: the `race` recipe snaps to its years, because each year is a measured value and the values between them are interpolated.
@@ -127,6 +127,7 @@ The checker warns (`layout-jump`) when a control or the chart moves after one of
127
127
  2. **A readout that wraps on a phone pushes the chart down**: give it a `min-height` for its longest text at 320px (`min-height: 2lh`, or `3lh`), counting the longest numbers and dates that gain a year ("Jan 4, 2027").
128
128
  3. **Numbers that change width shift what follows them**: use `font-variant-numeric: tabular-nums`, and give a column of numbers a fixed room; in an "auto" end room, align them across slats with `justify-self: stretch` and `justify-content: flex-end` on the label.
129
129
  In a readout, tabular figures alone are not enough when the number of digits changes ("$654" to "$84,176") or a date's month names differ in width: start each part where nothing before it changes width (a grid with a column per part, or each part on its own line), or give the value a box with a `min-width` in `ch` for the longest value it can show, right-aligned. A label or a unit after a value on the same line ("8,101 tons in 2026") slides at every step of a pointer moving along the chart.
130
+ Tip: when the font's tabular figures do not suit the poster (a slashed or dotted zero), keep its own digits and set each in an equal-width slot (design.md, section 5); a right-aligned number that changes, such as a large date, needs the same.
130
131
  4. **Every bar slides when a slat is lit**: the lit name turns bold in a `room.start` of "auto", which measures the widest name again and moves the plot.
131
132
  Change only the lit name's color, or keep its bold width at rest: room in px, or the bold text drawn hidden under the name (`.name::after { content: attr(data-name); display: block; height: 0; overflow: hidden; visibility: hidden; font-weight: 700; }`, with `data-name=${() => d.name}` on the Label; measured: the bars held still at 1280px, where a plain bold name moved them 10.8px).
132
133
  5. **A button whose text changes (Play, Pause) moves its neighbors**: stack both texts in one grid cell, the hidden one `visibility: hidden`, so the button is always as wide as the longer one.