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