@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/skill/SKILL.md +5 -0
  3. package/skill/recipes/area.html +199 -0
  4. package/skill/recipes/bar.html +199 -0
  5. package/skill/recipes/box-plot.html +199 -0
  6. package/skill/recipes/bubble.html +199 -0
  7. package/skill/recipes/bullet.html +199 -0
  8. package/skill/recipes/candlestick.html +199 -0
  9. package/skill/recipes/column.html +199 -0
  10. package/skill/recipes/dial.html +199 -0
  11. package/skill/recipes/diverging-bars.html +199 -0
  12. package/skill/recipes/donut.html +199 -0
  13. package/skill/recipes/dumbbell.html +199 -0
  14. package/skill/recipes/gantt.html +199 -0
  15. package/skill/recipes/grouped-bars.html +199 -0
  16. package/skill/recipes/heatmap.html +199 -0
  17. package/skill/recipes/histogram.html +199 -0
  18. package/skill/recipes/line.html +199 -0
  19. package/skill/recipes/live.html +199 -0
  20. package/skill/recipes/log-line.html +199 -0
  21. package/skill/recipes/lollipop.html +199 -0
  22. package/skill/recipes/multi-line.html +199 -0
  23. package/skill/recipes/pyramid.html +199 -0
  24. package/skill/recipes/race.html +199 -0
  25. package/skill/recipes/radial-bars.html +199 -0
  26. package/skill/recipes/scatter.html +199 -0
  27. package/skill/recipes/silhouettes.html +199 -0
  28. package/skill/recipes/slope.html +199 -0
  29. package/skill/recipes/sparklines.html +199 -0
  30. package/skill/recipes/stacked-100.html +199 -0
  31. package/skill/recipes/stacked-bars.html +199 -0
  32. package/skill/recipes/strip.html +199 -0
  33. package/skill/recipes/unit-stack.html +199 -0
  34. package/skill/recipes/violin.html +199 -0
  35. package/skill/recipes/waffle.html +199 -0
  36. package/skill/recipes/waterfall.html +199 -0
  37. package/skill/references/download.md +218 -0
  38. package/skill/references/environments.md +1 -0
  39. package/src/server.js +1 -1
