@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
@@ -350,5 +350,204 @@ function Skyline() {
350
350
 
351
351
  render(() => html`<${Skyline} />`, document.getElementById("chart"));
352
352
  </script>
353
+ <style id="poster-download-styles">
354
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
355
+ .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; }
356
+ .poster-download svg { display: block; width: 20px; height: 20px; }
357
+ .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
358
+ .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
359
+ .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
360
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
361
+ @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
362
+ </style>
363
+ <script type="module" id="poster-download-script">
364
+ function addPosterDownloads() {
365
+ document.querySelectorAll('.poster').forEach((poster, index) => {
366
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
367
+ const tools = navigation || document.createElement('div');
368
+ if (tools.querySelector('.poster-download')) return;
369
+ if (!navigation) {
370
+ tools.className = 'poster-download-tools';
371
+ poster.before(tools);
372
+ const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
373
+ new ResizeObserver(align).observe(poster);
374
+ align();
375
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
376
+ if (rgb) {
377
+ const light = rgb.slice(0, 3).map(Number).map(v => {
378
+ v /= 255;
379
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
380
+ });
381
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
382
+ }
383
+ }
384
+ const button = document.createElement('button');
385
+ button.type = 'button';
386
+ button.className = 'poster-download';
387
+ button.setAttribute('aria-label', 'Download poster as PNG');
388
+ button.title = 'Download poster as PNG';
389
+ 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>';
390
+ const error = document.createElement('p');
391
+ error.className = 'poster-download-error';
392
+ error.setAttribute('role', 'alert');
393
+ error.hidden = true;
394
+ tools.after(error);
395
+ // Download is part of the poster interaction, even though its control sits outside the image.
396
+ button.addEventListener('pointerdown', event => {
397
+ event.stopPropagation();
398
+ });
399
+ button.addEventListener('mousedown', event => {
400
+ event.stopPropagation();
401
+ });
402
+ poster.addEventListener('focusout', event => {
403
+ if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
404
+ }, true);
405
+ poster.addEventListener('pointerleave', event => {
406
+ if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
407
+ }, true);
408
+ for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
409
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
410
+ });
411
+ let exporter, retries = 0, busy = false;
412
+ button.addEventListener('click', async event => {
413
+ event.stopPropagation();
414
+ if (busy) return;
415
+ busy = true;
416
+ button.setAttribute('aria-disabled', 'true');
417
+ button.setAttribute('aria-busy', 'true');
418
+ button.setAttribute('aria-label', 'Preparing poster PNG');
419
+ error.hidden = true;
420
+ let url;
421
+ try {
422
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
423
+ const { domToBlob } = await exporter;
424
+ await document.fonts.ready;
425
+ // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
426
+ const autoMargins = new Set();
427
+ const marginRules = rules => [...rules].map(rule => {
428
+ if (rule.selectorText && rule.style) {
429
+ const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
430
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
431
+ return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
432
+ }
433
+ if (!rule.cssRules) return '';
434
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
435
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
436
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
437
+ }).join('\n');
438
+ const margins = [...document.styleSheets].map(sheet => {
439
+ try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
440
+ }).join('\n');
441
+ const restoreMargins = (source, clone) => {
442
+ if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
443
+ [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
444
+ [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
445
+ }
446
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
447
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
448
+ };
449
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
450
+ const fontFiles = new Map();
451
+ const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
452
+ let css;
453
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
454
+ catch (cause) {
455
+ if (!sheet.href) return '';
456
+ const response = await fetch(sheet.href);
457
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
458
+ css = await response.text();
459
+ }
460
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
461
+ return (await Promise.all(rules.map(async rule => {
462
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
463
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
464
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
465
+ const response = await fetch(source);
466
+ if (!response.ok) throw new Error('Font could not be loaded');
467
+ const blob = await response.blob();
468
+ return new Promise((resolve, reject) => {
469
+ const reader = new FileReader();
470
+ reader.onload = () => resolve(reader.result);
471
+ reader.onerror = reject;
472
+ reader.readAsDataURL(blob);
473
+ });
474
+ })());
475
+ rule = rule.replace(match[1], await fontFiles.get(source));
476
+ }
477
+ return rule;
478
+ }))).join('\n');
479
+ }));
480
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
481
+ // This keeps SVG filter fragment references local to their own image.
482
+ const textures = new Map();
483
+ const embedTexture = async (node) => {
484
+ if (!node.style) return;
485
+ let background = node.style.backgroundImage;
486
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
487
+ if (!textures.has(source)) textures.set(source, (async () => {
488
+ const image = new Image();
489
+ image.src = source;
490
+ await image.decode();
491
+ const canvas = document.createElement('canvas');
492
+ canvas.width = image.naturalWidth;
493
+ canvas.height = image.naturalHeight;
494
+ canvas.getContext('2d').drawImage(image, 0, 0);
495
+ return canvas.toDataURL();
496
+ })());
497
+ background = background.replace(source, await textures.get(source));
498
+ }
499
+ node.style.backgroundImage = background;
500
+ };
501
+ const blob = await domToBlob(poster, {
502
+ scale: 2, style: { margin: '0' },
503
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
504
+ onCloneNode: clone => {
505
+ restoreMargins(poster, clone);
506
+ const style = document.createElement('style');
507
+ style.textContent = margins;
508
+ clone.prepend(style);
509
+ },
510
+ // CSS content's alternative text follows a slash and must not be painted.
511
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
512
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
513
+ }),
514
+ });
515
+ if (!blob) throw new Error('No PNG was created');
516
+ url = URL.createObjectURL(blob);
517
+ const link = document.createElement('a');
518
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
519
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
520
+ link.href = url;
521
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
522
+ document.body.append(link);
523
+ link.click();
524
+ link.remove();
525
+ } catch (cause) {
526
+ exporter = null;
527
+ retries++;
528
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
529
+ error.hidden = false;
530
+ } finally {
531
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
532
+ busy = false;
533
+ button.removeAttribute('aria-disabled');
534
+ button.removeAttribute('aria-busy');
535
+ button.setAttribute('aria-label', 'Download poster as PNG');
536
+ }
537
+ });
538
+ tools.append(button);
539
+ });
540
+ }
541
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
542
+ if (document.querySelector('.poster')) addPosterDownloads();
543
+ else {
544
+ const observer = new MutationObserver(() => {
545
+ if (!document.querySelector('.poster')) return;
546
+ observer.disconnect();
547
+ addPosterDownloads();
548
+ });
549
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
550
+ }
551
+ </script>
353
552
  </body>
