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