@@ -184,5 +184,204 @@ function CitiesChart() {
184
184
 
185
185
  render(() => html`<${CitiesChart} />`, document.getElementById("chart"));
186
186
  </script>
187
+ <style id="poster-download-styles">
188
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
189
+ .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; }
190
+ .poster-download svg { display: block; width: 20px; height: 20px; }
191
+ .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
192
+ .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
193
+ .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
194
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
195
+ @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
196
+ </style>
197
+ <script type="module" id="poster-download-script">
198
+ function addPosterDownloads() {
199
+ document.querySelectorAll('.poster').forEach((poster, index) => {
200
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
201
+ const tools = navigation || document.createElement('div');
202
+ if (tools.querySelector('.poster-download')) return;
203
+ if (!navigation) {
204
+ tools.className = 'poster-download-tools';
205
+ poster.before(tools);
206
+ const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
207
+ new ResizeObserver(align).observe(poster);
208
+ align();
209
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
210
+ if (rgb) {
211
+ const light = rgb.slice(0, 3).map(Number).map(v => {
212
+ v /= 255;
213
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
214
+ });
215
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
216
+ }
217
+ }
218
+ const button = document.createElement('button');
219
+ button.type = 'button';
220
+ button.className = 'poster-download';
221
+ button.setAttribute('aria-label', 'Download poster as PNG');
222
+ button.title = 'Download poster as PNG';
223
+ 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>';
224
+ const error = document.createElement('p');
225
+ error.className = 'poster-download-error';
226
+ error.setAttribute('role', 'alert');
227
+ error.hidden = true;
228
+ tools.after(error);
229
+ // Download is part of the poster interaction, even though its control sits outside the image.
230
+ button.addEventListener('pointerdown', event => {
231
+ event.stopPropagation();
232
+ });
233
+ button.addEventListener('mousedown', event => {
234
+ event.stopPropagation();
235
+ });
236
+ poster.addEventListener('focusout', event => {
237
+ if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
238
+ }, true);
239
+ poster.addEventListener('pointerleave', event => {
240
+ if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
241
+ }, true);
242
+ for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
243
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
244
+ });
245
+ let exporter, retries = 0, busy = false;
246
+ button.addEventListener('click', async event => {
247
+ event.stopPropagation();
248
+ if (busy) return;
249
+ busy = true;
250
+ button.setAttribute('aria-disabled', 'true');
251
+ button.setAttribute('aria-busy', 'true');
252
+ button.setAttribute('aria-label', 'Preparing poster PNG');
253
+ error.hidden = true;
254
+ let url;
255
+ try {
256
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
257
+ const { domToBlob } = await exporter;
258
+ await document.fonts.ready;
259
+ // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
260
+ const autoMargins = new Set();
261
+ const marginRules = rules => [...rules].map(rule => {
262
+ if (rule.selectorText && rule.style) {
263
+ const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
264
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
265
+ return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
266
+ }
267
+ if (!rule.cssRules) return '';
268
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
269
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
270
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
271
+ }).join('\n');
272
+ const margins = [...document.styleSheets].map(sheet => {
273
+ try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
274
+ }).join('\n');
275
+ const restoreMargins = (source, clone) => {
276
+ if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
277
+ [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
278
+ [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
279
+ }
280
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
281
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
282
+ };
283
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
284
+ const fontFiles = new Map();
285
+ const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
286
+ let css;
287
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
288
+ catch (cause) {
289
+ if (!sheet.href) return '';
290
+ const response = await fetch(sheet.href);
291
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
292
+ css = await response.text();
293
+ }
294
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
295
+ return (await Promise.all(rules.map(async rule => {
296
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
297
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
298
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
299
+ const response = await fetch(source);
300
+ if (!response.ok) throw new Error('Font could not be loaded');
301
+ const blob = await response.blob();
302
+ return new Promise((resolve, reject) => {
303
+ const reader = new FileReader();
304
+ reader.onload = () => resolve(reader.result);
305
+ reader.onerror = reject;
306
+ reader.readAsDataURL(blob);
307
+ });
308
+ })());
309
+ rule = rule.replace(match[1], await fontFiles.get(source));
310
+ }
311
+ return rule;
312
+ }))).join('\n');
313
+ }));
314
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
315
+ // This keeps SVG filter fragment references local to their own image.
316
+ const textures = new Map();
317
+ const embedTexture = async (node) => {
318
+ if (!node.style) return;
319
+ let background = node.style.backgroundImage;
320
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
321
+ if (!textures.has(source)) textures.set(source, (async () => {
322
+ const image = new Image();
323
+ image.src = source;
324
+ await image.decode();
325
+ const canvas = document.createElement('canvas');
326
+ canvas.width = image.naturalWidth;
327
+ canvas.height = image.naturalHeight;
328
+ canvas.getContext('2d').drawImage(image, 0, 0);
329
+ return canvas.toDataURL();
330
+ })());
331
+ background = background.replace(source, await textures.get(source));
332
+ }
333
+ node.style.backgroundImage = background;
334
+ };
335
+ const blob = await domToBlob(poster, {
336
+ scale: 2, style: { margin: '0' },
337
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
338
+ onCloneNode: clone => {
339
+ restoreMargins(poster, clone);
340
+ const style = document.createElement('style');
341
+ style.textContent = margins;
342
+ clone.prepend(style);
343
+ },
344
+ // CSS content's alternative text follows a slash and must not be painted.
345
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
346
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
347
+ }),
348
+ });
349
+ if (!blob) throw new Error('No PNG was created');
350
+ url = URL.createObjectURL(blob);
351
+ const link = document.createElement('a');
352
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
353
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
354
+ link.href = url;
355
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
356
+ document.body.append(link);
357
+ link.click();
358
+ link.remove();
359
+ } catch (cause) {
360
+ exporter = null;
361
+ retries++;
362
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
363
+ error.hidden = false;
364
+ } finally {
365
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
366
+ busy = false;
367
+ button.removeAttribute('aria-disabled');
368
+ button.removeAttribute('aria-busy');
369
+ button.setAttribute('aria-label', 'Download poster as PNG');
370
+ }
371
+ });
372
+ tools.append(button);
373
+ });
374
+ }
375
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
376
+ if (document.querySelector('.poster')) addPosterDownloads();
377
+ else {
378
+ const observer = new MutationObserver(() => {
379
+ if (!document.querySelector('.poster')) return;
380
+ observer.disconnect();
381
+ addPosterDownloads();
382
+ });
383
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
384
+ }
385
+ </script>
187
386
  </body>
188
387
  </html>
