// Reusable chart + table primitives — light theme const fmtUSD = (n, dp = 2) => '$' + (n || 0).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp }); const fmtNum = (n) => (n || 0).toLocaleString('en-US'); const fmtCompact = (n) => { if (n == null) return '0'; if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'; if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; return Math.round(n).toString(); }; const fmtPct = (n, dp = 1) => (n * 100).toFixed(dp) + '%'; const fmtDelta = (n) => (n >= 0 ? '+' : '') + (n * 100).toFixed(1) + '%'; window.fmtUSD = fmtUSD; window.fmtNum = fmtNum; window.fmtCompact = fmtCompact; window.fmtPct = fmtPct; window.fmtDelta = fmtDelta; // ResponsiveBox:用 ResizeObserver 偵測 container 寬度,把實際 px 傳進 children render-prop。 // 圖表元件本來都 hardcode width={920/760/...},包進來後會自動 fit container(cards 寬度變動也會 reflow)。 // 用法: // {(w, h) => } function ResponsiveBox({ height, minWidth = 200, children }) { const ref = React.useRef(null); const [w, setW] = React.useState(0); React.useLayoutEffect(() => { if (!ref.current) return; const measure = () => { const cw = ref.current ? ref.current.clientWidth : 0; if (cw > 0) setW(Math.floor(cw)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(ref.current); window.addEventListener('resize', measure); return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; }, []); return (
{w > 0 ? children(w, height) : null}
); } window.ResponsiveBox = ResponsiveBox; function Sparkline({ data, w = 80, h = 22, color = '#5C6470', fill = false, strokeWidth = 1.25 }) { if (!data || data.length === 0) return null; const max = Math.max(...data); const min = Math.min(...data); const range = max - min || 1; const stepX = w / (data.length - 1); const points = data.map((v, i) => [i * stepX, h - 2 - ((v - min) / range) * (h - 4)]); const path = points.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' '); const fillPath = path + ` L ${w},${h} L 0,${h} Z`; return ( {fill && } ); } function StackedArea({ series, dates, colors, w = 800, h = 240, totalLabel = '總計' }) { const n = dates.length; const totals = dates.map((_, i) => series.reduce((s, ser) => s + ser.values[i], 0)); const max = Math.max(...totals) * 1.05; const padL = 50, padR = 12, padT = 14, padB = 24; const innerW = w - padL - padR; const innerH = h - padT - padB; const stepX = innerW / (n - 1); const stack = []; let lower = new Array(n).fill(0); series.forEach((ser, si) => { const upper = lower.map((l, i) => l + ser.values[i]); const points = []; for (let i = 0; i < n; i++) { points.push([padL + i * stepX, padT + innerH - (upper[i] / max) * innerH]); } const lowerPts = []; for (let i = n - 1; i >= 0; i--) { lowerPts.push([padL + i * stepX, padT + innerH - (lower[i] / max) * innerH]); } const path = points.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' ') + ' ' + lowerPts.map(p => 'L' + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' ') + ' Z'; const linePath = points.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' '); stack.push({ ser, path, linePath, color: colors[si % colors.length] }); lower = upper; }); const yTicks = 4; const tickVals = Array.from({ length: yTicks + 1 }, (_, i) => max * (i / yTicks)); const xTickIdx = []; for (let i = 0; i < n; i += 5) xTickIdx.push(i); if (xTickIdx[xTickIdx.length - 1] !== n - 1) xTickIdx.push(n - 1); const [hover, setHover] = React.useState(null); return (
{ const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const i = Math.round((x - padL) / stepX); if (i >= 0 && i < n) setHover({ i, x: padL + i * stepX }); }} onMouseLeave={() => setHover(null)} > {tickVals.map((v, i) => { const y = padT + innerH - (v / max) * innerH; return ( ${fmtCompact(v)} ); })} {xTickIdx.map((i) => ( {dates[i].slice(5)} ))} {stack.map((s, i) => ( ))} {hover && ( {stack.map((s, si) => { const v = s.ser.values[hover.i]; let yAcc = 0; for (let k = 0; k <= si; k++) yAcc += stack[k].ser.values[hover.i]; const y = padT + innerH - (yAcc / max) * innerH; return ; })} )} {hover && (
{dates[hover.i]}
{[...stack].reverse().map((s, i) => (
{s.ser.name} {fmtUSD(s.ser.values[hover.i])}
))}
{totalLabel} {fmtUSD(totals[hover.i])}
)}
); } function Donut({ slices, size = 140, thickness = 22, centerLabel, centerValue }) { const total = slices.reduce((s, x) => s + x.value, 0) || 1; const cx = size / 2, cy = size / 2; const r = (size - thickness) / 2; let acc = 0; return ( {slices.map((s, i) => { const start = acc / total * Math.PI * 2 - Math.PI / 2; acc += s.value; const end = acc / total * Math.PI * 2 - Math.PI / 2; const large = end - start > Math.PI ? 1 : 0; const x1 = cx + r * Math.cos(start), y1 = cy + r * Math.sin(start); const x2 = cx + r * Math.cos(end), y2 = cy + r * Math.sin(end); const path = `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`; return ; })} {centerLabel && ( {centerLabel} )} {centerValue && ( {centerValue} )} ); } function Pill({ children, color = 'gray' }) { const palette = { green: { bg: '#DCFCE7', fg: '#15803D', border: '#86EFAC' }, red: { bg: '#FEE2E2', fg: '#DC2626', border: '#FCA5A5' }, amber: { bg: '#FEF3C7', fg: '#B45309', border: '#FCD34D' }, blue: { bg: '#DBEAFE', fg: '#1D4ED8', border: '#93C5FD' }, purple: { bg: '#EDE9FE', fg: '#6D28D9', border: '#C4B5FD' }, yellow: { bg: '#FFFAB8', fg: '#7A6E00', border: '#FFE600' }, gray: { bg: '#F0F0EE', fg: '#5C6470', border: '#D4D4CF' }, }; const p = palette[color] || palette.gray; return ( {children} ); } function TierBadge({ tier }) { const map = { enterprise: { color: 'purple', label: '企業版' }, growth: { color: 'blue', label: '成長版' }, starter: { color: 'gray', label: '入門版' }, trial: { color: 'amber', label: '試用' }, }; const m = map[tier] || map.starter; return {m.label}; } function StatusDot({ status }) { const map = { active: { color: '#15803D', label: '使用中' }, anomalous: { color: '#DC2626', label: '異常' }, dormant: { color: '#9098A3', label: '沈睡' }, }; const m = map[status] || map.dormant; return ( {m.label} ); } window.Sparkline = Sparkline; window.StackedArea = StackedArea; window.Donut = Donut; window.Pill = Pill; window.TierBadge = TierBadge; window.StatusDot = StatusDot;