// Custom SVG charts // Smooth path generator function smoothPath(points) { if (points.length < 2) return ''; let d = `M ${points[0].x} ${points[0].y}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[Math.max(0, i - 1)]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[Math.min(points.length - 1, i + 2)]; const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6; d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`; } return d; } // --- BarChart: monthly savings function SavingsChart({ data = [], height = 240 }) { const padL = 56, padR = 16, padT = 24, padB = 36; const ref = useRef(null); const [width, setW] = useState(640); useLayoutEffect(() => { if (!ref.current) return; const ro = new ResizeObserver(entries => setW(Math.max(320, entries[0].contentRect.width))); ro.observe(ref.current); return () => ro.disconnect(); }, []); const max = Math.max(...data.map(d => d.value)) * 1.15; const innerW = width - padL - padR; const innerH = height - padT - padB; const bw = innerW / data.length; return (
{[0, 0.25, 0.5, 0.75, 1].map((t, i) => { const y = padT + innerH * (1 - t); const v = max * t; return ( {v >= 1000 ? (v/1000).toFixed(1) + 'k' : v.toFixed(0)} ); })} {data.map((d, i) => { const h = innerH * (d.value / max); const x = padL + i * bw + bw * 0.18; const y = padT + innerH - h; const w = bw * 0.64; return ( {d.m} {(d.value/1000).toFixed(1)}k ); })}
); } // --- Donut chart: category share function DonutChart({ data = [], size = 220, thick = 26, onArcClick, centerLabel = 'Total 30d' }) { const total = data.reduce((s, d) => s + d.value, 0); const r = (size - thick) / 2; const cx = size / 2, cy = size / 2; let acc = 0; const arcs = data.map((d, i) => { const start = acc; const end = acc + d.value / total; acc = end; const a1 = start * 2 * Math.PI - Math.PI / 2; const a2 = end * 2 * Math.PI - Math.PI / 2; const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1); const x2 = cx + r * Math.cos(a2), y2 = cy + r * Math.sin(a2); const large = end - start > 0.5 ? 1 : 0; return { d: `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`, tone: d.tone, label: d.label, value: d.value, raw: d }; }); const colorMap = { emerald: 'oklch(0.78 0.16 160)', coral: 'oklch(0.74 0.18 25)', sky: 'oklch(0.78 0.13 230)', amber: 'oklch(0.84 0.14 78)', violet: 'oklch(0.74 0.14 285)', }; const clickable = !!onArcClick; return (
{arcs.map((a, i) => ( onArcClick(a.raw) : undefined} > {`${a.label} ยท ${window.SCP.BRLk(a.value)}`} ))}
{centerLabel} {window.SCP.BRLk(total)}
); } // --- Line chart: price evolution function LineChart({ data = [], height = 180, color = 'oklch(0.78 0.16 160)' }) { const padL = 44, padR = 16, padT = 16, padB = 28; const ref = useRef(null); const [width, setW] = useState(560); useLayoutEffect(() => { if (!ref.current) return; const ro = new ResizeObserver(entries => setW(Math.max(280, entries[0].contentRect.width))); ro.observe(ref.current); return () => ro.disconnect(); }, []); const min = Math.min(...data.map(d => d.v)); const max = Math.max(...data.map(d => d.v)); const span = max - min || 1; const innerW = width - padL - padR; const innerH = height - padT - padB; const pts = data.map((d, i) => ({ x: padL + (i / (data.length - 1)) * innerW, y: padT + (1 - (d.v - min) / span) * innerH, v: d.v, d: d.d })); const pathD = smoothPath(pts); const areaD = pathD + ` L ${pts[pts.length - 1].x} ${padT + innerH} L ${pts[0].x} ${padT + innerH} Z`; return (
{[0, 1, 2, 3].map(t => { const y = padT + (t / 3) * innerH; const v = max - (span * t / 3); return ( {v.toFixed(2)} ); })} {pts.map((p, i) => ( {p.d} ))}
); } // Mini sparkline function Sparkline({ values = [], width = 80, height = 28, color = 'oklch(0.78 0.16 160)' }) { const min = Math.min(...values); const max = Math.max(...values); const span = max - min || 1; const pts = values.map((v, i) => ({ x: (i / (values.length - 1)) * width, y: height - ((v - min) / span) * height * 0.8 - height * 0.1, })); return ( ); } // inject chart css (function() { if (document.getElementById('scp-charts-css')) return; const s = document.createElement('style'); s.id = 'scp-charts-css'; s.textContent = ` .scp-donut { position: relative; display: inline-block; } .scp-donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; } .scp-donut-center .lbl { font-size: 10px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .1em; font-weight: 600; } .scp-donut-center .val { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin-top: 2px; font-family: var(--font-sans); font-feature-settings: "tnum"; } .scp-donut-arc { cursor: pointer; transition: opacity .15s, stroke-width .15s; transform-origin: center; transform-box: fill-box; } .scp-donut:hover .scp-donut-arc { opacity: 0.55; } .scp-donut .scp-donut-arc:hover { opacity: 1; stroke-width: 30; } `; document.head.appendChild(s); })(); Object.assign(window, { SavingsChart, DonutChart, LineChart, Sparkline, smoothPath });