@bezda/rhp-mcp 0.1.7 → 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 (50) hide show
  1. package/package.json +2 -2
  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/api.md +4 -2
  39. package/skill/references/design.md +13 -0
  40. package/skill/references/download.md +372 -140
  41. package/skill/references/environments.md +1 -1
  42. package/skill/references/interaction.md +2 -0
  43. package/skill/references/performance.md +45 -0
  44. package/skill/references/pitfalls.md +9 -0
  45. package/src/check/README.md +2 -1
  46. package/src/check/findings.js +17 -1
  47. package/src/check/inpage.js +53 -5
  48. package/src/check/report.js +1 -1
  49. package/src/check/source.js +2 -1
  50. package/src/guard/rules.js +2 -2
@@ -125,7 +125,7 @@ render(() => html`<${ProfitBridge} />`, document.getElementById("chart"));
125
125
  </script>
126
126
  <style id="poster-download-styles">
127
127
  .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
128
- .poster-actions { display: flex; align-items: center; margin-left: auto; }
128
+ .poster-actions { position: relative; display: flex; align-items: center; margin-left: auto; }
129
129
  .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; }
130
130
  .poster-actions svg { display: block; width: 20px; height: 20px; }
131
131
  .poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
@@ -136,7 +136,14 @@ render(() => html`<${ProfitBridge} />`, document.getElementById("chart"));
136
136
  .poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
137
137
  .poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
138
138
  .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
139
- @media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
139
+ .poster-download-progress { font: 600 11px/1 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
140
+ .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); }
141
+ .poster-download-menu[hidden] { display: none; }
142
+ .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; }
143
+ .poster-download-menu button span { font-weight: 400; font-size: 12.5px; opacity: .8; }
144
+ .poster-download-menu button:hover { background: color-mix(in srgb, currentColor 9%, transparent); }
145
+ .poster-download-menu button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
146
+ @media print { .poster-download-tools, .poster-actions, .poster-download-menu, .poster-download-error { display: none !important; } }
140
147
  </style>
141
148
  <script type="module" id="poster-download-script">
142
149
  function addPosterFit(poster, button, changed) {
@@ -214,6 +221,8 @@ function addPosterDownloads() {
214
221
  tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
215
222
  }
216
223
  }
224
+ const ground = getComputedStyle(document.body).backgroundColor;
225
+ if (!/^(transparent|rgba\(0, 0, 0, 0\))$/.test(ground)) tools.style.setProperty('--poster-tools-ground', ground);
217
226
  const button = document.createElement('button');
218
227
  button.type = 'button';
219
228
  button.className = 'poster-download';
@@ -259,154 +268,356 @@ function addPosterDownloads() {
259
268
  for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
260
269
  if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
261
270
  });