354
553
  </html>
@@ -208,5 +208,204 @@ function GroceryChart() {
208
208
 
209
209
  render(() => html`<${GroceryChart} />`, document.getElementById("chart"));
210
210
  </script>
211
+ <style id="poster-download-styles">
212
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
213
+ .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; }
214
+ .poster-download svg { display: block; width: 20px; height: 20px; }
215
+ .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
216
+ .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
217
+ .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
218
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
219
+ @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
220
+ </style>
221
+ <script type="module" id="poster-download-script">
222
+ function addPosterDownloads() {
223
+ document.querySelectorAll('.poster').forEach((poster, index) => {
224
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
225
+ const tools = navigation || document.createElement('div');
226
+ if (tools.querySelector('.poster-download')) return;
227
+ if (!navigation) {
228
+ tools.className = 'poster-download-tools';
229
+ poster.before(tools);
230
+ const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
231
+ new ResizeObserver(align).observe(poster);
232
+ align();
233
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
234
+ if (rgb) {
235
+ const light = rgb.slice(0, 3).map(Number).map(v => {
236
+ v /= 255;
237
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
238
+ });
239
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
240
+ }
241
+ }
242
+ const button = document.createElement('button');
243
+ button.type = 'button';
244
+ button.className = 'poster-download';
245
+ button.setAttribute('aria-label', 'Download poster as PNG');
246
+ button.title = 'Download poster as PNG';
247
+ 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>';
248
+ const error = document.createElement('p');
249
+ error.className = 'poster-download-error';
250
+ error.setAttribute('role', 'alert');
251
+ error.hidden = true;
252
+ tools.after(error);
253
+ // Download is part of the poster interaction, even though its control sits outside the image.
254
+ button.addEventListener('pointerdown', event => {
255
+ event.stopPropagation();
256
+ });
257
+ button.addEventListener('mousedown', event => {
258
+ event.stopPropagation();
259
+ });
260
+ poster.addEventListener('focusout', event => {
261
+ if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
262
+ }, true);
263
+ poster.addEventListener('pointerleave', event => {
264
+ if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
265
+ }, true);
266
+ for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
267
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
268
+ });
269
+ let exporter, retries = 0, busy = false;
270
+ button.addEventListener('click', async event => {
271
+ event.stopPropagation();
272
+ if (busy) return;
273
+ busy = true;
274
+ button.setAttribute('aria-disabled', 'true');
275
+ button.setAttribute('aria-busy', 'true');
276
+ button.setAttribute('aria-label', 'Preparing poster PNG');
277
+ error.hidden = true;
278
+ let url;
279
+ try {
280
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
281
+ const { domToBlob } = await exporter;
282
+ await document.fonts.ready;
283
+ // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
284
+ const autoMargins = new Set();
285
+ const marginRules = rules => [...rules].map(rule => {
286
+ if (rule.selectorText && rule.style) {
287
+ const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
288
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
289
+ return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
290
+ }
291
+ if (!rule.cssRules) return '';
292
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
293
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
294
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
295
+ }).join('\n');
296
+ const margins = [...document.styleSheets].map(sheet => {
297
+ try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
298
+ }).join('\n');
299
+ const restoreMargins = (source, clone) => {
300
+ if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
301
+ [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
302
+ [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
303
+ }
304
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
305
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
306
+ };
307
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
308
+ const fontFiles = new Map();
309
+ const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
310
+ let css;
311
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
312
+ catch (cause) {
313
+ if (!sheet.href) return '';
314
+ const response = await fetch(sheet.href);
315
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
316
+ css = await response.text();
317
+ }
318
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
319
+ return (await Promise.all(rules.map(async rule => {
320
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
321
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
322
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
323
+ const response = await fetch(source);
324
+ if (!response.ok) throw new Error('Font could not be loaded');
325
+ const blob = await response.blob();
326
+ return new Promise((resolve, reject) => {
327
+ const reader = new FileReader();
328
+ reader.onload = () => resolve(reader.result);
329
+ reader.onerror = reject;
330
+ reader.readAsDataURL(blob);
331
+ });
332
+ })());
333
+ rule = rule.replace(match[1], await fontFiles.get(source));
334
+ }
335
+ return rule;
336
+ }))).join('\n');
337
+ }));
338
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
339
+ // This keeps SVG filter fragment references local to their own image.
340
+ const textures = new Map();
341
+ const embedTexture = async (node) => {
342
+ if (!node.style) return;
343
+ let background = node.style.backgroundImage;
344
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
345
+ if (!textures.has(source)) textures.set(source, (async () => {
346
+ const image = new Image();
347
+ image.src = source;
348
+ await image.decode();
349
+ const canvas = document.createElement('canvas');
350
+ canvas.width = image.naturalWidth;
351
+ canvas.height = image.naturalHeight;
352
+ canvas.getContext('2d').drawImage(image, 0, 0);
353
+ return canvas.toDataURL();
354
+ })());
355
+ background = background.replace(source, await textures.get(source));
356
+ }
357
+ node.style.backgroundImage = background;
358
+ };
359
+ const blob = await domToBlob(poster, {
360
+ scale: 2, style: { margin: '0' },
361
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
362
+ onCloneNode: clone => {
363
+ restoreMargins(poster, clone);
364
+ const style = document.createElement('style');
365
+ style.textContent = margins;
366
+ clone.prepend(style);
367
+ },
368
+ // CSS content's alternative text follows a slash and must not be painted.
369
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
370
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
371
+ }),
372
+ });
373
+ if (!blob) throw new Error('No PNG was created');
374
+ url = URL.createObjectURL(blob);
375
+ const link = document.createElement('a');
376
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
377
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
378
+ link.href = url;
379
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
380
+ document.body.append(link);
381
+ link.click();
382
+ link.remove();
383
+ } catch (cause) {
384
+ exporter = null;
385
+ retries++;
386
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
387
+ error.hidden = false;
388
+ } finally {
389
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
390
+ busy = false;
391
+ button.removeAttribute('aria-disabled');
392
+ button.removeAttribute('aria-busy');
393
+ button.setAttribute('aria-label', 'Download poster as PNG');
394
+ }
395
+ });
396
+ tools.append(button);
397
+ });
398
+ }
399
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
400
+ if (document.querySelector('.poster')) addPosterDownloads();
401
+ else {
402
+ const observer = new MutationObserver(() => {
403
+ if (!document.querySelector('.poster')) return;
404
+ observer.disconnect();
405
+ addPosterDownloads();
406
+ });
407
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
408
+ }
409
+ </script>
211
410
  </body>
