@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
package/skill/recipes/dial.html
CHANGED
|
@@ -347,24 +347,84 @@ render(() => html`<${DayDial} />`, document.getElementById("chart"));
|
|
|
347
347
|
</script>
|
|
348
348
|
<style id="poster-download-styles">
|
|
349
349
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
350
|
-
.poster-
|
|
351
|
-
.poster-download
|
|
352
|
-
.poster-
|
|
353
|
-
.poster-
|
|
354
|
-
.poster-
|
|
350
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
351
|
+
.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; }
|
|
352
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
353
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
354
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
355
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
356
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
357
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
358
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
359
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
355
360
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
356
|
-
@media print { .poster-download-tools, .poster-
|
|
361
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
357
362
|
</style>
|
|
358
363
|
<script type="module" id="poster-download-script">
|
|
364
|
+
function addPosterFit(poster, button, changed) {
|
|
365
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
366
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
367
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
368
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
369
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
370
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
371
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
372
|
+
const layoutKey = () => {
|
|
373
|
+
const css = getComputedStyle(poster);
|
|
374
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
375
|
+
};
|
|
376
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
377
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
378
|
+
else poster.style.removeProperty(name);
|
|
379
|
+
});
|
|
380
|
+
function update() {
|
|
381
|
+
request = 0;
|
|
382
|
+
if (fitted && layout === layoutKey()) return;
|
|
383
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
384
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
385
|
+
if (fitted) {
|
|
386
|
+
const box = poster.getBoundingClientRect();
|
|
387
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
388
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
389
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
390
|
+
poster.style.setProperty('max-width', 'none');
|
|
391
|
+
poster.style.setProperty('margin', '0');
|
|
392
|
+
content.style.width = `${box.width}px`;
|
|
393
|
+
content.style.transform = `scale(${scale})`;
|
|
394
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
395
|
+
frame.classList.add('is-fitted');
|
|
396
|
+
}
|
|
397
|
+
layout = layoutKey();
|
|
398
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
399
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
400
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
401
|
+
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>`;
|
|
402
|
+
changed();
|
|
403
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
404
|
+
}
|
|
405
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
406
|
+
new ResizeObserver(schedule).observe(poster);
|
|
407
|
+
addEventListener('resize', schedule);
|
|
408
|
+
document.fonts.ready.then(schedule);
|
|
409
|
+
update();
|
|
410
|
+
return {
|
|
411
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
412
|
+
dimensions() {
|
|
413
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
414
|
+
const css = getComputedStyle(poster);
|
|
415
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
359
419
|
function addPosterDownloads() {
|
|
360
420
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
361
421
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
362
422
|
const tools = navigation || document.createElement('div');
|
|
363
423
|
if (tools.querySelector('.poster-download')) return;
|
|
424
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
364
425
|
if (!navigation) {
|
|
365
426
|
tools.className = 'poster-download-tools';
|
|
366
427
|
poster.before(tools);
|
|
367
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
368
428
|
new ResizeObserver(align).observe(poster);
|
|
369
429
|
align();
|
|
370
430
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -382,27 +442,43 @@ function addPosterDownloads() {
|
|
|
382
442
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
383
443
|
button.title = 'Download poster as PNG';
|
|
384
444
|
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>';
|
|
445
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
446
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
447
|
+
if (!poster.id) {
|
|
448
|
+
let id = `rhp-poster-${index + 1}`;
|
|
449
|
+
while (document.getElementById(id)) id += '-view';
|
|
450
|
+
poster.id = id;
|
|
451
|
+
}
|
|
452
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
453
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
454
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
455
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
456
|
+
actions.append(fitButton, button);
|
|
385
457
|
const error = document.createElement('p');
|
|
386
458
|
error.className = 'poster-download-error';
|
|
387
459
|
error.setAttribute('role', 'alert');
|
|
388
460
|
error.hidden = true;
|
|
389
461
|
tools.after(error);
|
|
390
462
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
391
|
-
|
|
463
|
+
actions.addEventListener('pointerdown', event => {
|
|
392
464
|
event.stopPropagation();
|
|
393
465
|
});
|
|
394
|
-
|
|
466
|
+
actions.addEventListener('mousedown', event => {
|
|
395
467
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
396
468
|
event.preventDefault();
|
|
397
469
|
event.stopPropagation();
|
|
398
470
|
});
|
|
399
471
|
poster.addEventListener('focusout', event => {
|
|
400
|
-
if (
|
|
472
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
401
473
|
}, true);
|
|
402
474
|
poster.addEventListener('pointerleave', event => {
|
|
403
|
-
if (
|
|
475
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
404
476
|
}, true);
|
|
405
|
-
|
|
477
|
+
poster.addEventListener('pointermove', event => {
|
|
478
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
479
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
480
|
+
}, true);
|
|
481
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
406
482
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
407
483
|
});
|
|
408
484
|
let exporter, retries = 0, busy = false;
|
|
@@ -412,6 +488,7 @@ function addPosterDownloads() {
|
|
|
412
488
|
busy = true;
|
|
413
489
|
button.setAttribute('aria-disabled', 'true');
|
|
414
490
|
button.setAttribute('aria-busy', 'true');
|
|
491
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
415
492
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
416
493
|
error.hidden = true;
|
|
417
494
|
let url;
|
|
@@ -419,35 +496,53 @@ function addPosterDownloads() {
|
|
|
419
496
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
420
497
|
const { domToBlob } = await exporter;
|
|
421
498
|
await document.fonts.ready;
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
const
|
|
499
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
500
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
501
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
502
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
503
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
504
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
425
505
|
if (rule.selectorText && rule.style) {
|
|
426
|
-
const margins = [...rule.style].filter(
|
|
506
|
+
const margins = [...rule.style].filter(isMargin);
|
|
427
507
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
428
|
-
|
|
508
|
+
const insets = [...rule.style].filter(isInset);
|
|
509
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
510
|
+
const layout = [...margins, ...insets];
|
|
511
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
429
512
|
}
|
|
430
|
-
if (!rule.cssRules) return '';
|
|
431
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
432
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
433
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
513
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
514
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
515
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
516
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
434
517
|
}).join('\n');
|
|
435
|
-
const
|
|
436
|
-
|
|
518
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
519
|
+
const layout = sheets.map(sheet => {
|
|
520
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
437
521
|
}).join('\n');
|
|
438
522
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
439
523
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
440
|
-
const
|
|
441
|
-
if (clone.style && source !== poster
|
|
442
|
-
[...
|
|
443
|
-
|
|
524
|
+
const restoreLayout = (source, clone) => {
|
|
525
|
+
if (clone.style && source !== poster) {
|
|
526
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
527
|
+
const computed = getComputedStyle(source);
|
|
528
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
529
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
530
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
531
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
532
|
+
if (insets) {
|
|
533
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
534
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
535
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
536
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
537
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
538
|
+
}
|
|
444
539
|
}
|
|
445
540
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
446
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
541
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
447
542
|
};
|
|
448
543
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
449
544
|
const fontFiles = new Map();
|
|
450
|
-
const fontRules = await Promise.all(
|
|
545
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
451
546
|
let css;
|
|
452
547
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
453
548
|
catch (cause) {
|
|
@@ -498,12 +593,12 @@ function addPosterDownloads() {
|
|
|
498
593
|
node.style.backgroundImage = background;
|
|
499
594
|
};
|
|
500
595
|
const blob = await domToBlob(poster, {
|
|
501
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
596
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
502
597
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
503
598
|
onCloneNode: clone => {
|
|
504
|
-
|
|
599
|
+
restoreLayout(poster, clone);
|
|
505
600
|
const style = document.createElement('style');
|
|
506
|
-
style.textContent =
|
|
601
|
+
style.textContent = layout;
|
|
507
602
|
clone.prepend(style);
|
|
508
603
|
},
|
|
509
604
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -531,10 +626,11 @@ function addPosterDownloads() {
|
|
|
531
626
|
busy = false;
|
|
532
627
|
button.removeAttribute('aria-disabled');
|
|
533
628
|
button.removeAttribute('aria-busy');
|
|
629
|
+
fitButton.removeAttribute('aria-disabled');
|
|
534
630
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
535
631
|
}
|
|
536
632
|
});
|
|
537
|
-
tools.append(
|
|
633
|
+
tools.append(actions);
|
|
538
634
|
});
|
|
539
635
|
}
|
|
540
636
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
@@ -148,24 +148,84 @@ render(() => html`<${StaffSurvey} />`, document.getElementById("chart"));
|
|
|
148
148
|
</script>
|
|
149
149
|
<style id="poster-download-styles">
|
|
150
150
|
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
151
|
-
.poster-
|
|
152
|
-
.poster-download
|
|
153
|
-
.poster-
|
|
154
|
-
.poster-
|
|
155
|
-
.poster-
|
|
151
|
+
.poster-actions { display: flex; align-items: center; margin-left: auto; }
|
|
152
|
+
.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; }
|
|
153
|
+
.poster-actions svg { display: block; width: 20px; height: 20px; }
|
|
154
|
+
.poster-actions button:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
155
|
+
.poster-actions button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
156
|
+
.poster-actions button[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
157
|
+
.poster-fit-frame, .poster-fit-content { display: contents; }
|
|
158
|
+
.poster-fit-frame { max-height: 95svh; }
|
|
159
|
+
.poster-fit-frame.is-fitted { display: block; margin: 0 auto; overflow: clip; }
|
|
160
|
+
.poster-fit-frame.is-fitted > .poster-fit-content { display: block; transform-origin: top left; }
|
|
156
161
|
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
157
|
-
@media print { .poster-download-tools, .poster-
|
|
162
|
+
@media print { .poster-download-tools, .poster-actions, .poster-download-error { display: none !important; } }
|
|
158
163
|
</style>
|
|
159
164
|
<script type="module" id="poster-download-script">
|
|
165
|
+
function addPosterFit(poster, button, changed) {
|
|
166
|
+
const frame = document.createElement('div'), content = document.createElement('div');
|
|
167
|
+
frame.className = 'poster-fit-frame'; content.className = 'poster-fit-content';
|
|
168
|
+
poster.before(frame); frame.append(content); content.append(poster);
|
|
169
|
+
const properties = ['width', 'max-width', 'margin', 'box-sizing'];
|
|
170
|
+
const original = properties.map(name => [name, poster.style.getPropertyValue(name), poster.style.getPropertyPriority(name)]);
|
|
171
|
+
let fitted = false, scale = 1, request = 0, layout = null;
|
|
172
|
+
const availableHeight = () => parseFloat(getComputedStyle(frame).maxHeight) || innerHeight * .95;
|
|
173
|
+
const layoutKey = () => {
|
|
174
|
+
const css = getComputedStyle(poster);
|
|
175
|
+
return [innerWidth, availableHeight(), css.width, css.height].join('/');
|
|
176
|
+
};
|
|
177
|
+
const restore = () => original.forEach(([name, value, priority]) => {
|
|
178
|
+
if (value) poster.style.setProperty(name, value, priority);
|
|
179
|
+
else poster.style.removeProperty(name);
|
|
180
|
+
});
|
|
181
|
+
function update() {
|
|
182
|
+
request = 0;
|
|
183
|
+
if (fitted && layout === layoutKey()) return;
|
|
184
|
+
frame.classList.remove('is-fitted'); frame.style.width = ''; frame.style.height = '';
|
|
185
|
+
content.style.width = ''; content.style.transform = ''; restore(); scale = 1;
|
|
186
|
+
if (fitted) {
|
|
187
|
+
const box = poster.getBoundingClientRect();
|
|
188
|
+
scale = Math.min(1, availableHeight() / box.height);
|
|
189
|
+
poster.style.setProperty('box-sizing', 'border-box');
|
|
190
|
+
poster.style.setProperty('width', `${box.width}px`);
|
|
191
|
+
poster.style.setProperty('max-width', 'none');
|
|
192
|
+
poster.style.setProperty('margin', '0');
|
|
193
|
+
content.style.width = `${box.width}px`;
|
|
194
|
+
content.style.transform = `scale(${scale})`;
|
|
195
|
+
frame.style.width = `${box.width * scale}px`; frame.style.height = `${box.height * scale}px`;
|
|
196
|
+
frame.classList.add('is-fitted');
|
|
197
|
+
}
|
|
198
|
+
layout = layoutKey();
|
|
199
|
+
const label = fitted ? 'Restore poster size' : 'Fit poster to screen';
|
|
200
|
+
button.setAttribute('aria-label', label); button.title = label;
|
|
201
|
+
button.setAttribute('aria-pressed', String(fitted));
|
|
202
|
+
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>`;
|
|
203
|
+
changed();
|
|
204
|
+
poster.dispatchEvent(new Event('poster-view-change'));
|
|
205
|
+
}
|
|
206
|
+
const schedule = () => { if (fitted && !request && layout !== layoutKey()) request = requestAnimationFrame(update); };
|
|
207
|
+
new ResizeObserver(schedule).observe(poster);
|
|
208
|
+
addEventListener('resize', schedule);
|
|
209
|
+
document.fonts.ready.then(schedule);
|
|
210
|
+
update();
|
|
211
|
+
return {
|
|
212
|
+
toggle() { fitted = !fitted; layout = null; cancelAnimationFrame(request); update(); },
|
|
213
|
+
dimensions() {
|
|
214
|
+
if (!fitted) { const box = poster.getBoundingClientRect(); return {width:box.width,height:box.height}; }
|
|
215
|
+
const css = getComputedStyle(poster);
|
|
216
|
+
return {width:parseFloat(css.width),height:parseFloat(css.height)};
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
160
220
|
function addPosterDownloads() {
|
|
161
221
|
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
162
222
|
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
163
223
|
const tools = navigation || document.createElement('div');
|
|
164
224
|
if (tools.querySelector('.poster-download')) return;
|
|
225
|
+
const align = () => { if (!navigation) tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
165
226
|
if (!navigation) {
|
|
166
227
|
tools.className = 'poster-download-tools';
|
|
167
228
|
poster.before(tools);
|
|
168
|
-
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
169
229
|
new ResizeObserver(align).observe(poster);
|
|
170
230
|
align();
|
|
171
231
|
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
@@ -183,27 +243,43 @@ function addPosterDownloads() {
|
|
|
183
243
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
184
244
|
button.title = 'Download poster as PNG';
|
|
185
245
|
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>';
|
|
246
|
+
const actions = document.createElement('div'), fitButton = document.createElement('button');
|
|
247
|
+
actions.className = 'poster-actions'; fitButton.className = 'poster-fit-toggle'; fitButton.type = 'button';
|
|
248
|
+
if (!poster.id) {
|
|
249
|
+
let id = `rhp-poster-${index + 1}`;
|
|
250
|
+
while (document.getElementById(id)) id += '-view';
|
|
251
|
+
poster.id = id;
|
|
252
|
+
}
|
|
253
|
+
fitButton.setAttribute('aria-controls', poster.id);
|
|
254
|
+
fitButton.setAttribute('data-poster-view-toggle', '');
|
|
255
|
+
const fit = addPosterFit(poster, fitButton, align);
|
|
256
|
+
fitButton.addEventListener('click', event => { event.stopPropagation(); if (!busy) fit.toggle(); });
|
|
257
|
+
actions.append(fitButton, button);
|
|
186
258
|
const error = document.createElement('p');
|
|
187
259
|
error.className = 'poster-download-error';
|
|
188
260
|
error.setAttribute('role', 'alert');
|
|
189
261
|
error.hidden = true;
|
|
190
262
|
tools.after(error);
|
|
191
263
|
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
192
|
-
|
|
264
|
+
actions.addEventListener('pointerdown', event => {
|
|
193
265
|
event.stopPropagation();
|
|
194
266
|
});
|
|
195
|
-
|
|
267
|
+
actions.addEventListener('mousedown', event => {
|
|
196
268
|
// Pointer activation downloads without taking focus from the selected chart item.
|
|
197
269
|
event.preventDefault();
|
|
198
270
|
event.stopPropagation();
|
|
199
271
|
});
|
|
200
272
|
poster.addEventListener('focusout', event => {
|
|
201
|
-
if (
|
|
273
|
+
if (actions.contains(event.relatedTarget) || actions.querySelector(':active')) event.stopImmediatePropagation();
|
|
202
274
|
}, true);
|
|
203
275
|
poster.addEventListener('pointerleave', event => {
|
|
204
|
-
if (
|
|
276
|
+
if (actions.contains(event.relatedTarget) || actions.contains(document.activeElement)) event.stopImmediatePropagation();
|
|
277
|
+
}, true);
|
|
278
|
+
poster.addEventListener('pointermove', event => {
|
|
279
|
+
// Safari can move the pointer over the title while a keyboard-triggered download is being prepared.
|
|
280
|
+
if (actions.contains(document.activeElement) && !event.target.closest('.rhp-chart')) event.stopImmediatePropagation();
|
|
205
281
|
}, true);
|
|
206
|
-
for (const type of ['keydown', 'keyup'])
|
|
282
|
+
for (const type of ['keydown', 'keyup']) actions.addEventListener(type, event => {
|
|
207
283
|
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
208
284
|
});
|
|
209
285
|
let exporter, retries = 0, busy = false;
|
|
@@ -213,6 +289,7 @@ function addPosterDownloads() {
|
|
|
213
289
|
busy = true;
|
|
214
290
|
button.setAttribute('aria-disabled', 'true');
|
|
215
291
|
button.setAttribute('aria-busy', 'true');
|
|
292
|
+
fitButton.setAttribute('aria-disabled', 'true');
|
|
216
293
|
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
217
294
|
error.hidden = true;
|
|
218
295
|
let url;
|
|
@@ -220,35 +297,53 @@ function addPosterDownloads() {
|
|
|
220
297
|
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
221
298
|
const { domToBlob } = await exporter;
|
|
222
299
|
await document.fonts.ready;
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
const
|
|
300
|
+
// Preserve author layout rules where computed styles lose the grid's used geometry.
|
|
301
|
+
// Safari misreports percentage insets in subgrids, and grid auto margins resolve to zero.
|
|
302
|
+
const autoMargins = new Set(), positioned = new Set();
|
|
303
|
+
const isMargin = name => name === 'margin' || name.startsWith('margin-');
|
|
304
|
+
const isInset = name => name === 'inset' || name.startsWith('inset-') || ['top', 'right', 'bottom', 'left'].includes(name);
|
|
305
|
+
const layoutRules = rules => [...rules].map(rule => {
|
|
226
306
|
if (rule.selectorText && rule.style) {
|
|
227
|
-
const margins = [...rule.style].filter(
|
|
307
|
+
const margins = [...rule.style].filter(isMargin);
|
|
228
308
|
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
229
|
-
|
|
309
|
+
const insets = [...rule.style].filter(isInset);
|
|
310
|
+
if (insets.length) positioned.add(rule.selectorText);
|
|
311
|
+
const layout = [...margins, ...insets];
|
|
312
|
+
return layout.length ? `${rule.selectorText} { ${layout.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
230
313
|
}
|
|
231
|
-
if (!rule.cssRules) return '';
|
|
232
|
-
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ?
|
|
233
|
-
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ?
|
|
234
|
-
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) +
|
|
314
|
+
if (!rule.cssRules) return /^@layer\b/.test(rule.cssText) ? rule.cssText : '';
|
|
315
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? layoutRules(rule.cssRules) : '';
|
|
316
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? layoutRules(rule.cssRules) : '';
|
|
317
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + layoutRules(rule.cssRules) + '}';
|
|
235
318
|
}).join('\n');
|
|
236
|
-
const
|
|
237
|
-
|
|
319
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets];
|
|
320
|
+
const layout = sheets.map(sheet => {
|
|
321
|
+
try { return layoutRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
238
322
|
}).join('\n');
|
|
239
323
|
// Omit display-none HTML subtrees, such as collapsed chart rows, from the image and its SVG payload.
|
|
240
324
|
const includeNode = node => !(node instanceof HTMLElement) || getComputedStyle(node).display !== 'none';
|
|
241
|
-
const
|
|
242
|
-
if (clone.style && source !== poster
|
|
243
|
-
[...
|
|
244
|
-
|
|
325
|
+
const restoreLayout = (source, clone) => {
|
|
326
|
+
if (clone.style && source !== poster) {
|
|
327
|
+
const margins = [...autoMargins].some(selector => source.matches(selector));
|
|
328
|
+
const computed = getComputedStyle(source);
|
|
329
|
+
const insets = computed.position === 'absolute' && [...positioned].some(selector => source.matches(selector));
|
|
330
|
+
const restore = name => (margins && isMargin(name)) || (insets && isInset(name));
|
|
331
|
+
[...clone.style].filter(restore).forEach(name => clone.style.removeProperty(name));
|
|
332
|
+
[...source.style].filter(restore).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
333
|
+
if (insets) {
|
|
334
|
+
// Only the scaled axis needs its percentage rule. Freeze the other axis in pixels:
|
|
335
|
+
// Firefox does not reevaluate container queries reliably inside an SVG foreignObject.
|
|
336
|
+
const axis = source.getAttribute('data-rhp-o');
|
|
337
|
+
const fixed = axis === 'h' ? ['top', 'bottom'] : axis === 'v' ? ['left', 'right'] : ['top', 'right', 'bottom', 'left'];
|
|
338
|
+
fixed.forEach(name => clone.style.setProperty(name, computed.getPropertyValue(name), 'important'));
|
|
339
|
+
}
|
|
245
340
|
}
|
|
246
341
|
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName) && includeNode(node));
|
|
247
|
-
[...clone.children].forEach((child, i) => { if (children[i])
|
|
342
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreLayout(children[i], child); });
|
|
248
343
|
};
|
|
249
344
|
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
250
345
|
const fontFiles = new Map();
|
|
251
|
-
const fontRules = await Promise.all(
|
|
346
|
+
const fontRules = await Promise.all(sheets.map(async sheet => {
|
|
252
347
|
let css;
|
|
253
348
|
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
254
349
|
catch (cause) {
|
|
@@ -299,12 +394,12 @@ function addPosterDownloads() {
|
|
|
299
394
|
node.style.backgroundImage = background;
|
|
300
395
|
};
|
|
301
396
|
const blob = await domToBlob(poster, {
|
|
302
|
-
scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
397
|
+
...fit.dimensions(), scale: 2, style: { margin: '0' }, filter: includeNode,
|
|
303
398
|
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
304
399
|
onCloneNode: clone => {
|
|
305
|
-
|
|
400
|
+
restoreLayout(poster, clone);
|
|
306
401
|
const style = document.createElement('style');
|
|
307
|
-
style.textContent =
|
|
402
|
+
style.textContent = layout;
|
|
308
403
|
clone.prepend(style);
|
|
309
404
|
},
|
|
310
405
|
// CSS content's alternative text follows a slash and must not be painted.
|
|
@@ -332,10 +427,11 @@ function addPosterDownloads() {
|
|
|
332
427
|
busy = false;
|
|
333
428
|
button.removeAttribute('aria-disabled');
|
|
334
429
|
button.removeAttribute('aria-busy');
|
|
430
|
+
fitButton.removeAttribute('aria-disabled');
|
|
335
431
|
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
336
432
|
}
|
|
337
433
|
});
|
|
338
|
-
tools.append(
|
|
434
|
+
tools.append(actions);
|
|
339
435
|
});
|
|
340
436
|
}
|
|
341
437
|
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|