262
- let exporter, retries = 0, busy = false;
263
- button.addEventListener('click', async event => {
264
- event.stopPropagation();
265
- if (busy) return;
271
+ // A poster that plays through its data gives itself a playback ({ duration, position, seek }): its download then
272
+ // offers the poster as it is now, a PNG, or the whole playback, an animated GIF
273
+ let exporter, encoder, retries = 0, busy = false, making = null, cancelled = false;
274
+ const playable = () => typeof poster.playback?.seek === 'function';
275
+ const icon = button.innerHTML;
276
+ const menu = document.createElement('div');
277
+ menu.className = 'poster-download-menu';
278
+ menu.setAttribute('role', 'menu');
279
+ menu.setAttribute('aria-label', 'Download poster');
280
+ menu.hidden = true;
281
+ menu.innerHTML = '<button type="button" role="menuitem" data-format="png">PNG image<span>The poster as it is now</span></button>'
282
+ + '<button type="button" role="menuitem" data-format="gif">Animated GIF<span>The whole playback</span></button>';
283
+ const items = [...menu.querySelectorAll('[role="menuitem"]')];
284
+ actions.append(menu);
285
+ const label = text => { button.setAttribute('aria-label', text); button.title = text; };
286
+ const rest = () => {
287
+ label(playable() ? 'Download poster' : 'Download poster as PNG');
288
+ if (playable()) { button.setAttribute('aria-haspopup', 'menu'); button.setAttribute('aria-expanded', String(!menu.hidden)); }
289
+ };
290
+ rest();
291
+ const closeMenu = focus => {
292
+ if (menu.hidden) return;
293
+ menu.hidden = true;
294
+ rest();
295
+ if (focus) button.focus();
296
+ };
297
+ const openMenu = focus => {
298
+ menu.hidden = false;
299
+ rest();
300
+ if (focus) items[0].focus();
301
+ };
302
+ document.addEventListener('pointerdown', event => { if (!actions.contains(event.target)) closeMenu(false); }, true);
303
+ menu.addEventListener('keydown', event => {
304
+ const at = items.indexOf(document.activeElement);
305
+ const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[event.key];
306
+ if (to !== undefined) { event.preventDefault(); items[(to + items.length) % items.length].focus(); }
307
+ else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); }
308
+ else if (event.key === 'Tab') closeMenu(false);
309
+ });
310
+ menu.addEventListener('focusout', event => { if (!menu.contains(event.relatedTarget) && event.relatedTarget !== button) closeMenu(false); });
311
+ // The capture options, made once for a download and for every frame of a GIF
312
+ const loadExporter = () => (exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : '')));
313
+ async function captureOptions() {
314
+ await document.fonts.ready;
315
+ // Preserve author layout rules where computed styles lose the grid's used geometry.
316
+ // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
317
+ const autoMargins = new Set(), positioned = new Set();
318
+ const isMargin = name => name === 'margin' || name.startsWith('margin-');
319
+ const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
320
+ const layoutRules = rules => [...rules].map(rule => {
321
+ if (rule.selectorText && rule.style) {
322
+ const margins = [...rule.style].filter(isMargin);
323
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
324
+ const insets = [...rule.style].filter(isInset);
325
+ if (insets.length) positioned.add(rule.selectorText);
326
+ const layout = [...margins, ...insets];
327
+ return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
328
+ }
329
+ if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
330
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
331
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
332
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
333
+ }).join('\n');
334
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
335
+ const layout = sheets.map(sheet => {
336
+ try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
337
+ }).join('\n');
338
+ // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
339
+ const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
340
+ const restoreLayout = (source, clone) => {
341
+ if (clone.style && source !== poster) {
342
+ const margins = [...autoMargins].some(selector => source.matches(selector));
343
+ const computed = getComputedStyle(source);
344
+ const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
345
+ const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
346
+ [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
347
+ [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
348
+ if (insets) {
349
+ // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
350
+ // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
351
+ const axis = source.getAttribute('data-rhp-o');
352
+ const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
353
+ fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
354
+ }
355
+ }
356
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
357
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
358
+ };
359
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
360
+ const fontFiles = new Map();
361
+ const fontRules = await Promise.all(sheets.map(async sheet => {
362
+ let css;
363
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
364
+ catch (cause) {
365
+ if (!sheet.href) return '';
366
+ const response = await fetch(sheet.href);
367
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
368
+ css = await response.text();
369
+ }
370
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
371
+ return (await Promise.all(rules.map(async rule => {
372
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
373
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
374
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
375
+ const response = await fetch(source);
376
+ if (!response.ok) throw new Error('Font could not be loaded');
377
+ const blob = await response.blob();
378
+ return new Promise((resolve, reject) => {
379
+ const reader = new FileReader();
380
+ reader.onload = () => resolve(reader.result);
381
+ reader.onerror = reject;
382
+ reader.readAsDataURL(blob);
383
+ });
384
+ })());
385
+ rule = rule.replace(match[1], await fontFiles.get(source));
386
+ }
387
+ return rule;
388
+ }))).join('\n');
389
+ }));
390
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
391
+ // This keeps SVG filter fragment references local to their own image.
392
+ const textures = new Map();
393
+ const embedTexture = async (node) => {
394
+ if (!node.style) return;
395
+ let background = node.style.backgroundImage;
396
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
397
+ if (!textures.has(source)) textures.set(source, (async () => {
398
+ const image = new Image();
399
+ image.src = source;
400
+ await image.decode();
401
+ const canvas = document.createElement('canvas');
402
+ canvas.width = image.naturalWidth;
403
+ canvas.height = image.naturalHeight;
404
+ canvas.getContext('2d').drawImage(image, 0, 0);
405
+ return canvas.toDataURL();
406
+ })());
407
+ background = background.replace(source, await textures.get(source));
408
+ }
409
+ node.style.backgroundImage = background;
410
+ };
411
+ return {
412
+ ...fit.dimensions(), style: { margin: '0' }, filter: includeNode,
413
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
414
+ onCloneNode: clone => {
415
+ restoreLayout(poster, clone);
416
+ const style = document.createElement('style');
417
+ style.textContent = layout;
418
+ clone.prepend(style);
419
+ },
420
+ // CSS content's alternative text follows a slash and must not be painted.
421
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
422
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
423
+ }),
424
+ };
425
+ }
426
+ let url;
427
+ const save = (blob, extension) => {
428
+ url = URL.createObjectURL(blob);
429
+ const link = document.createElement('a');
430
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
431
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.' + extension;
432
+ link.href = url;
433
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
434
+ document.body.append(link);
435
+ link.click();
436
+ link.remove();
437
+ };
438
+ const begin = kind => {
266
439
  busy = true;
267
- button.setAttribute('aria-disabled', 'true');
440
+ making = kind;
268
441
  button.setAttribute('aria-busy', 'true');
269
442
  fitButton.setAttribute('aria-disabled', 'true');
270
- button.setAttribute('aria-label', 'Preparing poster PNG');
443
+ // A GIF takes a while: the button shows its progress and cancels it
444
+ if (kind === 'gif') label('Cancel the GIF');
445
+ else { button.setAttribute('aria-disabled', 'true'); label('Preparing poster PNG'); }
271
446
  error.hidden = true;
272
- let url;
447
+ };
448
+ const end = () => {
449
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
450
+ url = null;
451
+ busy = false;
452
+ making = null;
453
+ cancelled = false;
454
+ button.innerHTML = icon;
455
+ button.removeAttribute('aria-disabled');
456
+ button.removeAttribute('aria-busy');
457
+ fitButton.removeAttribute('aria-disabled');
458
+ rest();
459
+ };
460
+ const fail = message => {
461
+ exporter = null;
462
+ encoder = null;
463
+ retries++;
464
+ error.textContent = message;
465
+ error.hidden = false;
466
+ };
467
+ async function savePng() {
468
+ begin('png');
273
469
  try {
274
- exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
275
- const { domToBlob } = await exporter;
276
- await document.fonts.ready;
277
- // Preserve author layout rules where computed styles lose the grid's used geometry.
278
- // Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
279
- const autoMargins = new Set(), positioned = new Set();
280
- const isMargin = name => name === 'margin' || name.startsWith('margin-');
281
- const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
282
- const layoutRules = rules => [...rules].map(rule => {
283
- if (rule.selectorText && rule.style) {
284
- const margins = [...rule.style].filter(isMargin);
285
- if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
286
- const insets = [...rule.style].filter(isInset);
287
- if (insets.length) positioned.add(rule.selectorText);
288
- const layout = [...margins, ...insets];
289
- return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
290
- }
291
- if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
292
- if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
293
- if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
294
- return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
295
- }).join('\n');
296
- const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
297
- const layout = sheets.map(sheet => {
298
- try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
299
- }).join('\n');
300
- // Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
301
- const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
302
- const restoreLayout = (source, clone) => {
303
- if (clone.style && source !== poster) {
304
- const margins = [...autoMargins].some(selector => source.matches(selector));
305
- const computed = getComputedStyle(source);
306
- const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
307
- const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
308
- [...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
309
- [...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
310
- if (insets) {
311
- // Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
312
- // Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
313
- const axis = source.getAttribute('data-rhp-o');
314
- const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
315
- fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
470
+ const { domToBlob } = await loadExporter();
471
+ const blob = await domToBlob(poster, { ...(await captureOptions()), scale: 2 });
472
+ if (!blob) throw new Error('No PNG was created');
473
+ save(blob, 'png');
474
+ } catch (cause) {
475
+ fail('The PNG could not be saved. Check your connection and try again.');
476
+ } finally {
477
+ end();
478
+ }
479
+ }
480
+ // The GIF's clock: while it is made, rhp's motion (performance.now and animation frames) and the page's CSS
481
+ // animations run on a time of their own, which only moves on when a frame asks it to, so every frame shows the chart
482
+ // exactly as it is that many milliseconds into playing, its glides and slides included
483
+ const gifClock = () => {
484
+ const raf = window.requestAnimationFrame, caf = window.cancelAnimationFrame;
485
+ const queue = new Map(), started = new Map();
486
+ let time = performance.now(), id = 0, pump = 0;
487
+ const flush = () => {
488
+ const due = [...queue.values()];
489
+ queue.clear();
490
+ due.forEach(callback => { try { callback(time); } catch (cause) { reportError(cause); } });
491
+ };
492
+ // Callbacks still run every real frame, at the clock's time, so whatever waits for a frame is not held up
493
+ const loop = () => { flush(); pump = raf.call(window, loop); };
494
+ window.requestAnimationFrame = callback => { queue.set(++id, callback); return id; };
495
+ window.cancelAnimationFrame = n => queue.delete(n);
496
+ performance.now = () => time;
497
+ pump = raf.call(window, loop);
498
+ return {
499
+ // Two of the browser's frames: callbacks that were already waiting for one (rhp's ticker) come over to the clock
500
+ ready: new Promise(resolve => raf.call(window, () => raf.call(window, resolve))),
501
+ advance(ms, steps = 1) {
502
+ for (let i = 0; i < steps; i++) {
503
+ time += ms / steps;
504
+ // A frame's motion, then the style writes it schedules
505
+ flush(); flush(); flush();
506
+ for (const animation of document.getAnimations()) {
507
+ if (!started.has(animation)) started.set(animation, time - (animation.currentTime ?? 0));
508
+ animation.pause();
509
+ animation.currentTime = time - started.get(animation);
316
510
  }
317
511
  }
318
- const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
319
- [...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
512
+ },
513
+ stop() {
514
+ caf.call(window, pump);
515
+ window.requestAnimationFrame = raf;
516
+ window.cancelAnimationFrame = caf;
517
+ delete performance.now;
518
+ for (const callback of queue.values()) raf.call(window, callback);
519
+ started.forEach((_, animation) => animation.play());
520
+ },
521
+ };
522
+ };
523
+ // The GIF: the playback at its own pace, 25 frames a second, at most 1200 pixels wide. One palette from the
524
+ // first and the last frame serves every frame; its last color is never drawn and marks the pixels a frame leaves as
525
+ // the frame before had them, so each frame stores only what changed.
526
+ async function saveGif() {
527
+ const playback = poster.playback;
528
+ const from = playback.position();
529
+ let clock = null;
530
+ begin('gif');
531
+ try {
532
+ encoder ||= import('https://cdn.jsdelivr.net/npm/gifenc@1.0.3/+esm' + (retries ? `?retry=${retries}` : ''));
533
+ const [{ domToCanvas }, { GIFEncoder, quantize, applyPalette }] = await Promise.all([loadExporter(), encoder]);
534
+ const options = await captureOptions();
535
+ const scale = Math.min(1, 1200 / options.width);
536
+ // The GIF plays at the chart's own pace: its playback's duration, in frames 40 ms apart
537
+ const step = 40, count = Math.max(2, Math.round((playback.duration ?? 15000) / step) + 1);
538
+ const capture = async () => {
539
+ const canvas = await domToCanvas(poster, { ...options, scale });
540
+ return canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
320
541
  };
321
- // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
322
- const fontFiles = new Map();
323
- const fontRules = await Promise.all(sheets.map(async sheet => {
324
- let css;
325
- try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
326
- catch (cause) {
327
- if (!sheet.href) return '';
328
- const response = await fetch(sheet.href);
329
- if (!response.ok) throw new Error('Font stylesheet could not be loaded');
330
- css = await response.text();
331
- }
332
- const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
333
- return (await Promise.all(rules.map(async rule => {
334
- for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
335
- const source = new URL(match[1], sheet.href || document.baseURI).href;
336
- if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
337
- const response = await fetch(source);
338
- if (!response.ok) throw new Error('Font could not be loaded');
339
- const blob = await response.blob();
340
- return new Promise((resolve, reject) => {
341
- const reader = new FileReader();
342
- reader.onload = () => resolve(reader.result);
343
- reader.onerror = reject;
344
- reader.readAsDataURL(blob);
345
- });
346
- })());
347
- rule = rule.replace(match[1], await fontFiles.get(source));
348
- }
349
- return rule;
350
- }))).join('\n');
351
- }));
352
- // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
353
- // This keeps SVG filter fragment references local to their own image.
354
- const textures = new Map();
355
- const embedTexture = async (node) => {
356
- if (!node.style) return;
357
- let background = node.style.backgroundImage;
358
- for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
359
- if (!textures.has(source)) textures.set(source, (async () => {
360
- const image = new Image();
361
- image.src = source;
362
- await image.decode();
363
- const canvas = document.createElement('canvas');
364
- canvas.width = image.naturalWidth;
365
- canvas.height = image.naturalHeight;
366
- canvas.getContext('2d').drawImage(image, 0, 0);
367
- return canvas.toDataURL();
368
- })());
369
- background = background.replace(source, await textures.get(source));
542
+ clock = gifClock();
543
+ await clock.ready;
544
+ // The end and the start, each given a second to settle, for the palette; the start is also the first frame
545
+ playback.seek(1);
546
+ clock.advance(1000, 25);
547
+ const last = await capture();
548
+ playback.seek(0);
549
+ clock.advance(1000, 25);
550
+ const first = await capture();
551
+ const both = new Uint8ClampedArray(first.data.length + last.data.length);
552
+ both.set(first.data);
553
+ both.set(last.data, first.data.length);
554
+ const palette = quantize(both, 255), kept = palette.length;
555
+ // A flat color that fills a part of the poster keeps its exact value: it takes the nearest palette entry
556
+ const counts = new Map(), taken = new Set();
557
+ for (let j = 0; j < both.length; j += 4) {
558
+ const color = (both[j] << 16) | (both[j + 1] << 8) | both[j + 2];
559
+ counts.set(color, (counts.get(color) ?? 0) + 1);
560
+ }
561
+ for (const [color] of [...counts].filter(([, n]) => n > both.length / 8000).sort((a, b) => b[1] - a[1])) {
562
+ const rgb = [color >> 16, (color >> 8) & 255, color & 255];
563
+ let nearest = -1, distance = Infinity;
564
+ palette.forEach((entry, k) => {
565
+ const d = (entry[0] - rgb[0]) ** 2 + (entry[1] - rgb[1]) ** 2 + (entry[2] - rgb[2]) ** 2;
566
+ if (!taken.has(k) && d < distance) { nearest = k; distance = d; }
567
+ });
568
+ if (nearest >= 0) { palette[nearest] = rgb; taken.add(nearest); }
569
+ }
570
+ palette.push([255, 0, 255]);
571
+ const gif = GIFEncoder();
572
+ let previous = null;
573
+ for (let i = 0; i < count; i++) {
574
+ if (cancelled) return;
575
+ if (i > 0) {
576
+ playback.seek(i / (count - 1));
577
+ clock.advance(step);
578
+ // The last frame is held: it shows the end settled
579
+ if (i === count - 1) clock.advance(1000, 25);
370
580
  }
371
- node.style.backgroundImage = background;
372
- };
373
- const blob = await domToBlob(poster, {
374
- ...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
375
- font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
376
- onCloneNode: clone => {
377
- restoreLayout(poster, clone);
378
- const style = document.createElement('style');
379
- style.textContent = layout;
380
- clone.prepend(style);
381
- },
382
- // CSS content's alternative text follows a slash and must not be painted.
383
- onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
384
- style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
385
- }),
386
- });
387
- if (!blob) throw new Error('No PNG was created');
388
- url = URL.createObjectURL(blob);
389
- const link = document.createElement('a');
390
- const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
391
- link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
392
- link.href = url;
393
- link.addEventListener('click', event => event.stopPropagation(), { once: true });
394
- document.body.append(link);
395
- link.click();
396
- link.remove();
581
+ const image = i === 0 ? first : await capture();
582
+ const index = applyPalette(image.data, palette.slice(0, kept));
583
+ const pixels = previous ? index.map((p, j) => (p === previous[j] ? kept : p)) : index;
584
+ gif.writeFrame(pixels, image.width, image.height, {
585
+ palette: i === 0 ? palette : undefined, delay: i === 0 ? 1000 : i === count - 1 ? 3000 : step,
586
+ transparent: i > 0, transparentIndex: kept, dispose: 1,
587
+ });
588
+ previous = index;
589
+ const done = Math.round(((i + 1) / count) * 100);
590
+ button.innerHTML = `<span class="poster-download-progress" aria-hidden="true">${done}%</span>`;
591
+ label(`Cancel the GIF, ${done}% made`);
592
+ }
593
+ gif.finish();
594
+ save(new Blob([gif.bytes()], { type: 'image/gif' }), 'gif');
397
595
  } catch (cause) {
398
- exporter = null;
399
- retries++;
400
- error.textContent = 'The PNG could not be saved. Check your connection and try again.';
401
- error.hidden = false;
596
+ fail('The GIF could not be made. Check your connection and try again.');
402
597
  } finally {
403
- if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
404
- busy = false;
405
- button.removeAttribute('aria-disabled');
406
- button.removeAttribute('aria-busy');
407
- fitButton.removeAttribute('aria-disabled');
408
- button.setAttribute('aria-label', 'Download poster as PNG');
598
+ clock?.stop();
599
+ playback.seek(from);
600
+ end();
601
+ }
602
+ }
603
+ button.addEventListener('click', event => {
604
+ event.stopPropagation();
605
+ if (busy) {
606
+ if (making === 'gif') cancelled = true;
607
+ return;
409
608
  }
609
+ if (!playable()) return savePng();
610
+ // A key press is a click with no pointer detail: it opens the menu on its first item
611
+ if (menu.hidden) openMenu(event.detail === 0);
612
+ else closeMenu(event.detail === 0);
613
+ });
614
+ menu.addEventListener('click', event => {
615
+ const item = event.target.closest('[data-format]');
616
+ if (!item) return;
617
+ event.stopPropagation();
618
+ closeMenu(event.detail === 0);
619
+ if (item.dataset.format === 'gif') saveGif();
620
+ else savePng();
410
621
  });