212
411
  </html>
@@ -171,5 +171,204 @@ function RicherSince1990() {
171
171
 
172
172
  render(() => html`<${RicherSince1990} />`, document.getElementById("chart"));
173
173
  </script>
174
+ <style id="poster-download-styles">
175
+ .poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
176
+ .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; }
177
+ .poster-download svg { display: block; width: 20px; height: 20px; }
178
+ .poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
179
+ .poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
180
+ .poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
181
+ .poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
182
+ @media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
183
+ </style>
184
+ <script type="module" id="poster-download-script">
185
+ function addPosterDownloads() {
186
+ document.querySelectorAll('.poster').forEach((poster, index) => {
187
+ const navigation = index === 0 && document.querySelector('.recipe-navigation');
188
+ const tools = navigation || document.createElement('div');
189
+ if (tools.querySelector('.poster-download')) return;
190
+ if (!navigation) {
191
+ tools.className = 'poster-download-tools';
192
+ poster.before(tools);
193
+ const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
194
+ new ResizeObserver(align).observe(poster);
195
+ align();
196
+ const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
197
+ if (rgb) {
198
+ const light = rgb.slice(0, 3).map(Number).map(v => {
199
+ v /= 255;
200
+ return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
201
+ });
202
+ tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
203
+ }
204
+ }
205
+ const button = document.createElement('button');
206
+ button.type = 'button';
207
+ button.className = 'poster-download';
208
+ button.setAttribute('aria-label', 'Download poster as PNG');
209
+ button.title = 'Download poster as PNG';
210
+ button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
211
+ const error = document.createElement('p');
212
+ error.className = 'poster-download-error';
213
+ error.setAttribute('role', 'alert');
214
+ error.hidden = true;
215
+ tools.after(error);
216
+ // Download is part of the poster interaction, even though its control sits outside the image.
217
+ button.addEventListener('pointerdown', event => {
218
+ event.stopPropagation();
219
+ });
220
+ button.addEventListener('mousedown', event => {
221
+ event.stopPropagation();
222
+ });
223
+ poster.addEventListener('focusout', event => {
224
+ if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
225
+ }, true);
226
+ poster.addEventListener('pointerleave', event => {
227
+ if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
228
+ }, true);
229
+ for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
230
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
231
+ });
232
+ let exporter, retries = 0, busy = false;
233
+ button.addEventListener('click', async event => {
234
+ event.stopPropagation();
235
+ if (busy) return;
236
+ busy = true;
237
+ button.setAttribute('aria-disabled', 'true');
238
+ button.setAttribute('aria-busy', 'true');
239
+ button.setAttribute('aria-label', 'Preparing poster PNG');
240
+ error.hidden = true;
241
+ let url;
242
+ try {
243
+ exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
244
+ const { domToBlob } = await exporter;
245
+ await document.fonts.ready;
246
+ // Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
247
+ const autoMargins = new Set();
248
+ const marginRules = rules => [...rules].map(rule => {
249
+ if (rule.selectorText && rule.style) {
250
+ const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
251
+ if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
252
+ return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
253
+ }
254
+ if (!rule.cssRules) return '';
255
+ if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
256
+ if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
257
+ return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
258
+ }).join('\n');
259
+ const margins = [...document.styleSheets].map(sheet => {
260
+ try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
261
+ }).join('\n');
262
+ const restoreMargins = (source, clone) => {
263
+ if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
264
+ [...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
265
+ [...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
266
+ }
267
+ const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
268
+ [...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
269
+ };
270
+ // Cross-origin font stylesheets are readable through fetch, but not through cssRules.
271
+ const fontFiles = new Map();
272
+ const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
273
+ let css;
274
+ try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
275
+ catch (cause) {
276
+ if (!sheet.href) return '';
277
+ const response = await fetch(sheet.href);
278
+ if (!response.ok) throw new Error('Font stylesheet could not be loaded');
279
+ css = await response.text();
280
+ }
281
+ const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
282
+ return (await Promise.all(rules.map(async rule => {
283
+ for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
284
+ const source = new URL(match[1], sheet.href || document.baseURI).href;
285
+ if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
286
+ const response = await fetch(source);
287
+ if (!response.ok) throw new Error('Font could not be loaded');
288
+ const blob = await response.blob();
289
+ return new Promise((resolve, reject) => {
290
+ const reader = new FileReader();
291
+ reader.onload = () => resolve(reader.result);
292
+ reader.onerror = reject;
293
+ reader.readAsDataURL(blob);
294
+ });
295
+ })());
296
+ rule = rule.replace(match[1], await fontFiles.get(source));
297
+ }
298
+ return rule;
299
+ }))).join('\n');
300
+ }));
301
+ // Rasterize SVG paper textures before nesting them inside the exporter's SVG.
302
+ // This keeps SVG filter fragment references local to their own image.
303
+ const textures = new Map();
304
+ const embedTexture = async (node) => {
305
+ if (!node.style) return;
306
+ let background = node.style.backgroundImage;
307
+ for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
308
+ if (!textures.has(source)) textures.set(source, (async () => {
309
+ const image = new Image();
310
+ image.src = source;
311
+ await image.decode();
312
+ const canvas = document.createElement('canvas');
313
+ canvas.width = image.naturalWidth;
314
+ canvas.height = image.naturalHeight;
315
+ canvas.getContext('2d').drawImage(image, 0, 0);
316
+ return canvas.toDataURL();
317
+ })());
318
+ background = background.replace(source, await textures.get(source));
319
+ }
320
+ node.style.backgroundImage = background;
321
+ };
322
+ const blob = await domToBlob(poster, {
323
+ scale: 2, style: { margin: '0' },
324
+ font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
325
+ onCloneNode: clone => {
326
+ restoreMargins(poster, clone);
327
+ const style = document.createElement('style');
328
+ style.textContent = margins;
329
+ clone.prepend(style);
330
+ },
331
+ // CSS content's alternative text follows a slash and must not be painted.
332
+ onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
333
+ style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
334
+ }),
335
+ });
336
+ if (!blob) throw new Error('No PNG was created');
337
+ url = URL.createObjectURL(blob);
338
+ const link = document.createElement('a');
339
+ const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
340
+ link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
341
+ link.href = url;
342
+ link.addEventListener('click', event => event.stopPropagation(), { once: true });
343
+ document.body.append(link);
344
+ link.click();
345
+ link.remove();
346
+ } catch (cause) {
347
+ exporter = null;
348
+ retries++;
349
+ error.textContent = 'The PNG could not be saved. Check your connection and try again.';
350
+ error.hidden = false;
351
+ } finally {
352
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
353
+ busy = false;
354
+ button.removeAttribute('aria-disabled');
355
+ button.removeAttribute('aria-busy');
356
+ button.setAttribute('aria-label', 'Download poster as PNG');
357
+ }
358
+ });
359
+ tools.append(button);
360
+ });
361
+ }
362
+ // A chart module may await fonts or data after DOMContentLoaded has already fired.
363
+ if (document.querySelector('.poster')) addPosterDownloads();
364
+ else {
365
+ const observer = new MutationObserver(() => {
366
+ if (!document.querySelector('.poster')) return;
367
+ observer.disconnect();
368
+ addPosterDownloads();
369
+ });
370
+ observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
371
+ }
372
+ </script>
174
373
  </body>
175
374
  </html>