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