@bezda/rhp-mcp 0.1.5 → 0.1.7
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 +6 -1
- package/skill/SKILL.md +113 -22
- package/skill/recipes/area.html +129 -33
- package/skill/recipes/bar.html +129 -33
- package/skill/recipes/box-plot.html +129 -33
- package/skill/recipes/bubble.html +129 -33
- package/skill/recipes/bullet.html +129 -33
- package/skill/recipes/candlestick.html +129 -33
- package/skill/recipes/column.html +129 -33
- package/skill/recipes/dial.html +129 -33
- package/skill/recipes/diverging-bars.html +129 -33
- package/skill/recipes/donut.html +129 -33
- package/skill/recipes/dumbbell.html +129 -33
- package/skill/recipes/gantt.html +129 -33
- package/skill/recipes/grouped-bars.html +129 -33
- package/skill/recipes/heatmap.html +129 -33
- package/skill/recipes/histogram.html +129 -33
- package/skill/recipes/line.html +129 -33
- package/skill/recipes/live.html +129 -33
- package/skill/recipes/log-line.html +129 -33
- package/skill/recipes/lollipop.html +129 -33
- package/skill/recipes/multi-line.html +129 -33
- package/skill/recipes/pyramid.html +129 -33
- package/skill/recipes/race.html +129 -33
- package/skill/recipes/radial-bars.html +129 -33
- package/skill/recipes/scatter.html +129 -33
- package/skill/recipes/silhouettes.html +129 -33
- package/skill/recipes/slope.html +129 -33
- package/skill/recipes/sparklines.html +129 -33
- package/skill/recipes/stacked-100.html +129 -33
- package/skill/recipes/stacked-bars.html +129 -33
- package/skill/recipes/strip.html +129 -33
- package/skill/recipes/tape.html +578 -0
- package/skill/recipes/unit-stack.html +129 -33
- package/skill/recipes/violin.html +129 -33
- package/skill/recipes/waffle.html +129 -33
- package/skill/recipes/waterfall.html +129 -33
- package/skill/references/api.md +6 -6
- package/skill/references/design.md +18 -0
- package/skill/references/download.md +135 -35
- package/skill/references/forms.md +3 -2
- package/skill/references/interaction.md +8 -4
- package/skill/references/pitfalls.md +3 -2
- package/skill/scripts/add-download.mjs +33 -0
- package/src/check/README.md +29 -5
- package/src/check/build.js +4 -3
- package/src/check/colors.js +4 -2
- package/src/check/findings.js +99 -3
- package/src/check/index.js +158 -17
- package/src/check/inpage.js +292 -5
- package/src/check/interact.js +22 -4
- package/src/check/png.js +6 -1
- package/src/check/report.js +9 -2
- package/src/check/selection-controls.js +105 -0
- package/src/check/selection.js +54 -12
- package/src/check/source.js +21 -0
- package/src/server.js +4 -4
|
@@ -274,24 +274,84 @@ render(() => html`<${Spending} />`, document.getElementById("chart"));
|
|
|
274
274
|
</script>
|
|
275
275
|
<style id="poster-download-styles">
|
|
276
276
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
277
|
-
.poster-
|
|
278
|
-
.poster-download
|
|
279
|
-
.poster-
|
|
280
|
-
.poster-
|
|
281
|
-
.poster-
|
|
277
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
278
|
+
.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; }
|
|
279
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
280
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
281
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
282
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
283
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
284
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
285
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
286
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
282
287
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
283
|
-
@media print { .poster-download-tools, .poster-
|
|
288
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
284
289
|
</style>
|
|
285
290
|
<script type="module" id="poster-download-script">
|
|
291
|
+
function addPosterFit(poster, button, changed) {
|
|
292
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
293
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
294
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
295
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
296
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
297
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
298
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
299
|
+
const layoutKey = () => {
|
|
300
|
+
const css = getComputedStyle(poster);
|
|
301
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
302
|
+
};
|
|
303
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
304
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
305
|
+
else poster.style.removeProperty(name);
|
|
306
|
+
});
|
|
307
|
+
function update() {
|
|
308
|
+
request = 0;
|
|
309
|
+
if (fitted && layout === layoutKey()) return;
|
|
310
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
311
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
312
|
+
if (fitted) {
|
|
313
|
+
const box = poster.getBoundingClientRect();
|
|
314
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
315
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
316
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
317
|
+
poster.style.setProperty('max-width', 'none');
|
|
318
|
+
poster.style.setProperty('margin', '0');
|
|
319
|
+
content.style.width = `${box.width}px`;
|
|
320
|
+
content.style.transform = `scale(${scale})`;
|
|
321
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
322
|
+
frame.classList.add('is-fitted');
|
|
323
|
+
}
|
|
324
|
+
layout = layoutKey();
|
|
325
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
326
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
327
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
328
|
+
button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${fitted ? 'M15 3h6v6m0-6-7 7M9 21H3v-6m0 6 7-7' : 'M4 4l6 6m0-6v6H4m16 10-6-6m0 6v-6h6'}"/></svg>`;
|
|
329
|
+
changed();
|
|
330
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
331
|
+
}
|
|
332
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
333
|
+
new ResizeObserver(schedule).observe(poster);
|
|
334
|
+
addEventListener('resize', schedule);
|
|
335
|
+
document.fonts.ready.then(schedule);
|
|
336
|
+
update();
|
|
337
|
+
return {
|
|
338
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
339
|
+
dimensions() {
|
|
340
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
341
|
+
const css = getComputedStyle(poster);
|
|
342
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
286
346
|
function addPosterDownloads() {
|
|
287
347
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
288
348
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
289
349
|
const tools = navigation || document.createElement('div');
|
|
290
350
|
if (tools.querySelector('.poster-download')) return;
|
|
351
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
291
352
|
if (!navigation) {
|
|
292
353
|
tools.className = 'poster-download-tools';
|
|
293
354
|
poster.before(tools);
|
|
294
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
295
355
|
new ResizeObserver(align).observe(poster);
|
|
296
356
|
align();
|
|
297
357
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -309,27 +369,43 @@ function addPosterDownloads() {
|
|
|
309
369
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
310
370
|
button.title = 'Download poster as PNG';
|
|
311
371
|
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
|
|
372
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
373
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
374
|
+
if (!poster.id) {
|
|
375
|
+
let id = `rhp-poster-${index + 1}`;
|
|
376
|
+
while (document.getElementById(id)) id += '-view';
|
|
377
|
+
poster.id = id;
|
|
378
|
+
}
|
|
379
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
380
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
381
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
382
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
383
|
+
actions.append(fitButton, button);
|
|
312
384
|
const error = document.createElement('p');
|
|
313
385
|
error.className = 'poster-download-error';
|
|
314
386
|
error.setAttribute('role', 'alert');
|
|
315
387
|
error.hidden = true;
|
|
316
388
|
tools.after(error);
|
|
317
389
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
318
|
-
|
|
390
|
+
actions.addEventListener('pointerdown', event => {
|
|
319
391
|
event.stopPropagation();
|
|
320
392
|
});
|
|
321
|
-
|
|
393
|
+
actions.addEventListener('mousedown', event => {
|
|
322
394
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
323
395
|
event.preventDefault();
|
|
324
396
|
event.stopPropagation();
|
|
325
397
|
});
|
|
326
398
|
poster.addEventListener('focusout', event => {
|
|
327
|
-
if (
|
|
399
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
328
400
|
}, true);
|
|
329
401
|
poster.addEventListener('pointerleave', event => {
|
|
330
|
-
if (
|
|
402
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
331
403
|
}, true);
|
|
332
|
-
|
|
404
|
+
poster.addEventListener('pointermove', event => {
|
|
405
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
406
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
407
|
+
}, true);
|
|
408
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
333
409
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
334
410
|
});
|
|
335
411
|
let exporter, retries = 0, busy = false;
|
|
@@ -339,6 +415,7 @@ function addPosterDownloads() {
|
|
|
339
415
|
busy = true;
|
|
340
416
|
button.setAttribute('aria-disabled', 'true');
|
|
341
417
|
button.setAttribute('aria-busy', 'true');
|
|
418
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
342
419
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
343
420
|
error.hidden = true;
|
|
344
421
|
let url;
|
|
@@ -346,35 +423,53 @@ function addPosterDownloads() {
|
|
|
346
423
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
347
424
|
const { domToBlob } = await exporter;
|
|
348
425
|
await document.fonts.ready;
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
const
|
|
426
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
427
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
428
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
429
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
430
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
431
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
352
432
|
if (rule.selectorText && rule.style) {
|
|
353
|
-
const margins = [...rule.style].filter(
|
|
433
|
+
const margins = [...rule.style].filter(isMargin);
|
|
354
434
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
355
|
-
|
|
435
|
+
const insets = [...rule.style].filter(isInset);
|
|
436
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
437
|
+
const layout = [...margins, ...insets];
|
|
438
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
356
439
|
}
|
|
357
|
-
if (!rule.cssRules) return '';
|
|
358
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
359
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
360
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
440
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
441
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
442
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
443
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
361
444
|
}).join('\n');
|
|
362
|
-
const
|
|
363
|
-
|
|
445
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
446
|
+
const layout = sheets.map(sheet => {
|
|
447
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
364
448
|
}).join('\n');
|
|
365
449
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
366
450
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
367
|
-
const
|
|
368
|
-
if (clone.style && source !== poster
|
|
369
|
-
[...
|
|
370
|
-
|
|
451
|
+
const restoreLayout = (source, clone) => {
|
|
452
|
+
if (clone.style && source !== poster) {
|
|
453
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
454
|
+
const computed = getComputedStyle(source);
|
|
455
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
456
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
457
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
458
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
459
|
+
if (insets) {
|
|
460
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
461
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
462
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
463
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
464
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
465
|
+
}
|
|
371
466
|
}
|
|
372
467
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
373
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
468
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
374
469
|
};
|
|
375
470
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
376
471
|
const fontFiles = new Map();
|
|
377
|
-
const fontRules = await Promise.all(
|
|
472
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
378
473
|
let css;
|
|
379
474
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
380
475
|
catch (cause) {
|
|
@@ -425,12 +520,12 @@ function addPosterDownloads() {
|
|
|
425
520
|
node.style.backgroundImage = background;
|
|
426
521
|
};
|
|
427
522
|
const blob = await domToBlob(poster, {
|
|
428
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
523
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
429
524
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
430
525
|
onCloneNode: clone => {
|
|
431
|
-
|
|
526
|
+
restoreLayout(poster, clone);
|
|
432
527
|
const style = document.createElement('style');
|
|
433
|
-
style.textContent =
|
|
528
|
+
style.textContent = layout;
|
|
434
529
|
clone.prepend(style);
|
|
435
530
|
},
|
|
436
531
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -458,10 +553,11 @@ function addPosterDownloads() {
|
|
|
458
553
|
busy = false;
|
|
459
554
|
button.removeAttribute('aria-disabled');
|
|
460
555
|
button.removeAttribute('aria-busy');
|
|
556
|
+
fitButton.removeAttribute('aria-disabled');
|
|
461
557
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
462
558
|
}
|
|
463
559
|
});
|
|
464
|
-
tools.append(
|
|
560
|
+
tools.append(actions);
|
|
465
561
|
});
|
|
466
562
|
}
|
|
467
563
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
@@ -167,24 +167,84 @@ render(() => html`<${SleepByAge} />`, document.getElementById("chart"));
|
|
|
167
167
|
</script>
|
|
168
168
|
<style id="poster-download-styles">
|
|
169
169
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
170
|
-
.poster-
|
|
171
|
-
.poster-download
|
|
172
|
-
.poster-
|
|
173
|
-
.poster-
|
|
174
|
-
.poster-
|
|
170
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
171
|
+
.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; }
|
|
172
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
173
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
174
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
175
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
176
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
177
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
178
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
179
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
175
180
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
176
|
-
@media print { .poster-download-tools, .poster-
|
|
181
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
177
182
|
</style>
|
|
178
183
|
<script type="module" id="poster-download-script">
|
|
184
|
+
function addPosterFit(poster, button, changed) {
|
|
185
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
186
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
187
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
188
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
189
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
190
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
191
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
192
|
+
const layoutKey = () => {
|
|
193
|
+
const css = getComputedStyle(poster);
|
|
194
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
195
|
+
};
|
|
196
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
197
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
198
|
+
else poster.style.removeProperty(name);
|
|
199
|
+
});
|
|
200
|
+
function update() {
|
|
201
|
+
request = 0;
|
|
202
|
+
if (fitted && layout === layoutKey()) return;
|
|
203
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
204
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
205
|
+
if (fitted) {
|
|
206
|
+
const box = poster.getBoundingClientRect();
|
|
207
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
208
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
209
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
210
|
+
poster.style.setProperty('max-width', 'none');
|
|
211
|
+
poster.style.setProperty('margin', '0');
|
|
212
|
+
content.style.width = `${box.width}px`;
|
|
213
|
+
content.style.transform = `scale(${scale})`;
|
|
214
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
215
|
+
frame.classList.add('is-fitted');
|
|
216
|
+
}
|
|
217
|
+
layout = layoutKey();
|
|
218
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
219
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
220
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
221
|
+
button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${fitted ? 'M15 3h6v6m0-6-7 7M9 21H3v-6m0 6 7-7' : 'M4 4l6 6m0-6v6H4m16 10-6-6m0 6v-6h6'}"/></svg>`;
|
|
222
|
+
changed();
|
|
223
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
224
|
+
}
|
|
225
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
226
|
+
new ResizeObserver(schedule).observe(poster);
|
|
227
|
+
addEventListener('resize', schedule);
|
|
228
|
+
document.fonts.ready.then(schedule);
|
|
229
|
+
update();
|
|
230
|
+
return {
|
|
231
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
232
|
+
dimensions() {
|
|
233
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
234
|
+
const css = getComputedStyle(poster);
|
|
235
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
179
239
|
function addPosterDownloads() {
|
|
180
240
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
181
241
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
182
242
|
const tools = navigation || document.createElement('div');
|
|
183
243
|
if (tools.querySelector('.poster-download')) return;
|
|
244
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
184
245
|
if (!navigation) {
|
|
185
246
|
tools.className = 'poster-download-tools';
|
|
186
247
|
poster.before(tools);
|
|
187
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
188
248
|
new ResizeObserver(align).observe(poster);
|
|
189
249
|
align();
|
|
190
250
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -202,27 +262,43 @@ function addPosterDownloads() {
|
|
|
202
262
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
203
263
|
button.title = 'Download poster as PNG';
|
|
204
264
|
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
|
|
265
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
266
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
267
|
+
if (!poster.id) {
|
|
268
|
+
let id = `rhp-poster-${index + 1}`;
|
|
269
|
+
while (document.getElementById(id)) id += '-view';
|
|
270
|
+
poster.id = id;
|
|
271
|
+
}
|
|
272
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
273
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
274
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
275
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
276
|
+
actions.append(fitButton, button);
|
|
205
277
|
const error = document.createElement('p');
|
|
206
278
|
error.className = 'poster-download-error';
|
|
207
279
|
error.setAttribute('role', 'alert');
|
|
208
280
|
error.hidden = true;
|
|
209
281
|
tools.after(error);
|
|
210
282
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
211
|
-
|
|
283
|
+
actions.addEventListener('pointerdown', event => {
|
|
212
284
|
event.stopPropagation();
|
|
213
285
|
});
|
|
214
|
-
|
|
286
|
+
actions.addEventListener('mousedown', event => {
|
|
215
287
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
216
288
|
event.preventDefault();
|
|
217
289
|
event.stopPropagation();
|
|
218
290
|
});
|
|
219
291
|
poster.addEventListener('focusout', event => {
|
|
220
|
-
if (
|
|
292
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
221
293
|
}, true);
|
|
222
294
|
poster.addEventListener('pointerleave', event => {
|
|
223
|
-
if (
|
|
295
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
296
|
+
}, true);
|
|
297
|
+
poster.addEventListener('pointermove', event => {
|
|
298
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
299
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
224
300
|
}, true);
|
|
225
|
-
for (const type of ['keydown', 'keyup'])
|
|
301
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
226
302
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
227
303
|
});
|
|
228
304
|
let exporter, retries = 0, busy = false;
|
|
@@ -232,6 +308,7 @@ function addPosterDownloads() {
|
|
|
232
308
|
busy = true;
|
|
233
309
|
button.setAttribute('aria-disabled', 'true');
|
|
234
310
|
button.setAttribute('aria-busy', 'true');
|
|
311
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
235
312
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
236
313
|
error.hidden = true;
|
|
237
314
|
let url;
|
|
@@ -239,35 +316,53 @@ function addPosterDownloads() {
|
|
|
239
316
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
240
317
|
const { domToBlob } = await exporter;
|
|
241
318
|
await document.fonts.ready;
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
const
|
|
319
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
320
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
321
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
322
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
323
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
324
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
245
325
|
if (rule.selectorText && rule.style) {
|
|
246
|
-
const margins = [...rule.style].filter(
|
|
326
|
+
const margins = [...rule.style].filter(isMargin);
|
|
247
327
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
248
|
-
|
|
328
|
+
const insets = [...rule.style].filter(isInset);
|
|
329
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
330
|
+
const layout = [...margins, ...insets];
|
|
331
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
249
332
|
}
|
|
250
|
-
if (!rule.cssRules) return '';
|
|
251
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
252
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
253
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
333
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
334
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
335
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
336
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
254
337
|
}).join('\n');
|
|
255
|
-
const
|
|
256
|
-
|
|
338
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
339
|
+
const layout = sheets.map(sheet => {
|
|
340
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
257
341
|
}).join('\n');
|
|
258
342
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
259
343
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
260
|
-
const
|
|
261
|
-
if (clone.style && source !== poster
|
|
262
|
-
[...
|
|
263
|
-
|
|
344
|
+
const restoreLayout = (source, clone) => {
|
|
345
|
+
if (clone.style && source !== poster) {
|
|
346
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
347
|
+
const computed = getComputedStyle(source);
|
|
348
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
349
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
350
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
351
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
352
|
+
if (insets) {
|
|
353
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
354
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
355
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
356
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
357
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
358
|
+
}
|
|
264
359
|
}
|
|
265
360
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
266
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
361
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
267
362
|
};
|
|
268
363
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
269
364
|
const fontFiles = new Map();
|
|
270
|
-
const fontRules = await Promise.all(
|
|
365
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
271
366
|
let css;
|
|
272
367
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
273
368
|
catch (cause) {
|
|
@@ -318,12 +413,12 @@ function addPosterDownloads() {
|
|
|
318
413
|
node.style.backgroundImage = background;
|
|
319
414
|
};
|
|
320
415
|
const blob = await domToBlob(poster, {
|
|
321
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
416
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
322
417
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
323
418
|
onCloneNode: clone => {
|
|
324
|
-
|
|
419
|
+
restoreLayout(poster, clone);
|
|
325
420
|
const style = document.createElement('style');
|
|
326
|
-
style.textContent =
|
|
421
|
+
style.textContent = layout;
|
|
327
422
|
clone.prepend(style);
|
|
328
423
|
},
|
|
329
424
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -351,10 +446,11 @@ function addPosterDownloads() {
|
|
|
351
446
|
busy = false;
|
|
352
447
|
button.removeAttribute('aria-disabled');
|
|
353
448
|
button.removeAttribute('aria-busy');
|
|
449
|
+
fitButton.removeAttribute('aria-disabled');
|
|
354
450
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
355
451
|
}
|
|
356
452
|
});
|
|
357
|
-
tools.append(
|
|
453
|
+
tools.append(actions);
|
|
358
454
|
});
|
|
359
455
|
}
|
|
360
456
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|