|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- "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<object>();
- 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<HTMLDivElement>(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<void> };
- 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 (
- <Typography color="text.secondary" sx={{ py: 3 }}>
- {EMPTY_MESSAGE}
- </Typography>
- );
- }
-
- return (
- <Box
- ref={containerRef}
- className={dom.className}
- id={dom.id}
- style={dom.style}
- sx={{
- width: "100%",
- minHeight: minH,
- position: "relative",
- ...dom.sx,
- }}
- />
- );
- }
|