// 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 (
);
}
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 (
{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 (
);
}
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;