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