@bezda/rhp-mcp 0.1.3 → 0.1.4
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 +1 -1
- package/skill/SKILL.md +5 -0
- package/skill/recipes/area.html +199 -0
- package/skill/recipes/bar.html +199 -0
- package/skill/recipes/box-plot.html +199 -0
- package/skill/recipes/bubble.html +199 -0
- package/skill/recipes/bullet.html +199 -0
- package/skill/recipes/candlestick.html +199 -0
- package/skill/recipes/column.html +199 -0
- package/skill/recipes/dial.html +199 -0
- package/skill/recipes/diverging-bars.html +199 -0
- package/skill/recipes/donut.html +199 -0
- package/skill/recipes/dumbbell.html +199 -0
- package/skill/recipes/gantt.html +199 -0
- package/skill/recipes/grouped-bars.html +199 -0
- package/skill/recipes/heatmap.html +199 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +199 -0
- package/skill/recipes/live.html +199 -0
- package/skill/recipes/log-line.html +199 -0
- package/skill/recipes/lollipop.html +199 -0
- package/skill/recipes/multi-line.html +199 -0
- package/skill/recipes/pyramid.html +199 -0
- package/skill/recipes/race.html +199 -0
- package/skill/recipes/radial-bars.html +199 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +199 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +199 -0
- package/skill/recipes/stacked-100.html +199 -0
- package/skill/recipes/stacked-bars.html +199 -0
- package/skill/recipes/strip.html +199 -0
- package/skill/recipes/unit-stack.html +199 -0
- package/skill/recipes/violin.html +199 -0
- package/skill/recipes/waffle.html +199 -0
- package/skill/recipes/waterfall.html +199 -0
- package/skill/references/download.md +218 -0
- package/skill/references/environments.md +1 -0
- package/src/server.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bezda/rhp-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Make charts with rhp from any AI agent: an MCP server with rhp's guide, references and recipes, and a checker that renders a chart in a browser and reports what is wrong, with screenshots",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rhp",
|
package/skill/SKILL.md
CHANGED
|
@@ -195,6 +195,10 @@ A chart with fixed data and no interaction gets `static=${true}` on its Chart: r
|
|
|
195
195
|
|
|
196
196
|
### 6. Build it, with the recipe's technique
|
|
197
197
|
|
|
198
|
+
For every standalone HTML poster page, keep the recipe's PNG download control, or copy the complete block from [download.md](references/download.md) before `</body>`.
|
|
199
|
+
It is an icon above the poster on the right, outside the saved image; on gallery recipe pages it shares the row and color of the gallery link.
|
|
200
|
+
It saves the full poster, including the headline and note, even when the page scrolls.
|
|
201
|
+
|
|
198
202
|
- Keep the recipe's code format and section order: data, then slat types, then the chart component, then mounting.
|
|
199
203
|
With `ManyDots`, omit slat types and put the collection directly inside the Chart.
|
|
200
204
|
- Use the user's data exactly, every value, in their units.
|
|
@@ -444,6 +448,7 @@ Read the ones the chart needs; each is self-contained.
|
|
|
444
448
|
- [design.md](references/design.md): art direction from the subject (idea, marks, composition, palette, type), the poster, color, type, layout, annotation, charts inside an app, a style the user asks for.
|
|
445
449
|
- [looks.md](references/looks.md): eight looks written out in full (tokens, fonts, texture, type), as examples of how a look is written, not a menu.
|
|
446
450
|
- [interaction.md](references/interaction.md): which interaction for which story, with complete code for each.
|
|
451
|
+
- [download.md](references/download.md): the download icon for standalone HTML posters, with a complete PNG export block.
|
|
447
452
|
- [performance.md](references/performance.md): measured optimizations for an expensive chart, preserving its slats, styling, state, and interactions.
|
|
448
453
|
- [environments.md](references/environments.md): plain HTML, Solid, Astro, React, Next.js, Vue, Svelte and Angular, each verified, and the html template to JSX rules.
|
|
449
454
|
- [api.md](references/api.md): every component, prop, helper and CSS variable, exactly.
|
package/skill/recipes/area.html
CHANGED
|
@@ -152,5 +152,204 @@ function PowerChart() {
|
|
|
152
152
|
|
|
153
153
|
render(() => html`<${PowerChart} />`, document.getElementById("chart"));
|
|
154
154
|
</script>
|
|
155
|
+
<style id="poster-download-styles">
|
|
156
|
+
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
157
|
+
.poster-download { 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; }
|
|
158
|
+
.poster-download svg { display: block; width: 20px; height: 20px; }
|
|
159
|
+
.poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
160
|
+
.poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
161
|
+
.poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
162
|
+
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
163
|
+
@media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
|
|
164
|
+
</style>
|
|
165
|
+
<script type="module" id="poster-download-script">
|
|
166
|
+
function addPosterDownloads() {
|
|
167
|
+
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
168
|
+
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
169
|
+
const tools = navigation || document.createElement('div');
|
|
170
|
+
if (tools.querySelector('.poster-download')) return;
|
|
171
|
+
if (!navigation) {
|
|
172
|
+
tools.className = 'poster-download-tools';
|
|
173
|
+
poster.before(tools);
|
|
174
|
+
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
175
|
+
new ResizeObserver(align).observe(poster);
|
|
176
|
+
align();
|
|
177
|
+
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
178
|
+
if (rgb) {
|
|
179
|
+
const light = rgb.slice(0, 3).map(Number).map(v => {
|
|
180
|
+
v /= 255;
|
|
181
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
182
|
+
});
|
|
183
|
+
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const button = document.createElement('button');
|
|
187
|
+
button.type = 'button';
|
|
188
|
+
button.className = 'poster-download';
|
|
189
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
190
|
+
button.title = 'Download poster as PNG';
|
|
191
|
+
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>';
|
|
192
|
+
const error = document.createElement('p');
|
|
193
|
+
error.className = 'poster-download-error';
|
|
194
|
+
error.setAttribute('role', 'alert');
|
|
195
|
+
error.hidden = true;
|
|
196
|
+
tools.after(error);
|
|
197
|
+
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
198
|
+
button.addEventListener('pointerdown', event => {
|
|
199
|
+
event.stopPropagation();
|
|
200
|
+
});
|
|
201
|
+
button.addEventListener('mousedown', event => {
|
|
202
|
+
event.stopPropagation();
|
|
203
|
+
});
|
|
204
|
+
poster.addEventListener('focusout', event => {
|
|
205
|
+
if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
|
|
206
|
+
}, true);
|
|
207
|
+
poster.addEventListener('pointerleave', event => {
|
|
208
|
+
if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
|
|
209
|
+
}, true);
|
|
210
|
+
for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
|
|
211
|
+
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
212
|
+
});
|
|
213
|
+
let exporter, retries = 0, busy = false;
|
|
214
|
+
button.addEventListener('click', async event => {
|
|
215
|
+
event.stopPropagation();
|
|
216
|
+
if (busy) return;
|
|
217
|
+
busy = true;
|
|
218
|
+
button.setAttribute('aria-disabled', 'true');
|
|
219
|
+
button.setAttribute('aria-busy', 'true');
|
|
220
|
+
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
221
|
+
error.hidden = true;
|
|
222
|
+
let url;
|
|
223
|
+
try {
|
|
224
|
+
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
225
|
+
const { domToBlob } = await exporter;
|
|
226
|
+
await document.fonts.ready;
|
|
227
|
+
// Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
|
|
228
|
+
const autoMargins = new Set();
|
|
229
|
+
const marginRules = rules => [...rules].map(rule => {
|
|
230
|
+
if (rule.selectorText && rule.style) {
|
|
231
|
+
const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
|
|
232
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
233
|
+
return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
234
|
+
}
|
|
235
|
+
if (!rule.cssRules) return '';
|
|
236
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
|
|
237
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
|
|
238
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
|
|
239
|
+
}).join('\n');
|
|
240
|
+
const margins = [...document.styleSheets].map(sheet => {
|
|
241
|
+
try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
242
|
+
}).join('\n');
|
|
243
|
+
const restoreMargins = (source, clone) => {
|
|
244
|
+
if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
|
|
245
|
+
[...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
|
|
246
|
+
[...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
247
|
+
}
|
|
248
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
|
|
249
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
|
|
250
|
+
};
|
|
251
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
252
|
+
const fontFiles = new Map();
|
|
253
|
+
const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
|
|
254
|
+
let css;
|
|
255
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
256
|
+
catch (cause) {
|
|
257
|
+
if (!sheet.href) return '';
|
|
258
|
+
const response = await fetch(sheet.href);
|
|
259
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
260
|
+
css = await response.text();
|
|
261
|
+
}
|
|
262
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
263
|
+
return (await Promise.all(rules.map(async rule => {
|
|
264
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
265
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
266
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
267
|
+
const response = await fetch(source);
|
|
268
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
269
|
+
const blob = await response.blob();
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const reader = new FileReader();
|
|
272
|
+
reader.onload = () => resolve(reader.result);
|
|
273
|
+
reader.onerror = reject;
|
|
274
|
+
reader.readAsDataURL(blob);
|
|
275
|
+
});
|
|
276
|
+
})());
|
|
277
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
278
|
+
}
|
|
279
|
+
return rule;
|
|
280
|
+
}))).join('\n');
|
|
281
|
+
}));
|
|
282
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
283
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
284
|
+
const textures = new Map();
|
|
285
|
+
const embedTexture = async (node) => {
|
|
286
|
+
if (!node.style) return;
|
|
287
|
+
let background = node.style.backgroundImage;
|
|
288
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
289
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
290
|
+
const image = new Image();
|
|
291
|
+
image.src = source;
|
|
292
|
+
await image.decode();
|
|
293
|
+
const canvas = document.createElement('canvas');
|
|
294
|
+
canvas.width = image.naturalWidth;
|
|
295
|
+
canvas.height = image.naturalHeight;
|
|
296
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
297
|
+
return canvas.toDataURL();
|
|
298
|
+
})());
|
|
299
|
+
background = background.replace(source, await textures.get(source));
|
|
300
|
+
}
|
|
301
|
+
node.style.backgroundImage = background;
|
|
302
|
+
};
|
|
303
|
+
const blob = await domToBlob(poster, {
|
|
304
|
+
scale: 2, style: { margin: '0' },
|
|
305
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
306
|
+
onCloneNode: clone => {
|
|
307
|
+
restoreMargins(poster, clone);
|
|
308
|
+
const style = document.createElement('style');
|
|
309
|
+
style.textContent = margins;
|
|
310
|
+
clone.prepend(style);
|
|
311
|
+
},
|
|
312
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
313
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
314
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
if (!blob) throw new Error('No PNG was created');
|
|
318
|
+
url = URL.createObjectURL(blob);
|
|
319
|
+
const link = document.createElement('a');
|
|
320
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
321
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
|
|
322
|
+
link.href = url;
|
|
323
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
324
|
+
document.body.append(link);
|
|
325
|
+
link.click();
|
|
326
|
+
link.remove();
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
exporter = null;
|
|
329
|
+
retries++;
|
|
330
|
+
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
331
|
+
error.hidden = false;
|
|
332
|
+
} finally {
|
|
333
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
334
|
+
busy = false;
|
|
335
|
+
button.removeAttribute('aria-disabled');
|
|
336
|
+
button.removeAttribute('aria-busy');
|
|
337
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
tools.append(button);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
344
|
+
if (document.querySelector('.poster')) addPosterDownloads();
|
|
345
|
+
else {
|
|
346
|
+
const observer = new MutationObserver(() => {
|
|
347
|
+
if (!document.querySelector('.poster')) return;
|
|
348
|
+
observer.disconnect();
|
|
349
|
+
addPosterDownloads();
|
|
350
|
+
});
|
|
351
|
+
observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
|
|
352
|
+
}
|
|
353
|
+
</script>
|
|
155
354
|
</body>
|
|
156
355
|
</html>
|
package/skill/recipes/bar.html
CHANGED
|
@@ -143,5 +143,204 @@ function SpokenLanguages() {
|
|
|
143
143
|
|
|
144
144
|
render(() => html`<${SpokenLanguages} />`, document.getElementById("chart"));
|
|
145
145
|
</script>
|
|
146
|
+
<style id="poster-download-styles">
|
|
147
|
+
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
148
|
+
.poster-download { 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; }
|
|
149
|
+
.poster-download svg { display: block; width: 20px; height: 20px; }
|
|
150
|
+
.poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
151
|
+
.poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
152
|
+
.poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
153
|
+
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
154
|
+
@media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
|
|
155
|
+
</style>
|
|
156
|
+
<script type="module" id="poster-download-script">
|
|
157
|
+
function addPosterDownloads() {
|
|
158
|
+
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
159
|
+
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
160
|
+
const tools = navigation || document.createElement('div');
|
|
161
|
+
if (tools.querySelector('.poster-download')) return;
|
|
162
|
+
if (!navigation) {
|
|
163
|
+
tools.className = 'poster-download-tools';
|
|
164
|
+
poster.before(tools);
|
|
165
|
+
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
166
|
+
new ResizeObserver(align).observe(poster);
|
|
167
|
+
align();
|
|
168
|
+
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
169
|
+
if (rgb) {
|
|
170
|
+
const light = rgb.slice(0, 3).map(Number).map(v => {
|
|
171
|
+
v /= 255;
|
|
172
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
173
|
+
});
|
|
174
|
+
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const button = document.createElement('button');
|
|
178
|
+
button.type = 'button';
|
|
179
|
+
button.className = 'poster-download';
|
|
180
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
181
|
+
button.title = 'Download poster as PNG';
|
|
182
|
+
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>';
|
|
183
|
+
const error = document.createElement('p');
|
|
184
|
+
error.className = 'poster-download-error';
|
|
185
|
+
error.setAttribute('role', 'alert');
|
|
186
|
+
error.hidden = true;
|
|
187
|
+
tools.after(error);
|
|
188
|
+
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
189
|
+
button.addEventListener('pointerdown', event => {
|
|
190
|
+
event.stopPropagation();
|
|
191
|
+
});
|
|
192
|
+
button.addEventListener('mousedown', event => {
|
|
193
|
+
event.stopPropagation();
|
|
194
|
+
});
|
|
195
|
+
poster.addEventListener('focusout', event => {
|
|
196
|
+
if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
|
|
197
|
+
}, true);
|
|
198
|
+
poster.addEventListener('pointerleave', event => {
|
|
199
|
+
if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
|
|
200
|
+
}, true);
|
|
201
|
+
for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
|
|
202
|
+
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
203
|
+
});
|
|
204
|
+
let exporter, retries = 0, busy = false;
|
|
205
|
+
button.addEventListener('click', async event => {
|
|
206
|
+
event.stopPropagation();
|
|
207
|
+
if (busy) return;
|
|
208
|
+
busy = true;
|
|
209
|
+
button.setAttribute('aria-disabled', 'true');
|
|
210
|
+
button.setAttribute('aria-busy', 'true');
|
|
211
|
+
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
212
|
+
error.hidden = true;
|
|
213
|
+
let url;
|
|
214
|
+
try {
|
|
215
|
+
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
216
|
+
const { domToBlob } = await exporter;
|
|
217
|
+
await document.fonts.ready;
|
|
218
|
+
// Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
|
|
219
|
+
const autoMargins = new Set();
|
|
220
|
+
const marginRules = rules => [...rules].map(rule => {
|
|
221
|
+
if (rule.selectorText && rule.style) {
|
|
222
|
+
const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
|
|
223
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
224
|
+
return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
225
|
+
}
|
|
226
|
+
if (!rule.cssRules) return '';
|
|
227
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
|
|
228
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
|
|
229
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
|
|
230
|
+
}).join('\n');
|
|
231
|
+
const margins = [...document.styleSheets].map(sheet => {
|
|
232
|
+
try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
233
|
+
}).join('\n');
|
|
234
|
+
const restoreMargins = (source, clone) => {
|
|
235
|
+
if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
|
|
236
|
+
[...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
|
|
237
|
+
[...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
238
|
+
}
|
|
239
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
|
|
240
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
|
|
241
|
+
};
|
|
242
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
243
|
+
const fontFiles = new Map();
|
|
244
|
+
const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
|
|
245
|
+
let css;
|
|
246
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
247
|
+
catch (cause) {
|
|
248
|
+
if (!sheet.href) return '';
|
|
249
|
+
const response = await fetch(sheet.href);
|
|
250
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
251
|
+
css = await response.text();
|
|
252
|
+
}
|
|
253
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
254
|
+
return (await Promise.all(rules.map(async rule => {
|
|
255
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
256
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
257
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
258
|
+
const response = await fetch(source);
|
|
259
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
260
|
+
const blob = await response.blob();
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const reader = new FileReader();
|
|
263
|
+
reader.onload = () => resolve(reader.result);
|
|
264
|
+
reader.onerror = reject;
|
|
265
|
+
reader.readAsDataURL(blob);
|
|
266
|
+
});
|
|
267
|
+
})());
|
|
268
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
269
|
+
}
|
|
270
|
+
return rule;
|
|
271
|
+
}))).join('\n');
|
|
272
|
+
}));
|
|
273
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
274
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
275
|
+
const textures = new Map();
|
|
276
|
+
const embedTexture = async (node) => {
|
|
277
|
+
if (!node.style) return;
|
|
278
|
+
let background = node.style.backgroundImage;
|
|
279
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
280
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
281
|
+
const image = new Image();
|
|
282
|
+
image.src = source;
|
|
283
|
+
await image.decode();
|
|
284
|
+
const canvas = document.createElement('canvas');
|
|
285
|
+
canvas.width = image.naturalWidth;
|
|
286
|
+
canvas.height = image.naturalHeight;
|
|
287
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
288
|
+
return canvas.toDataURL();
|
|
289
|
+
})());
|
|
290
|
+
background = background.replace(source, await textures.get(source));
|
|
291
|
+
}
|
|
292
|
+
node.style.backgroundImage = background;
|
|
293
|
+
};
|
|
294
|
+
const blob = await domToBlob(poster, {
|
|
295
|
+
scale: 2, style: { margin: '0' },
|
|
296
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
297
|
+
onCloneNode: clone => {
|
|
298
|
+
restoreMargins(poster, clone);
|
|
299
|
+
const style = document.createElement('style');
|
|
300
|
+
style.textContent = margins;
|
|
301
|
+
clone.prepend(style);
|
|
302
|
+
},
|
|
303
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
304
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
305
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
if (!blob) throw new Error('No PNG was created');
|
|
309
|
+
url = URL.createObjectURL(blob);
|
|
310
|
+
const link = document.createElement('a');
|
|
311
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
312
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
|
|
313
|
+
link.href = url;
|
|
314
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
315
|
+
document.body.append(link);
|
|
316
|
+
link.click();
|
|
317
|
+
link.remove();
|
|
318
|
+
} catch (cause) {
|
|
319
|
+
exporter = null;
|
|
320
|
+
retries++;
|
|
321
|
+
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
322
|
+
error.hidden = false;
|
|
323
|
+
} finally {
|
|
324
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
325
|
+
busy = false;
|
|
326
|
+
button.removeAttribute('aria-disabled');
|
|
327
|
+
button.removeAttribute('aria-busy');
|
|
328
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
tools.append(button);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
335
|
+
if (document.querySelector('.poster')) addPosterDownloads();
|
|
336
|
+
else {
|
|
337
|
+
const observer = new MutationObserver(() => {
|
|
338
|
+
if (!document.querySelector('.poster')) return;
|
|
339
|
+
observer.disconnect();
|
|
340
|
+
addPosterDownloads();
|
|
341
|
+
});
|
|
342
|
+
observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
|
|
343
|
+
}
|
|
344
|
+
</script>
|
|
146
345
|
</body>
|
|
147
346
|
</html>
|
|
@@ -162,5 +162,204 @@ function CommuteTimes() {
|
|
|
162
162
|
|
|
163
163
|
render(() => html`<${CommuteTimes} />`, document.getElementById("chart"));
|
|
164
164
|
</script>
|
|
165
|
+
<style id="poster-download-styles">
|
|
166
|
+
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
167
|
+
.poster-download { 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; }
|
|
168
|
+
.poster-download svg { display: block; width: 20px; height: 20px; }
|
|
169
|
+
.poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
170
|
+
.poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
171
|
+
.poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
172
|
+
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
173
|
+
@media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
|
|
174
|
+
</style>
|
|
175
|
+
<script type="module" id="poster-download-script">
|
|
176
|
+
function addPosterDownloads() {
|
|
177
|
+
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
178
|
+
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
179
|
+
const tools = navigation || document.createElement('div');
|
|
180
|
+
if (tools.querySelector('.poster-download')) return;
|
|
181
|
+
if (!navigation) {
|
|
182
|
+
tools.className = 'poster-download-tools';
|
|
183
|
+
poster.before(tools);
|
|
184
|
+
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
185
|
+
new ResizeObserver(align).observe(poster);
|
|
186
|
+
align();
|
|
187
|
+
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
188
|
+
if (rgb) {
|
|
189
|
+
const light = rgb.slice(0, 3).map(Number).map(v => {
|
|
190
|
+
v /= 255;
|
|
191
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
192
|
+
});
|
|
193
|
+
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const button = document.createElement('button');
|
|
197
|
+
button.type = 'button';
|
|
198
|
+
button.className = 'poster-download';
|
|
199
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
200
|
+
button.title = 'Download poster as PNG';
|
|
201
|
+
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>';
|
|
202
|
+
const error = document.createElement('p');
|
|
203
|
+
error.className = 'poster-download-error';
|
|
204
|
+
error.setAttribute('role', 'alert');
|
|
205
|
+
error.hidden = true;
|
|
206
|
+
tools.after(error);
|
|
207
|
+
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
208
|
+
button.addEventListener('pointerdown', event => {
|
|
209
|
+
event.stopPropagation();
|
|
210
|
+
});
|
|
211
|
+
button.addEventListener('mousedown', event => {
|
|
212
|
+
event.stopPropagation();
|
|
213
|
+
});
|
|
214
|
+
poster.addEventListener('focusout', event => {
|
|
215
|
+
if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
|
|
216
|
+
}, true);
|
|
217
|
+
poster.addEventListener('pointerleave', event => {
|
|
218
|
+
if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
|
|
219
|
+
}, true);
|
|
220
|
+
for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
|
|
221
|
+
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
222
|
+
});
|
|
223
|
+
let exporter, retries = 0, busy = false;
|
|
224
|
+
button.addEventListener('click', async event => {
|
|
225
|
+
event.stopPropagation();
|
|
226
|
+
if (busy) return;
|
|
227
|
+
busy = true;
|
|
228
|
+
button.setAttribute('aria-disabled', 'true');
|
|
229
|
+
button.setAttribute('aria-busy', 'true');
|
|
230
|
+
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
231
|
+
error.hidden = true;
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
235
|
+
const { domToBlob } = await exporter;
|
|
236
|
+
await document.fonts.ready;
|
|
237
|
+
// Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
|
|
238
|
+
const autoMargins = new Set();
|
|
239
|
+
const marginRules = rules => [...rules].map(rule => {
|
|
240
|
+
if (rule.selectorText && rule.style) {
|
|
241
|
+
const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
|
|
242
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
243
|
+
return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
244
|
+
}
|
|
245
|
+
if (!rule.cssRules) return '';
|
|
246
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
|
|
247
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
|
|
248
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
|
|
249
|
+
}).join('\n');
|
|
250
|
+
const margins = [...document.styleSheets].map(sheet => {
|
|
251
|
+
try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
252
|
+
}).join('\n');
|
|
253
|
+
const restoreMargins = (source, clone) => {
|
|
254
|
+
if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
|
|
255
|
+
[...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
|
|
256
|
+
[...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
257
|
+
}
|
|
258
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
|
|
259
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
|
|
260
|
+
};
|
|
261
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
262
|
+
const fontFiles = new Map();
|
|
263
|
+
const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
|
|
264
|
+
let css;
|
|
265
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
266
|
+
catch (cause) {
|
|
267
|
+
if (!sheet.href) return '';
|
|
268
|
+
const response = await fetch(sheet.href);
|
|
269
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
270
|
+
css = await response.text();
|
|
271
|
+
}
|
|
272
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
273
|
+
return (await Promise.all(rules.map(async rule => {
|
|
274
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
275
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
276
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
277
|
+
const response = await fetch(source);
|
|
278
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
279
|
+
const blob = await response.blob();
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
const reader = new FileReader();
|
|
282
|
+
reader.onload = () => resolve(reader.result);
|
|
283
|
+
reader.onerror = reject;
|
|
284
|
+
reader.readAsDataURL(blob);
|
|
285
|
+
});
|
|
286
|
+
})());
|
|
287
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
288
|
+
}
|
|
289
|
+
return rule;
|
|
290
|
+
}))).join('\n');
|
|
291
|
+
}));
|
|
292
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
293
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
294
|
+
const textures = new Map();
|
|
295
|
+
const embedTexture = async (node) => {
|
|
296
|
+
if (!node.style) return;
|
|
297
|
+
let background = node.style.backgroundImage;
|
|
298
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
299
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
300
|
+
const image = new Image();
|
|
301
|
+
image.src = source;
|
|
302
|
+
await image.decode();
|
|
303
|
+
const canvas = document.createElement('canvas');
|
|
304
|
+
canvas.width = image.naturalWidth;
|
|
305
|
+
canvas.height = image.naturalHeight;
|
|
306
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
307
|
+
return canvas.toDataURL();
|
|
308
|
+
})());
|
|
309
|
+
background = background.replace(source, await textures.get(source));
|
|
310
|
+
}
|
|
311
|
+
node.style.backgroundImage = background;
|
|
312
|
+
};
|
|
313
|
+
const blob = await domToBlob(poster, {
|
|
314
|
+
scale: 2, style: { margin: '0' },
|
|
315
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
316
|
+
onCloneNode: clone => {
|
|
317
|
+
restoreMargins(poster, clone);
|
|
318
|
+
const style = document.createElement('style');
|
|
319
|
+
style.textContent = margins;
|
|
320
|
+
clone.prepend(style);
|
|
321
|
+
},
|
|
322
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
323
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
324
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
if (!blob) throw new Error('No PNG was created');
|
|
328
|
+
url = URL.createObjectURL(blob);
|
|
329
|
+
const link = document.createElement('a');
|
|
330
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
331
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
|
|
332
|
+
link.href = url;
|
|
333
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
334
|
+
document.body.append(link);
|
|
335
|
+
link.click();
|
|
336
|
+
link.remove();
|
|
337
|
+
} catch (cause) {
|
|
338
|
+
exporter = null;
|
|
339
|
+
retries++;
|
|
340
|
+
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
341
|
+
error.hidden = false;
|
|
342
|
+
} finally {
|
|
343
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
344
|
+
busy = false;
|
|
345
|
+
button.removeAttribute('aria-disabled');
|
|
346
|
+
button.removeAttribute('aria-busy');
|
|
347
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
tools.append(button);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
354
|
+
if (document.querySelector('.poster')) addPosterDownloads();
|
|
355
|
+
else {
|
|
356
|
+
const observer = new MutationObserver(() => {
|
|
357
|
+
if (!document.querySelector('.poster')) return;
|
|
358
|
+
observer.disconnect();
|
|
359
|
+
addPosterDownloads();
|
|
360
|
+
});
|
|
361
|
+
observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
|
|
362
|
+
}
|
|
363
|
+
</script>
|
|
165
364
|
</body>
|
|
166
365
|
</html>
|