@@ -134,5 +134,204 @@ function SalesByRegion() {
134
134
 
135
135
  render(() => html`<${SalesByRegion} />`, document.getElementById("chart"));
136
136
  </script>
137
+ <style id="poster-download-styles">
138
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
139
+ .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; }
140
+ .poster-download svg { display: block; width: 20px; height: 20px; }
141
+ .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
142
+ .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
143
+ .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
144
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
145
+ @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
146
+ </style>
147
+ <script type="module" id="poster-download-script">
148
+ function addPosterDownloads() {
149
+ document.querySelectorAll('.poster').forEach((poster, index) => {
150
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
151
+ const tools = navigation || document.createElement('div');
152
+ if (tools.querySelector('.poster-download')) return;
153
+ if (!navigation) {
154
+ tools.className = 'poster-download-tools';
155
+ poster.before(tools);
156
+ const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
157
+ new ResizeObserver(align).observe(poster);
158
+ align();
159
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
160
+ if (rgb) {
161
+ const light = rgb.slice(0, 3).map(Number).map(v => {
162
+ v /= 255;
163
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
164
+ });
165
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
166
+ }
167
+ }
168
+ const button = document.createElement('button');
169
+ button.type = 'button';
170
+ button.className = 'poster-download';
171
+ button.setAttribute('aria-label', 'Download poster as PNG');
172
+ button.title = 'Download poster as PNG';
173
+ 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>';
174
+ const error = document.createElement('p');
175
+ error.className = 'poster-download-error';
176
+ error.setAttribute('role', 'alert');
177
+ error.hidden = true;
178
+ tools.after(error);
179
+ // Download is part of the poster interaction, even though its control sits outside the image.
180
+ button.addEventListener('pointerdown', event => {
181
+ event.stopPropagation();
182
+ });
183
+ button.addEventListener('mousedown', event => {
184
+ event.stopPropagation();
185
+ });
186
+ poster.addEventListener('focusout', event => {
187
+ if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
188
+ }, true);
189
+ poster.addEventListener('pointerleave', event => {
190
+ if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
191
+ }, true);
192
+ for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
193
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
194
+ });
195
+ let exporter, retries = 0, busy = false;
196
+ button.addEventListener('click', async event => {
197
+ event.stopPropagation();
198
+ if (busy) return;
199
+ busy = true;
200
+ button.setAttribute('aria-disabled', 'true');
201
+ button.setAttribute('aria-busy', 'true');
202
+ button.setAttribute('aria-label', 'Preparing poster PNG');
203
+ error.hidden = true;
204
+ let url;
205
+ try {
206
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
207
+ const { domToBlob } = await exporter;
208
+ await document.fonts.ready;
209
+ // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
210
+ const autoMargins = new Set();
211
+ const marginRules = rules => [...rules].map(rule => {
212
+ if (rule.selectorText && rule.style) {
213
+ const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
214
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
215
+ return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
216
+ }
217
+ if (!rule.cssRules) return '';
218
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
219
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
220
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
221
+ }).join('\n');
222
+ const margins = [...document.styleSheets].map(sheet => {
223
+ try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
224
+ }).join('\n');
225
+ const restoreMargins = (source, clone) => {
226
+ if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
227
+ [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
228
+ [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
229
+ }
230
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
231
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
232
+ };
233
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
234
+ const fontFiles = new Map();
235
+ const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
236
+ let css;
237
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
238
+ catch (cause) {
239
+ if (!sheet.href) return '';
240
+ const response = await fetch(sheet.href);
241
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
242
+ css = await response.text();
243
+ }
244
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
245
+ return (await Promise.all(rules.map(async rule => {
246
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
247
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
248
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
249
+ const response = await fetch(source);
250
+ if (!response.ok) throw new Error('Font could not be loaded');
251
+ const blob = await response.blob();
252
+ return new Promise((resolve, reject) => {
253
+ const reader = new FileReader();
254
+ reader.onload = () => resolve(reader.result);
255
+ reader.onerror = reject;
256
+ reader.readAsDataURL(blob);
257
+ });
258
+ })());
259
+ rule = rule.replace(match[1], await fontFiles.get(source));
260
+ }
261
+ return rule;
262
+ }))).join('\n');
263
+ }));
264
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
265
+ // This keeps SVG filter fragment references local to their own image.
266
+ const textures = new Map();
267
+ const embedTexture = async (node) => {
268
+ if (!node.style) return;
269
+ let background = node.style.backgroundImage;
270
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
271
+ if (!textures.has(source)) textures.set(source, (async () => {
272
+ const image = new Image();
273
+ image.src = source;
274
+ await image.decode();
275
+ const canvas = document.createElement('canvas');
276
+ canvas.width = image.naturalWidth;
277
+ canvas.height = image.naturalHeight;
278
+ canvas.getContext('2d').drawImage(image, 0, 0);
279
+ return canvas.toDataURL();
280
+ })());
281
+ background = background.replace(source, await textures.get(source));
282
+ }
283
+ node.style.backgroundImage = background;
284
+ };
285
+ const blob = await domToBlob(poster, {
286
+ scale: 2, style: { margin: '0' },
287
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
288
+ onCloneNode: clone => {
289
+ restoreMargins(poster, clone);
290
+ const style = document.createElement('style');
291
+ style.textContent = margins;
292
+ clone.prepend(style);
293
+ },
294
+ // CSS content's alternative text follows a slash and must not be painted.
295
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
296
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
297
+ }),
298
+ });
299
+ if (!blob) throw new Error('No PNG was created');
300
+ url = URL.createObjectURL(blob);
301
+ const link = document.createElement('a');
302
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
303
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
304
+ link.href = url;
305
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
306
+ document.body.append(link);
307
+ link.click();
308
+ link.remove();
309
+ } catch (cause) {
310
+ exporter = null;
311
+ retries++;
312
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
313
+ error.hidden = false;
314
+ } finally {
315
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
316
+ busy = false;
317
+ button.removeAttribute('aria-disabled');
318
+ button.removeAttribute('aria-busy');
319
+ button.setAttribute('aria-label', 'Download poster as PNG');
320
+ }
321
+ });
322
+ tools.append(button);
323
+ });
324
+ }
325
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
326
+ if (document.querySelector('.poster')) addPosterDownloads();
327
+ else {
328
+ const observer = new MutationObserver(() => {
329
+ if (!document.querySelector('.poster')) return;
330
+ observer.disconnect();
331
+ addPosterDownloads();
332
+ });
333
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
334
+ }
335
+ </script>
137
336
  </body>