411
622
  tools.append(actions);
412
623
  });
@@ -43,7 +43,7 @@ Both `@bezda/rhp` and `@bezda/rhp/standalone` export rhp's whole API:
43
43
  - components: `Chart`, `Plot`, `Scale`, `Axis`, `Theme`, `Poster`
44
44
  - blocks: `Bar`, `Dot`, `Tick`, `Label`, `Cell`, `Place`, `Area`, `Line`
45
45
  - experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in rhp 2.0.3 and later
46
- - functions: `slat`, `restyle`, `linkedCss`, `shape`, `at`, `useOrientation`, `series`, `cycle`, `sortBy`, `extent`, `every`, `nice`, `stackUp`, `shares`, `running`, `summary`, `bins`, `density`, `animated`, `curve`, `drawing`
46
+ - functions: `slat`, `restyle`, `linkedCss`, `noCssGuard`, `inheritPosition`, `shape`, `at`, `useOrientation`, `series`, `cycle`, `sortBy`, `extent`, `every`, `nice`, `stackUp`, `shares`, `running`, `summary`, `bins`, `density`, `animated`, `curve`, `drawing`
47
47
  - the object `THEME` (the default theme)
48
48
 
49
49
  `@bezda/rhp/standalone` adds these from Solid, and nothing else: `render`, `html`, `createSignal`, `createMemo`, `createEffect`, `createRoot`, `createSelector`, `createComputed`, `on`, `onMount`, `onCleanup`, `batch`, `untrack`, `mergeProps`, `splitProps`, `For`, `Index`, `Show`, `Switch`, `Match`, `createStore`, `reconcile`, `produce`, `unwrap`.
