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