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