@@ -169,6 +169,7 @@ export default function FruitChart() {
169
169
  | `animate` | `true` or `{ duration, ease, slide }` | off | the JS version of motion (section 12); the Plots inside take it |
170
170
  | `theme` | theme object (section 8) | `THEME` | colors and font, over those of a `<Theme>` around it |
171
171
  | `static` | boolean | `false` | for data that never changes: slats are drawn once and keep no signals; a data change draws them all again, without motion |
172
+ | `noCssGuard` | boolean | `false` | faster, and page CSS reaches the chart (performance.md) |
172
173
  | `cross` | `[min, max]` | none | a second axis across the band (scatter plots, line charts; section 7) |
173
174
  | `crossTicks`, `crossFormat` | like `ticks`, `format` | about 5, the number | the second axis |
174
175
  | `crossGrid` | boolean | `true` | `false` hides only the second axis' grid lines, keeping its numbers. |
@@ -1219,7 +1220,7 @@ Even where page CSS reaches rhp's custom properties, set them in the slat's `css
1219
1220
  | `--rhp-slide-time`, `--rhp-slide-ease` | `.3s`, `ease-in-out` | how slats slide to new places |
1220
1221
  | `--rhp-gutter-max` | `40cqw` | with `room: "auto"`, the widest a name (and the room) can be |
1221
1222
 
1222
- **Variables a slat's CSS reads**: the theme (`--rhp-ink`, `--rhp-muted`, `--rhp-grid`, `--rhp-surface`, `--rhp-series-1`…, `--rhp-positive`, `--rhp-negative`, `--rhp-low`, `--rhp-high`, `--rhp-font`), `--rhp-color` (the block's color), `--rhp-toward-end` (the direction from a Bar's start to its end: `linear-gradient(var(--rhp-toward-end), …)`), `--rhp-p` (where a Dot, Tick, Label or Place sits on the scale, 0 to 1: a Label's room to the scale's end is `calc((1 - var(--rhp-p)) * 100%)`), `--rhp-lo` and `--rhp-hi` (where a Bar, an Area or a Line starts and ends on the scale, 0 to 1, clipped to it: the donut and radial-bars recipes turn a Bar's into angles), `--rhp-min`, `--rhp-max`, a block's `--rhp-from`, `--rhp-to`, `--rhp-at`, `--rhp-value`, a slat's `--rhp-position`, and `--rhp-n` (how many places the slat's Plot has on screen).
1223
+ **Variables a slat's CSS reads**: the theme (`--rhp-ink`, `--rhp-muted`, `--rhp-grid`, `--rhp-surface`, `--rhp-series-1`…, `--rhp-positive`, `--rhp-negative`, `--rhp-low`, `--rhp-high`, `--rhp-font`), `--rhp-color` (the block's color), `--rhp-toward-end` (the direction from a Bar's start to its end: `linear-gradient(var(--rhp-toward-end), …)`), `--rhp-p` (where a Dot, Tick, Label or Place sits on the scale, 0 to 1: a Label's room to the scale's end is `calc((1 - var(--rhp-p)) * 100%)`), `--rhp-lo` and `--rhp-hi` (where a Bar, an Area or a Line starts and ends on the scale, 0 to 1, clipped to it: the donut and radial-bars recipes turn a Bar's into angles), `--rhp-min`, `--rhp-max`, a block's `--rhp-from`, `--rhp-to`, `--rhp-at`, `--rhp-value`, a slat's `--rhp-position` (its place in its Plot, 0 for the first), and `--rhp-n` (how many places the slat's Plot has on screen).
1223
1224
 
1224
1225
  **Styling the axis.** Page CSS and slat CSS cannot reach rhp's grid lines and numbers.
1225
1226
  Their colors are the theme's `grid` and `muted`, their font the theme's `font`; `ticks` and `format` choose them (`format` may return an element with a class, which page CSS styles).
@@ -1682,3 +1683,4 @@ Every one of these was found by running rhp 2.0.1.
1682
1683
  Write the space as `${" "}`, or keep it next to other text (`<b>Name</b>: <em>12</em>`).
1683
1684
  36. **A Plot sits at `z-index: 1`.**
1684
1685
  A page element that overlaps a chart (a readout pinned with `position: sticky`, a menu that opens over the chart) is drawn under the slats unless its own `z-index` is 2 or more.
1686
+ 37. **`var(--rhp-position)` in a `style` prop inside a slat is empty** (rhp 2.1 and later): read it in the slat's `css`.
@@ -444,6 +444,19 @@ More lettering to start from, by where it comes from (each a Google Font; check
444
444
  Nothing in a chart under 11px, and nothing anywhere under 9px (the checker's floor).
445
445
 
446
446
  Numbers line up when they use `font-variant-numeric: tabular-nums` (values, readouts, tables; rhp's axis already does).
447
+
448
+ **Tip: numbers that change rapidly outside the chart.** A count, a date or a readout that runs with playback or follows the pointer changes width with its digits, and the words beside it shift on every frame (a right-aligned date shifts its left edge).
449
+ Consider the font's tabular figures; when they do not suit the poster (Mona Sans, for one, draws its equal-width zero slashed), keep the font's own digits and give each a slot as wide as the widest digit:
450
+
451
+ ```js
452
+ // Each digit in a slot as wide as the widest (measure it: .625em fits Mona Sans), so the words around the number keep still
453
+ const steady = (text) => [...text].map((c) => (c >= "0" && c <= "9" ? html`<span class="digit">${c}</span>` : c));
454
+ // CSS: .digit { display: inline-block; width: .625em; text-align: center; }
455
+ // html`<h2 class="count">${() => steady(count.format(Math.round(gained())))} stars since launch</h2>`
456
+ ```
457
+
458
+ The words still move when the number gains a digit, or a date its month: give that part a `min-width` (pitfalls.md, "The layout jumps").
459
+ To check, read the position of the word after the number while it plays (measured on the skill repo race: 58 positions over 60 days before, 1 after).
447
460
  Format every number with an `Intl.NumberFormat` made once, in the user's locale when you know it:
448
461
 
449
462
  ```js