138
337
  </html>
@@ -177,5 +177,204 @@ function SharePrice() {
177
177
 
178
178
  render(() => html`<${SharePrice} />`, document.getElementById("chart"));
179
179
  </script>
180
+ <style id="poster-download-styles">
181
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
182
+ .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; }
183
+ .poster-download svg { display: block; width: 20px; height: 20px; }
184
+ .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
185
+ .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
186
+ .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
187
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
188
+ @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
189
+ </style>
190
+ <script type="module" id="poster-download-script">
191
+ function addPosterDownloads() {
192
+ document.querySelectorAll('.poster').forEach((poster, index) => {
193
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
194
+ const tools = navigation || document.createElement('div');
195
+ if (tools.querySelector('.poster-download')) return;
196
+ if (!navigation) {
197
+ tools.className = 'poster-download-tools';
198
+ poster.before(tools);
199
+ const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
200
+ new ResizeObserver(align).observe(poster);
201
+ align();
202
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
203
+ if (rgb) {
204
+ const light = rgb.slice(0, 3).map(Number).map(v => {
205
+ v /= 255;
206
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
207
+ });
208
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
209
+ }
210
+ }
211
+ const button = document.createElement('button');
212
+ button.type = 'button';
213
+ button.className = 'poster-download';
214
+ button.setAttribute('aria-label', 'Download poster as PNG');
215
+ button.title = 'Download poster as PNG';
216
+ 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>';
217
+ const error = document.createElement('p');
218
+ error.className = 'poster-download-error';
219
+ error.setAttribute('role', 'alert');
220
+ error.hidden = true;
221
+ tools.after(error);
222
+ // Download is part of the poster interaction, even though its control sits outside the image.
223
+ button.addEventListener('pointerdown', event => {
224
+ event.stopPropagation();
225
+ });
226
+ button.addEventListener('mousedown', event => {
227
+ event.stopPropagation();
228
+ });
229
+ poster.addEventListener('focusout', event => {
230
+ if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
231
+ }, true);
232
+ poster.addEventListener('pointerleave', event => {
233
+ if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
234
+ }, true);
235
+ for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
236
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
237
+ });
238
+ let exporter, retries = 0, busy = false;
239
+ button.addEventListener('click', async event => {
240
+ event.stopPropagation();
241
+ if (busy) return;
242
+ busy = true;
243
+ button.setAttribute('aria-disabled', 'true');
244
+ button.setAttribute('aria-busy', 'true');
245
+ button.setAttribute('aria-label', 'Preparing poster PNG');
246
+ error.hidden = true;
247
+ let url;
248
+ try {
249
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
250
+ const { domToBlob } = await exporter;
251
+ await document.fonts.ready;
252
+ // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
253
+ const autoMargins = new Set();
254
+ const marginRules = rules => [...rules].map(rule => {
255
+ if (rule.selectorText && rule.style) {
256
+ const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
257
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
258
+ return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
259
+ }
260
+ if (!rule.cssRules) return '';
261
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
262
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
263
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
264
+ }).join('\n');
265
+ const margins = [...document.styleSheets].map(sheet => {
266
+ try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
267
+ }).join('\n');
268
+ const restoreMargins = (source, clone) => {
269
+ if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
270
+ [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
271
+ [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
272
+ }
273
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
274
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
275
+ };
276
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
277
+ const fontFiles = new Map();
278
+ const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
279
+ let css;
280
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
281
+ catch (cause) {
282
+ if (!sheet.href) return '';
283
+ const response = await fetch(sheet.href);
284
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
285
+ css = await response.text();
286
+ }
287
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
288
+ return (await Promise.all(rules.map(async rule => {
289
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
290
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
291
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
292
+ const response = await fetch(source);
293
+ if (!response.ok) throw new Error('Font could not be loaded');
294
+ const blob = await response.blob();
295
+ return new Promise((resolve, reject) => {
296
+ const reader = new FileReader();
297
+ reader.onload = () => resolve(reader.result);
298
+ reader.onerror = reject;
299
+ reader.readAsDataURL(blob);
300
+ });
301
+ })());
302
+ rule = rule.replace(match[1], await fontFiles.get(source));
303
+ }
304
+ return rule;
305
+ }))).join('\n');
306
+ }));
307
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
308
+ // This keeps SVG filter fragment references local to their own image.
309
+ const textures = new Map();
310
+ const embedTexture = async (node) => {
311
+ if (!node.style) return;
312
+ let background = node.style.backgroundImage;
313
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
314
+ if (!textures.has(source)) textures.set(source, (async () => {
315
+ const image = new Image();
316
+ image.src = source;
317
+ await image.decode();
318
+ const canvas = document.createElement('canvas');
319
+ canvas.width = image.naturalWidth;
320
+ canvas.height = image.naturalHeight;
321
+ canvas.getContext('2d').drawImage(image, 0, 0);
322
+ return canvas.toDataURL();
323
+ })());
324
+ background = background.replace(source, await textures.get(source));
325
+ }
326
+ node.style.backgroundImage = background;
327
+ };
328
+ const blob = await domToBlob(poster, {
329
+ scale: 2, style: { margin: '0' },
330
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
331
+ onCloneNode: clone => {
332
+ restoreMargins(poster, clone);
333
+ const style = document.createElement('style');
334
+ style.textContent = margins;
335
+ clone.prepend(style);
336
+ },
337
+ // CSS content's alternative text follows a slash and must not be painted.
338
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
339
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
340
+ }),
341
+ });
342
+ if (!blob) throw new Error('No PNG was created');
343
+ url = URL.createObjectURL(blob);
344
+ const link = document.createElement('a');
345
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
346
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
347
+ link.href = url;
348
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
349
+ document.body.append(link);
350
+ link.click();
351
+ link.remove();
352
+ } catch (cause) {
353
+ exporter = null;
354
+ retries++;
355
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
356
+ error.hidden = false;
357
+ } finally {
358
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
359
+ busy = false;
360
+ button.removeAttribute('aria-disabled');
361
+ button.removeAttribute('aria-busy');
362
+ button.setAttribute('aria-label', 'Download poster as PNG');
363
+ }
364
+ });
365
+ tools.append(button);
366
+ });
367
+ }
368
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
369
+ if (document.querySelector('.poster')) addPosterDownloads();
370
+ else {
371
+ const observer = new MutationObserver(() => {
372
+ if (!document.querySelector('.poster')) return;
373
+ observer.disconnect();
374
+ addPosterDownloads();
375
+ });
376
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
377
+ }
378
+ </script>
180
379
  </body>
181
380
  </html>