SPB Git

spb/qwhpi Public

QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.

Python 63.9% TypeScript 25.4% CSS 5.5% TeX 3.5% SQL 0.8% Makefile 0.5% Dockerfile 0.5%
2.6 KB · 78 lines typescript
Raw Blame History
1/**2 * =============================================================================3 * QWHPI — Quebec Weekly Housing Price Index4 * Author  : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * File    : web/lib/exportPng.ts7 * Purpose : Chart → PNG export (SVG serialization to canvas, no dependency).8 * =============================================================================9 */1011/** Resolve CSS custom properties so the serialized SVG is standalone. */12function inlineColors(svg: SVGSVGElement): SVGSVGElement {13  const clone = svg.cloneNode(true) as SVGSVGElement;14  const styles = getComputedStyle(document.documentElement);15  const resolve = (v: string) =>16    v.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name) =>17      styles.getPropertyValue(name).trim() || "#888");18  const walk = (el: Element) => {19    for (const attr of ["fill", "stroke", "color", "style"]) {20      const v = el.getAttribute(attr);21      if (v && v.includes("var(")) el.setAttribute(attr, resolve(v));22    }23    for (const child of Array.from(el.children)) walk(child);24  };25  walk(clone);26  return clone;27}2829export async function exportChartPng(30  container: HTMLElement,31  filename: string,32  credit = "QHPI — indexqc.house · Simon-Pierre Boucher",33): Promise<void> {34  const svg = container.querySelector<SVGSVGElement>("svg.recharts-surface, svg");35  if (!svg) return;36  const rect = svg.getBoundingClientRect();37  const scale = 2;38  const w = Math.round(rect.width);39  const h = Math.round(rect.height);4041  const clone = inlineColors(svg);42  clone.setAttribute("width", String(w));43  clone.setAttribute("height", String(h));44  const xml = new XMLSerializer().serializeToString(clone);45  const url = URL.createObjectURL(46    new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));4748  const img = new Image();49  await new Promise((res, rej) => {50    img.onload = res;51    img.onerror = rej;52    img.src = url;53  });5455  const surface = getComputedStyle(document.documentElement)56    .getPropertyValue("--surface-1").trim() || "#fff";57  const ink2 = getComputedStyle(document.documentElement)58    .getPropertyValue("--text-secondary").trim() || "#555";5960  const canvas = document.createElement("canvas");61  canvas.width = w * scale;62  canvas.height = (h + 26) * scale;63  const ctx = canvas.getContext("2d")!;64  ctx.scale(scale, scale);65  ctx.fillStyle = surface;66  ctx.fillRect(0, 0, w, h + 26);67  ctx.drawImage(img, 0, 0, w, h);68  ctx.fillStyle = ink2;69  ctx.font = "10px system-ui, sans-serif";70  ctx.fillText(credit, 8, h + 16);71  URL.revokeObjectURL(url);7273  const a = document.createElement("a");74  a.download = filename;75  a.href = canvas.toDataURL("image/png");76  a.click();77}78