// Dependency-free SVG charts (§6.2): a line chart for time series and a // horizontal bar chart for grouped totals. ~150 lines, no library. const NS = 'http://www.w3.org/2000/svg'; function el(name, attrs, parent) { const node = document.createElementNS(NS, name); Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, v)); if (parent) parent.appendChild(node); return node; } const fmt = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v * 100) / 100)); // lineChart(host, points) — points: [{label: string, value: number}] export function lineChart(host, points, opts = {}) { host.replaceChildren(); const W = opts.width || host.clientWidth || 560; const H = opts.height || 220; const pad = { l: 48, r: 12, t: 12, b: 28 }; const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img', 'aria-label': opts.label || 'line chart' }, host); if (!points.length) { const t = el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg); t.textContent = 'No data in this period'; return; } const max = Math.max(...points.map((p) => p.value), 1); const x = (i) => pad.l + (i * (W - pad.l - pad.r)) / Math.max(points.length - 1, 1); const y = (v) => H - pad.b - (v / max) * (H - pad.t - pad.b); // gridlines + y labels for (let g = 0; g <= 4; g++) { const v = (max * g) / 4; el('line', { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v), stroke: 'var(--border)', 'stroke-width': 1 }, svg); const t = el('text', { x: pad.l - 6, y: y(v) + 4, 'text-anchor': 'end', 'font-size': 11, fill: 'var(--muted)' }, svg); t.textContent = fmt(v); } // x labels (sparse) const step = Math.ceil(points.length / 8); points.forEach((p, i) => { if (i % step !== 0 && i !== points.length - 1) return; const t = el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', 'font-size': 11, fill: 'var(--muted)' }, svg); t.textContent = p.label; }); const d = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' '); // area fill el('path', { d: `${d} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`, fill: 'var(--accent)', opacity: 0.15, }, svg); el('path', { d, fill: 'none', stroke: 'var(--accent)', 'stroke-width': 2 }, svg); points.forEach((p, i) => { const c = el('circle', { cx: x(i), cy: y(p.value), r: 3, fill: 'var(--accent)' }, svg); const title = el('title', {}, c); title.textContent = `${p.label}: ${p.value}`; }); } // barChart(host, rows) — rows: [{label, value}], horizontal bars export function barChart(host, rows, opts = {}) { host.replaceChildren(); const W = opts.width || host.clientWidth || 560; const rowH = 26; const pad = { l: 140, r: 48, t: 6, b: 6 }; const H = pad.t + pad.b + Math.max(rows.length, 1) * rowH; const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img', 'aria-label': opts.label || 'bar chart' }, host); if (!rows.length) { const t = el('text', { x: W / 2, y: H / 2 + 4, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg); t.textContent = 'No data in this period'; return; } const max = Math.max(...rows.map((r) => r.value), 1); rows.forEach((r, i) => { const yPos = pad.t + i * rowH; const label = el('text', { x: pad.l - 8, y: yPos + rowH / 2 + 4, 'text-anchor': 'end', 'font-size': 12, fill: 'var(--text)' }, svg); label.textContent = r.label.length > 18 ? r.label.slice(0, 17) + '…' : r.label; const wBar = ((W - pad.l - pad.r) * r.value) / max; const rect = el('rect', { x: pad.l, y: yPos + 4, width: Math.max(wBar, 2), height: rowH - 8, fill: 'var(--accent)', rx: 2 }, svg); const title = el('title', {}, rect); title.textContent = `${r.label}: ${r.value}`; const val = el('text', { x: pad.l + Math.max(wBar, 2) + 6, y: yPos + rowH / 2 + 4, 'font-size': 12, fill: 'var(--muted)' }, svg); val.textContent = fmt(r.value); }); }