"use client"; import { useEffect, useRef, type CSSProperties } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import type { ApexOptions } from "apexcharts"; import type { Props as ApexChartProps } from "react-apexcharts"; export type SafeApexChartsProps = ApexChartProps & { /** Bumps internal remount when set — JSON.stringify(options) drops `chart.events`, so use this when handlers/data must stay in sync. */ chartRevision?: string | number; /** When true, bar/area charts with all-zero values still render (e.g. connectivity history with no incidents). */ allowZeroSeries?: boolean; }; /** * Donut/pie series is number[] (possibly string numbers from JSON). * Do not use `typeof series[0] === "number"` only — first slice can be 0 or a string. */ function tryCoerceOneDimensionalNumericSeries(raw: unknown[]): number[] | null { if (raw.length === 0) return null; const out: number[] = []; for (const x of raw) { if (typeof x === "number" && Number.isFinite(x)) { out.push(x); continue; } if (typeof x === "string" && x.trim() !== "") { const n = Number(x); if (Number.isFinite(n)) { out.push(n); continue; } } return null; } return out; } function sanitizeSeries(series: ApexChartProps["series"]): ApexChartProps["series"] { if (series == null) return []; if (Array.isArray(series)) { const asNums = tryCoerceOneDimensionalNumericSeries(series as unknown[]); if (asNums != null) { return asNums.map((v) => (Number.isFinite(v) ? v : 0)); } return (series as { name?: string; data?: unknown[] }[]).map((s) => ({ name: s?.name ?? "", data: Array.isArray(s?.data) ? s.data.map((v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }) : [], })); } return []; } function isAxisChart(type: ApexChartProps["type"]): boolean { return type === "line" || type === "area" || type === "bar"; } function isRadialChart(type: ApexChartProps["type"]): boolean { return type === "donut" || type === "pie" || type === "radialBar"; } function isNumberArray(sanitized: ApexChartProps["series"]): sanitized is number[] { return Array.isArray(sanitized) && sanitized.length > 0 && sanitized.every((v) => typeof v === "number"); } /** Stacked/category charts: every series row must have data.length === categories.length or Apex can throw. */ function alignSeriesToCategoryCount( series: ApexChartProps["series"], categoryCount: number, ): ApexChartProps["series"] { if (!Array.isArray(series) || categoryCount <= 0 || isNumberArray(series)) return series; return (series as { name?: string; data?: unknown[] }[]).map((row) => { const raw = Array.isArray(row.data) ? row.data : []; const data = raw.map((v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }); const next = data.slice(0, categoryCount); while (next.length < categoryCount) next.push(0); return { ...row, data: next }; }); } /** Sum numeric series (donut) or all y values in multi-series bar/line. */ function seriesNumericTotal(sanitized: ApexChartProps["series"]): number { if (!Array.isArray(sanitized) || sanitized.length === 0) return 0; if (isNumberArray(sanitized)) { return sanitized.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0); } return (sanitized as { data?: number[] }[]).reduce((sum, row) => { const d = row.data ?? []; return sum + d.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0); }, 0); } /** ApexCharts throws (e.g. .toString of undefined) on empty categories, empty multi-series, donut sum 0, etc. */ function shouldShowPlaceholder( type: ApexChartProps["type"], options: ApexChartProps["options"], sanitized: ApexChartProps["series"], allowZeroSeries: boolean, ): boolean { if (!Array.isArray(sanitized) || sanitized.length === 0) return true; if (isRadialChart(type)) { if (!isNumberArray(sanitized)) return true; const sum = sanitized.reduce((a, v) => a + (Number.isFinite(v) && v > 0 ? v : 0), 0); if (sum <= 0) return true; return false; } if (!isAxisChart(type)) return false; const cats = options?.xaxis?.categories; if (!Array.isArray(cats) || cats.length === 0) return true; if (isNumberArray(sanitized)) return false; const rows = sanitized as { data?: number[] }[]; const anyPoint = rows.some((r) => (r.data?.length ?? 0) > 0); if (!anyPoint) return true; if (!allowZeroSeries && type === "bar" && seriesNumericTotal(sanitized) <= 0) return true; return false; } function buildApexConfig( base: ApexOptions | undefined, chartType: string, h: ApexChartProps["height"], w: ApexChartProps["width"], s: ApexChartProps["series"], ): ApexOptions { const o = base ?? {}; const prev = o.chart && typeof o.chart === "object" ? o.chart : {}; return { ...o, chart: { ...prev, type: chartType, height: h ?? (prev as { height?: ApexChartProps["height"] }).height ?? "auto", width: w ?? (prev as { width?: string | number }).width ?? "100%", } as ApexOptions["chart"], series: s as ApexOptions["series"], }; } const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; /** Apex mutates `options` in place (circular refs). Never throw during render. */ function safeStringify(value: unknown): string { const seen = new WeakSet(); try { return JSON.stringify(value, (_key, nested) => { if (typeof nested === "function") return undefined; if (typeof nested === "object" && nested !== null) { if (seen.has(nested)) return undefined; seen.add(nested); } return nested; }); } catch { return ""; } } function destroyChart(chartRef: { current: { destroy: () => void } | null }) { const current = chartRef.current; chartRef.current = null; if (!current) return; try { current.destroy(); } catch { /* ignore */ } } export default function SafeApexCharts(props: SafeApexChartsProps) { const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; const containerRef = useRef(null); const chartRef = useRef<{ destroy: () => void } | null>(null); let sanitized = sanitizeSeries(series); const cats = options?.xaxis?.categories; if (isAxisChart(type) && Array.isArray(cats) && cats.length > 0) { sanitized = alignSeriesToCategoryCount(sanitized, cats.length); } let chartOptions = options; let renderSeries: ApexChartProps["series"] = sanitized; let showPlaceholder = shouldShowPlaceholder(type, options, sanitized, allowZeroSeries); if (!showPlaceholder && isRadialChart(type) && isNumberArray(sanitized)) { const prev = Array.isArray(options?.labels) ? (options!.labels as unknown[]) : []; const pairs = sanitized.map((v, i) => { const n = Number.isFinite(v) ? v : 0; const raw = prev[i]; const label = raw != null && String(raw).trim() !== "" ? String(raw) : `項目 ${i + 1}`; return { v: Math.max(0, n), label }; }); const nonzero = pairs.filter((p) => p.v > 0); if (nonzero.length === 0) { showPlaceholder = true; } else { renderSeries = nonzero.map((p) => p.v); chartOptions = { ...options, labels: nonzero.map((p) => p.label) }; } } const chartType = String(type ?? "line"); const configSnapshot = [ String(chartRevision ?? ""), chartType, String(showPlaceholder), safeStringify(renderSeries ?? null), safeStringify(cats ?? null), safeStringify(chartOptions?.labels ?? null), String(height ?? ""), String(width ?? ""), ].join("|"); useEffect(() => { if (showPlaceholder) { destroyChart(chartRef); return; } const el = containerRef.current; if (!el) return; let disposed = false; void import("apexcharts").then((mod) => { if (disposed || containerRef.current !== el) return; const ApexChartsCtor = mod.default; const config = buildApexConfig(chartOptions as ApexOptions, chartType, height, width, renderSeries); let instance: { destroy: () => void; render: () => Promise }; try { instance = new ApexChartsCtor(el, config); } catch { return; } chartRef.current = instance; if (disposed || containerRef.current !== el) { if (chartRef.current === instance) { destroyChart(chartRef); } return; } void instance.render().catch(() => { /* ignore */ }); }); return () => { disposed = true; destroyChart(chartRef); }; }, [configSnapshot]); const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240; const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object }; if (showPlaceholder) { return ( {EMPTY_MESSAGE} ); } return ( ); }