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