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