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