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