@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
|
@@ -491,24 +491,84 @@ render(() => html`<${BitcoinDecade} />`, document.getElementById("chart"));
|
|
|
491
491
|
</script>
|
|
492
492
|
<style id="poster-download-styles">
|
|
493
493
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
494
|
-
.poster-
|
|
495
|
-
.poster-download
|
|
496
|
-
.poster-
|
|
497
|
-
.poster-
|
|
498
|
-
.poster-
|
|
494
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
495
|
+
.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; }
|
|
496
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
497
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
498
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
499
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
500
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
501
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
502
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
503
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
499
504
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
500
|
-
@media print { .poster-download-tools, .poster-
|
|
505
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
501
506
|
</style>
|
|
502
507
|
<script type="module" id="poster-download-script">
|
|
508
|
+
function addPosterFit(poster, button, changed) {
|
|
509
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
510
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
511
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
512
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
513
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
514
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
515
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
516
|
+
const layoutKey = () => {
|
|
517
|
+
const css = getComputedStyle(poster);
|
|
518
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
519
|
+
};
|
|
520
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
521
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
522
|
+
else poster.style.removeProperty(name);
|
|
523
|
+
});
|
|
524
|
+
function update() {
|
|
525
|
+
request = 0;
|
|
526
|
+
if (fitted && layout === layoutKey()) return;
|
|
527
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
528
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
529
|
+
if (fitted) {
|
|
530
|
+
const box = poster.getBoundingClientRect();
|
|
531
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
532
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
533
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
534
|
+
poster.style.setProperty('max-width', 'none');
|
|
535
|
+
poster.style.setProperty('margin', '0');
|
|
536
|
+
content.style.width = `${box.width}px`;
|
|
537
|
+
content.style.transform = `scale(${scale})`;
|
|
538
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
539
|
+
frame.classList.add('is-fitted');
|
|
540
|
+
}
|
|
541
|
+
layout = layoutKey();
|
|
542
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
543
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
544
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
545
|
+
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>`;
|
|
546
|
+
changed();
|
|
547
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
548
|
+
}
|
|
549
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
550
|
+
new ResizeObserver(schedule).observe(poster);
|
|
551
|
+
addEventListener('resize', schedule);
|
|
552
|
+
document.fonts.ready.then(schedule);
|
|
553
|
+
update();
|
|
554
|
+
return {
|
|
555
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
556
|
+
dimensions() {
|
|
557
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
558
|
+
const css = getComputedStyle(poster);
|
|
559
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
503
563
|
function addPosterDownloads() {
|
|
504
564
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
505
565
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
506
566
|
const tools = navigation || document.createElement('div');
|
|
507
567
|
if (tools.querySelector('.poster-download')) return;
|
|
568
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
508
569
|
if (!navigation) {
|
|
509
570
|
tools.className = 'poster-download-tools';
|
|
510
571
|
poster.before(tools);
|
|
511
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
512
572
|
new ResizeObserver(align).observe(poster);
|
|
513
573
|
align();
|
|
514
574
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -526,27 +586,43 @@ function addPosterDownloads() {
|
|
|
526
586
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
527
587
|
button.title = 'Download poster as PNG';
|
|
528
588
|
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>';
|
|
589
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
590
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
591
|
+
if (!poster.id) {
|
|
592
|
+
let id = `rhp-poster-${index + 1}`;
|
|
593
|
+
while (document.getElementById(id)) id += '-view';
|
|
594
|
+
poster.id = id;
|
|
595
|
+
}
|
|
596
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
597
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
598
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
599
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
600
|
+
actions.append(fitButton, button);
|
|
529
601
|
const error = document.createElement('p');
|
|
530
602
|
error.className = 'poster-download-error';
|
|
531
603
|
error.setAttribute('role', 'alert');
|
|
532
604
|
error.hidden = true;
|
|
533
605
|
tools.after(error);
|
|
534
606
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
535
|
-
|
|
607
|
+
actions.addEventListener('pointerdown', event => {
|
|
536
608
|
event.stopPropagation();
|
|
537
609
|
});
|
|
538
|
-
|
|
610
|
+
actions.addEventListener('mousedown', event => {
|
|
539
611
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
540
612
|
event.preventDefault();
|
|
541
613
|
event.stopPropagation();
|
|
542
614
|
});
|
|
543
615
|
poster.addEventListener('focusout', event => {
|
|
544
|
-
if (
|
|
616
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
545
617
|
}, true);
|
|
546
618
|
poster.addEventListener('pointerleave', event => {
|
|
547
|
-
if (
|
|
619
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
548
620
|
}, true);
|
|
549
|
-
|
|
621
|
+
poster.addEventListener('pointermove', event => {
|
|
622
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
623
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
624
|
+
}, true);
|
|
625
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
550
626
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
551
627
|
});
|
|
552
628
|
let exporter, retries = 0, busy = false;
|
|
@@ -556,6 +632,7 @@ function addPosterDownloads() {
|
|
|
556
632
|
busy = true;
|
|
557
633
|
button.setAttribute('aria-disabled', 'true');
|
|
558
634
|
button.setAttribute('aria-busy', 'true');
|
|
635
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
559
636
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
560
637
|
error.hidden = true;
|
|
561
638
|
let url;
|
|
@@ -563,35 +640,53 @@ function addPosterDownloads() {
|
|
|
563
640
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
564
641
|
const { domToBlob } = await exporter;
|
|
565
642
|
await document.fonts.ready;
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
const
|
|
643
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
644
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
645
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
646
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
647
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
648
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
569
649
|
if (rule.selectorText && rule.style) {
|
|
570
|
-
const margins = [...rule.style].filter(
|
|
650
|
+
const margins = [...rule.style].filter(isMargin);
|
|
571
651
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
572
|
-
|
|
652
|
+
const insets = [...rule.style].filter(isInset);
|
|
653
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
654
|
+
const layout = [...margins, ...insets];
|
|
655
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
573
656
|
}
|
|
574
|
-
if (!rule.cssRules) return '';
|
|
575
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
576
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
577
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
657
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
658
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
659
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
660
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
578
661
|
}).join('\n');
|
|
579
|
-
const
|
|
580
|
-
|
|
662
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
663
|
+
const layout = sheets.map(sheet => {
|
|
664
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
581
665
|
}).join('\n');
|
|
582
666
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
583
667
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
584
|
-
const
|
|
585
|
-
if (clone.style && source !== poster
|
|
586
|
-
[...
|
|
587
|
-
|
|
668
|
+
const restoreLayout = (source, clone) => {
|
|
669
|
+
if (clone.style && source !== poster) {
|
|
670
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
671
|
+
const computed = getComputedStyle(source);
|
|
672
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
673
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
674
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
675
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
676
|
+
if (insets) {
|
|
677
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
678
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
679
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
680
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
681
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
682
|
+
}
|
|
588
683
|
}
|
|
589
684
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
590
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
685
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
591
686
|
};
|
|
592
687
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
593
688
|
const fontFiles = new Map();
|
|
594
|
-
const fontRules = await Promise.all(
|
|
689
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
595
690
|
let css;
|
|
596
691
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
597
692
|
catch (cause) {
|
|
@@ -642,12 +737,12 @@ function addPosterDownloads() {
|
|
|
642
737
|
node.style.backgroundImage = background;
|
|
643
738
|
};
|
|
644
739
|
const blob = await domToBlob(poster, {
|
|
645
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
740
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
646
741
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
647
742
|
onCloneNode: clone => {
|
|
648
|
-
|
|
743
|
+
restoreLayout(poster, clone);
|
|
649
744
|
const style = document.createElement('style');
|
|
650
|
-
style.textContent =
|
|
745
|
+
style.textContent = layout;
|
|
651
746
|
clone.prepend(style);
|
|
652
747
|
},
|
|
653
748
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -675,10 +770,11 @@ function addPosterDownloads() {
|
|
|
675
770
|
busy = false;
|
|
676
771
|
button.removeAttribute('aria-disabled');
|
|
677
772
|
button.removeAttribute('aria-busy');
|
|
773
|
+
fitButton.removeAttribute('aria-disabled');
|
|
678
774
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
679
775
|
}
|
|
680
776
|
});
|
|
681
|
-
tools.append(
|
|
777
|
+
tools.append(actions);
|
|
682
778
|
});
|
|
683
779
|
}
|
|
684
780
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
@@ -150,24 +150,84 @@ render(() => html`<${BigMacs} />`, document.getElementById("chart"));
|
|
|
150
150
|
</script>
|
|
151
151
|
<style id="poster-download-styles">
|
|
152
152
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
153
|
-
.poster-
|
|
154
|
-
.poster-download
|
|
155
|
-
.poster-
|
|
156
|
-
.poster-
|
|
157
|
-
.poster-
|
|
153
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
154
|
+
.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; }
|
|
155
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
156
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
157
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
158
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
159
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
160
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
161
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
162
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
158
163
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
159
|
-
@media print { .poster-download-tools, .poster-
|
|
164
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
160
165
|
</style>
|
|
161
166
|
<script type="module" id="poster-download-script">
|
|
167
|
+
function addPosterFit(poster, button, changed) {
|
|
168
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
169
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
170
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
171
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
172
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
173
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
174
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
175
|
+
const layoutKey = () => {
|
|
176
|
+
const css = getComputedStyle(poster);
|
|
177
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
178
|
+
};
|
|
179
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
180
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
181
|
+
else poster.style.removeProperty(name);
|
|
182
|
+
});
|
|
183
|
+
function update() {
|
|
184
|
+
request = 0;
|
|
185
|
+
if (fitted && layout === layoutKey()) return;
|
|
186
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
187
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
188
|
+
if (fitted) {
|
|
189
|
+
const box = poster.getBoundingClientRect();
|
|
190
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
191
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
192
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
193
|
+
poster.style.setProperty('max-width', 'none');
|
|
194
|
+
poster.style.setProperty('margin', '0');
|
|
195
|
+
content.style.width = `${box.width}px`;
|
|
196
|
+
content.style.transform = `scale(${scale})`;
|
|
197
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
198
|
+
frame.classList.add('is-fitted');
|
|
199
|
+
}
|
|
200
|
+
layout = layoutKey();
|
|
201
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
202
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
203
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
204
|
+
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>`;
|
|
205
|
+
changed();
|
|
206
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
207
|
+
}
|
|
208
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
209
|
+
new ResizeObserver(schedule).observe(poster);
|
|
210
|
+
addEventListener('resize', schedule);
|
|
211
|
+
document.fonts.ready.then(schedule);
|
|
212
|
+
update();
|
|
213
|
+
return {
|
|
214
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
215
|
+
dimensions() {
|
|
216
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
217
|
+
const css = getComputedStyle(poster);
|
|
218
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
162
222
|
function addPosterDownloads() {
|
|
163
223
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
164
224
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
165
225
|
const tools = navigation || document.createElement('div');
|
|
166
226
|
if (tools.querySelector('.poster-download')) return;
|
|
227
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
167
228
|
if (!navigation) {
|
|
168
229
|
tools.className = 'poster-download-tools';
|
|
169
230
|
poster.before(tools);
|
|
170
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
171
231
|
new ResizeObserver(align).observe(poster);
|
|
172
232
|
align();
|
|
173
233
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -185,27 +245,43 @@ function addPosterDownloads() {
|
|
|
185
245
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
186
246
|
button.title = 'Download poster as PNG';
|
|
187
247
|
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>';
|
|
248
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
249
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
250
|
+
if (!poster.id) {
|
|
251
|
+
let id = `rhp-poster-${index + 1}`;
|
|
252
|
+
while (document.getElementById(id)) id += '-view';
|
|
253
|
+
poster.id = id;
|
|
254
|
+
}
|
|
255
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
256
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
257
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
258
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
259
|
+
actions.append(fitButton, button);
|
|
188
260
|
const error = document.createElement('p');
|
|
189
261
|
error.className = 'poster-download-error';
|
|
190
262
|
error.setAttribute('role', 'alert');
|
|
191
263
|
error.hidden = true;
|
|
192
264
|
tools.after(error);
|
|
193
265
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
194
|
-
|
|
266
|
+
actions.addEventListener('pointerdown', event => {
|
|
195
267
|
event.stopPropagation();
|
|
196
268
|
});
|
|
197
|
-
|
|
269
|
+
actions.addEventListener('mousedown', event => {
|
|
198
270
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
199
271
|
event.preventDefault();
|
|
200
272
|
event.stopPropagation();
|
|
201
273
|
});
|
|
202
274
|
poster.addEventListener('focusout', event => {
|
|
203
|
-
if (
|
|
275
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
204
276
|
}, true);
|
|
205
277
|
poster.addEventListener('pointerleave', event => {
|
|
206
|
-
if (
|
|
278
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
279
|
+
}, true);
|
|
280
|
+
poster.addEventListener('pointermove', event => {
|
|
281
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
282
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
207
283
|
}, true);
|
|
208
|
-
for (const type of ['keydown', 'keyup'])
|
|
284
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
209
285
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
210
286
|
});
|
|
211
287
|
let exporter, retries = 0, busy = false;
|
|
@@ -215,6 +291,7 @@ function addPosterDownloads() {
|
|
|
215
291
|
busy = true;
|
|
216
292
|
button.setAttribute('aria-disabled', 'true');
|
|
217
293
|
button.setAttribute('aria-busy', 'true');
|
|
294
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
218
295
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
219
296
|
error.hidden = true;
|
|
220
297
|
let url;
|
|
@@ -222,35 +299,53 @@ function addPosterDownloads() {
|
|
|
222
299
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
223
300
|
const { domToBlob } = await exporter;
|
|
224
301
|
await document.fonts.ready;
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
const
|
|
302
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
303
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
304
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
305
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
306
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
307
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
228
308
|
if (rule.selectorText && rule.style) {
|
|
229
|
-
const margins = [...rule.style].filter(
|
|
309
|
+
const margins = [...rule.style].filter(isMargin);
|
|
230
310
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
231
|
-
|
|
311
|
+
const insets = [...rule.style].filter(isInset);
|
|
312
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
313
|
+
const layout = [...margins, ...insets];
|
|
314
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
232
315
|
}
|
|
233
|
-
if (!rule.cssRules) return '';
|
|
234
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
235
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
236
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
316
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
317
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
318
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
319
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
237
320
|
}).join('\n');
|
|
238
|
-
const
|
|
239
|
-
|
|
321
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
322
|
+
const layout = sheets.map(sheet => {
|
|
323
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
240
324
|
}).join('\n');
|
|
241
325
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
242
326
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
243
|
-
const
|
|
244
|
-
if (clone.style && source !== poster
|
|
245
|
-
[...
|
|
246
|
-
|
|
327
|
+
const restoreLayout = (source, clone) => {
|
|
328
|
+
if (clone.style && source !== poster) {
|
|
329
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
330
|
+
const computed = getComputedStyle(source);
|
|
331
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
332
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
333
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
334
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
335
|
+
if (insets) {
|
|
336
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
337
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
338
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
339
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
340
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
341
|
+
}
|
|
247
342
|
}
|
|
248
343
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
249
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
344
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
250
345
|
};
|
|
251
346
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
252
347
|
const fontFiles = new Map();
|
|
253
|
-
const fontRules = await Promise.all(
|
|
348
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
254
349
|
let css;
|
|
255
350
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
256
351
|
catch (cause) {
|
|
@@ -301,12 +396,12 @@ function addPosterDownloads() {
|
|
|
301
396
|
node.style.backgroundImage = background;
|
|
302
397
|
};
|
|
303
398
|
const blob = await domToBlob(poster, {
|
|
304
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
399
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
305
400
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
306
401
|
onCloneNode: clone => {
|
|
307
|
-
|
|
402
|
+
restoreLayout(poster, clone);
|
|
308
403
|
const style = document.createElement('style');
|
|
309
|
-
style.textContent =
|
|
404
|
+
style.textContent = layout;
|
|
310
405
|
clone.prepend(style);
|
|
311
406
|
},
|
|
312
407
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -334,10 +429,11 @@ function addPosterDownloads() {
|
|
|
334
429
|
busy = false;
|
|
335
430
|
button.removeAttribute('aria-disabled');
|
|
336
431
|
button.removeAttribute('aria-busy');
|
|
432
|
+
fitButton.removeAttribute('aria-disabled');
|
|
337
433
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
338
434
|
}
|
|
339
435
|
});
|
|
340
|
-
tools.append(
|
|
436
|
+
tools.append(actions);
|
|
341
437
|
});
|
|
342
438
|
}
|
|
343
439
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|