@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
|
@@ -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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
440
|
+
making = kind;
|
|
268
441
|
button.setAttribute('aria-busy', 'true');
|
|
269
442
|
fitButton.setAttribute('aria-disabled', 'true');
|
|
270
|
-
button
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
});
|
package/skill/references/api.md
CHANGED
|
@@ -659,6 +659,31 @@ const ChangeChart = () => html`
|
|
|
659
659
|
render(ChangeChart, document.getElementById("chart"));
|
|
660
660
|
```
|
|
661
661
|
|
|
662
|
+
### Missing data
|
|
663
|
+
|
|
664
|
+
`null`, `undefined`, `NaN` and infinite numbers are missing values, and rhp never draws one as 0.
|
|
665
|
+
|
|
666
|
+
- A block given a missing value for its place draws nothing: a Bar's `from` or `to`, the `at` or `cross` of a Dot, Tick, Label or Place, a Cell's `value`.
|
|
667
|
+
Its slat and the slat's other blocks stay.
|
|
668
|
+
- Only given values count: a Bar with no `from` starts at 0, and a Label at an `edge` is always drawn.
|
|
669
|
+
- A Line or an Area breaks at a point that is `null` or has a missing x or y, and draws each run of two or more points.
|
|
670
|
+
To join the line across a gap, leave the point out: `points.filter(([, y]) => y != null)`.
|
|
671
|
+
- The helpers leave missing values out, and `sortBy` puts them last in either direction (section 9).
|
|
672
|
+
- A value that comes back is drawn where it is, without moving from where it was.
|
|
673
|
+
- Read a CSV's empty cells as `null`, never 0: `(s) => (s.trim() === "" ? null : +s)`.
|
|
674
|
+
|
|
675
|
+
Say "no data" where the value would be, in the slat and in any readout:
|
|
676
|
+
|
|
677
|
+
```js
|
|
678
|
+
const shown = (v) => (Number.isFinite(v) ? v.toLocaleString() : "no data");
|
|
679
|
+
const Country = slat({ room: { start: 90, end: 60 } }, (d) => html`
|
|
680
|
+
<div>
|
|
681
|
+
<${Label} edge="start">${() => d.country}<//>
|
|
682
|
+
<${Bar} to=${() => d.rate} />
|
|
683
|
+
<${Label} edge="end">${() => shown(d.rate)}<//>
|
|
684
|
+
</div>`);
|
|
685
|
+
```
|
|
686
|
+
|
|
662
687
|
### ManyDots (experimental point collection)
|
|
663
688
|
|
|
664
689
|
`ManyDots` draws a bulk collection of points directly inside a `Chart` with `scale` and `cross`.
|
|
@@ -947,15 +972,15 @@ When their input changes, call them inside `createMemo` so they run once per cha
|
|
|
947
972
|
| Helper | Returns | Example (real output) |
|
|
948
973
|
|---|---|---|
|
|
949
974
|
| `nice(lo, hi, count = 5)` | a round scale `{ min, max, step, ticks }` | `nice(0, 87)` → `{ min: 0, max: 100, step: 20, ticks: [0, 20, 40, 60, 80, 100] }`; `nice(-23, 41)` → min -40, max 60, step 20 |
|
|
950
|
-
| `extent(values)` | `[smallest, largest]` (`[Infinity, -Infinity]` when empty) | `extent([5, -2, 9])` → `[-2, 9]` |
|
|
975
|
+
| `extent(values)` | `[smallest, largest]` (`[Infinity, -Infinity]` when empty) | `extent([5, null, -2, 9])` → `[-2, 9]` |
|
|
951
976
|
| `every(step, { ends })` | a ticks function of `[min, max]` (a step not above 0 throws) | `every(25)([0, 100])` → `[0, 25, 50, 75, 100]`; `every(5, { ends: true })([0, 27])` → `[0, 5, …, 25, 27]` |
|
|
952
|
-
| `stackUp(values)` | `{ from, to }`: positives stack up from 0, negatives down | `stackUp([5, -3, 4])` → from `[0, 0, 5]`, to `[5, -3, 9]` |
|
|
977
|
+
| `stackUp(values)` | `{ from, to }`: positives stack up from 0, negatives down; a missing value's are `null` | `stackUp([5, -3, 4])` → from `[0, 0, 5]`, to `[5, -3, 9]` |
|
|
953
978
|
| `shares(values, total = 100)` | each value's share of the sum of absolute values, scaled to `total` | `shares([1, 1, 2])` → `[25, 25, 50]` |
|
|
954
979
|
| `running(changes)` | `{ from, to }`: running totals (waterfall) | `running([100, -30, 45])` → from `[0, 100, 70]`, to `[100, 70, 115]` |
|
|
955
980
|
| `summary(samples)` | `{ min, q1, median, q3, max, low, high, mean, outliers }`; low and high are the whisker ends (within 1.5 × IQR) | `summary([1, …, 9, 40])` → median 5.5, high 9, outliers `[40]` |
|
|
956
981
|
| `bins(samples, { domain, count = 10 })` | `{ x0, x1, tally }`: bin k covers x0[k] to x1[k] | `bins([1, 2, 2, 3, 9, 10], { count: 3 })` → x0 `[1, 4, 7]`, tally `[4, 0, 2]` |
|
|
957
982
|
| `density(samples, { domain, points = 40, bandwidth })` | `[[x, density], …]` for an Area (Gaussian kernel, Silverman's bandwidth) | keep `domain` inside the Chart's scale |
|
|
958
|
-
| `sortBy(key, "asc" or "desc")` | an `order` function; `key` is a group name or `(d) => value`; ties keep their places on screen | `order=${sortBy("sold", "desc")}` |
|
|
983
|
+
| `sortBy(key, "asc" or "desc")` | an `order` function; `key` is a group name or `(d) => value`; ties keep their places on screen, missing values go last | `order=${sortBy("sold", "desc")}` |
|
|
959
984
|
| `series(n = 6)` | a data group: `"series-1"`… by row index | `color=${series(4)}` |
|
|
960
985
|
| `cycle(list)` | a data group repeating `list` over the rows (does not set the number of rows) | `color=${cycle(["#111", "#999"])}` |
|
|
961
986
|
| `at(group, i)` | item i of a data group (a list wraps around, a single value is every row's) | `at([1, 2, 3], 4)` → `2` |
|
|
@@ -967,6 +992,8 @@ When their input changes, call them inside `createMemo` so they run once per cha
|
|
|
967
992
|
| `restyle(slatType, css)` | gives a slat type made with `css` new CSS; its slats restyle in place | for style editors |
|
|
968
993
|
| `linkedCss()` | tells rhp the page links `@bezda/rhp/rhp.css` itself | call before the first chart; without the link rhp warns and adds its CSS anyway |
|
|
969
994
|
|
|
995
|
+
`extent`, `summary`, `bins` and `density` leave missing values out (section 6, Missing data); `stackUp` and `running` give one `null` for `from` and `to`, and `shares` gives it no share (`null`).
|
|
996
|
+
|
|
970
997
|
A shape must start with `M`.
|
|
971
998
|
Further `M` commands start separate subpaths, allowing disconnected pieces or holes with the opposite winding direction.
|
|
972
999
|
These work with native CSS `shape()` and the polygon fallback; curves in the fallback retain the existing approximation.
|
|
@@ -1364,7 +1391,8 @@ const VoteChart = () => {
|
|
|
1364
1391
|
render(VoteChart, document.getElementById("chart"));
|
|
1365
1392
|
```
|
|
1366
1393
|
|
|
1367
|
-
|
|
1394
|
+
### A top-N race whose rows leave and come in
|
|
1395
|
+
|
|
1368
1396
|
A row that leaves the top N slides to place N, just below the last place, fades there, and is hidden at the next step; a row that comes in fades in at its place.
|
|
1369
1397
|
Rows a source leaves out (a table of the top four each year) are `null` in the data.
|
|
1370
1398
|
Three parts do it:
|
|
@@ -1635,8 +1663,8 @@ Every one of these was found by running rhp 2.0.1.
|
|
|
1635
1663
|
15. **`scale` needs `min < max`.**
|
|
1636
1664
|
An equal or reversed pair draws Bars with no length and no axis.
|
|
1637
1665
|
To run bars right to left, use negative values and `format=${(v) => Math.abs(v)}`.
|
|
1638
|
-
16. **
|
|
1639
|
-
|
|
1666
|
+
16. **A missing value draws nothing, and a Label prints it as "NaN" or "null".**
|
|
1667
|
+
Print "no data" for it (section 6, Missing data).
|
|
1640
1668
|
17. **A numeric Dot `size` makes an ellipse** (a share of the slat's width and height).
|
|
1641
1669
|
Give a length: `size="12px"`.
|
|
1642
1670
|
18. **`series()` counts 6 colors whatever the theme holds.**
|