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%
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