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