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