FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

296 lines
9.2 KiB

  1. "use client";
  2. import { useEffect, useRef, type CSSProperties } from "react";
  3. import Box from "@mui/material/Box";
  4. import Typography from "@mui/material/Typography";
  5. import type { ApexOptions } from "apexcharts";
  6. import type { Props as ApexChartProps } from "react-apexcharts";
  7. export type SafeApexChartsProps = ApexChartProps & {
  8. /** Bumps internal remount when set — JSON.stringify(options) drops `chart.events`, so use this when handlers/data must stay in sync. */
  9. chartRevision?: string | number;
  10. /** When true, bar/area charts with all-zero values still render (e.g. connectivity history with no incidents). */
  11. allowZeroSeries?: boolean;
  12. };
  13. /**
  14. * Donut/pie series is number[] (possibly string numbers from JSON).
  15. * Do not use `typeof series[0] === "number"` only — first slice can be 0 or a string.
  16. */
  17. function tryCoerceOneDimensionalNumericSeries(raw: unknown[]): number[] | null {
  18. if (raw.length === 0) return null;
  19. const out: number[] = [];
  20. for (const x of raw) {
  21. if (typeof x === "number" && Number.isFinite(x)) {
  22. out.push(x);
  23. continue;
  24. }
  25. if (typeof x === "string" && x.trim() !== "") {
  26. const n = Number(x);
  27. if (Number.isFinite(n)) {
  28. out.push(n);
  29. continue;
  30. }
  31. }
  32. return null;
  33. }
  34. return out;
  35. }
  36. function sanitizeSeries(series: ApexChartProps["series"]): ApexChartProps["series"] {
  37. if (series == null) return [];
  38. if (Array.isArray(series)) {
  39. const asNums = tryCoerceOneDimensionalNumericSeries(series as unknown[]);
  40. if (asNums != null) {
  41. return asNums.map((v) => (Number.isFinite(v) ? v : 0));
  42. }
  43. return (series as { name?: string; data?: unknown[] }[]).map((s) => ({
  44. name: s?.name ?? "",
  45. data: Array.isArray(s?.data)
  46. ? s.data.map((v) => {
  47. const n = Number(v);
  48. return Number.isFinite(n) ? n : 0;
  49. })
  50. : [],
  51. }));
  52. }
  53. return [];
  54. }
  55. function isAxisChart(type: ApexChartProps["type"]): boolean {
  56. return type === "line" || type === "area" || type === "bar";
  57. }
  58. function isRadialChart(type: ApexChartProps["type"]): boolean {
  59. return type === "donut" || type === "pie" || type === "radialBar";
  60. }
  61. function isNumberArray(sanitized: ApexChartProps["series"]): sanitized is number[] {
  62. return Array.isArray(sanitized) && sanitized.length > 0 && sanitized.every((v) => typeof v === "number");
  63. }
  64. /** Stacked/category charts: every series row must have data.length === categories.length or Apex can throw. */
  65. function alignSeriesToCategoryCount(
  66. series: ApexChartProps["series"],
  67. categoryCount: number,
  68. ): ApexChartProps["series"] {
  69. if (!Array.isArray(series) || categoryCount <= 0 || isNumberArray(series)) return series;
  70. return (series as { name?: string; data?: unknown[] }[]).map((row) => {
  71. const raw = Array.isArray(row.data) ? row.data : [];
  72. const data = raw.map((v) => {
  73. const n = Number(v);
  74. return Number.isFinite(n) ? n : 0;
  75. });
  76. const next = data.slice(0, categoryCount);
  77. while (next.length < categoryCount) next.push(0);
  78. return { ...row, data: next };
  79. });
  80. }
  81. /** Sum numeric series (donut) or all y values in multi-series bar/line. */
  82. function seriesNumericTotal(sanitized: ApexChartProps["series"]): number {
  83. if (!Array.isArray(sanitized) || sanitized.length === 0) return 0;
  84. if (isNumberArray(sanitized)) {
  85. return sanitized.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0);
  86. }
  87. return (sanitized as { data?: number[] }[]).reduce((sum, row) => {
  88. const d = row.data ?? [];
  89. return sum + d.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0);
  90. }, 0);
  91. }
  92. /** ApexCharts throws (e.g. .toString of undefined) on empty categories, empty multi-series, donut sum 0, etc. */
  93. function shouldShowPlaceholder(
  94. type: ApexChartProps["type"],
  95. options: ApexChartProps["options"],
  96. sanitized: ApexChartProps["series"],
  97. allowZeroSeries: boolean,
  98. ): boolean {
  99. if (!Array.isArray(sanitized) || sanitized.length === 0) return true;
  100. if (isRadialChart(type)) {
  101. if (!isNumberArray(sanitized)) return true;
  102. const sum = sanitized.reduce((a, v) => a + (Number.isFinite(v) && v > 0 ? v : 0), 0);
  103. if (sum <= 0) return true;
  104. return false;
  105. }
  106. if (!isAxisChart(type)) return false;
  107. const cats = options?.xaxis?.categories;
  108. if (!Array.isArray(cats) || cats.length === 0) return true;
  109. if (isNumberArray(sanitized)) return false;
  110. const rows = sanitized as { data?: number[] }[];
  111. const anyPoint = rows.some((r) => (r.data?.length ?? 0) > 0);
  112. if (!anyPoint) return true;
  113. if (!allowZeroSeries && type === "bar" && seriesNumericTotal(sanitized) <= 0) return true;
  114. return false;
  115. }
  116. function buildApexConfig(
  117. base: ApexOptions | undefined,
  118. chartType: string,
  119. h: ApexChartProps["height"],
  120. w: ApexChartProps["width"],
  121. s: ApexChartProps["series"],
  122. ): ApexOptions {
  123. const o = base ?? {};
  124. const prev = o.chart && typeof o.chart === "object" ? o.chart : {};
  125. return {
  126. ...o,
  127. chart: {
  128. ...prev,
  129. type: chartType,
  130. height: h ?? (prev as { height?: ApexChartProps["height"] }).height ?? "auto",
  131. width: w ?? (prev as { width?: string | number }).width ?? "100%",
  132. } as ApexOptions["chart"],
  133. series: s as ApexOptions["series"],
  134. };
  135. }
  136. const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。";
  137. /** Apex mutates `options` in place (circular refs). Never throw during render. */
  138. function safeStringify(value: unknown): string {
  139. const seen = new WeakSet<object>();
  140. try {
  141. return JSON.stringify(value, (_key, nested) => {
  142. if (typeof nested === "function") return undefined;
  143. if (typeof nested === "object" && nested !== null) {
  144. if (seen.has(nested)) return undefined;
  145. seen.add(nested);
  146. }
  147. return nested;
  148. });
  149. } catch {
  150. return "";
  151. }
  152. }
  153. function destroyChart(chartRef: { current: { destroy: () => void } | null }) {
  154. const current = chartRef.current;
  155. chartRef.current = null;
  156. if (!current) return;
  157. try {
  158. current.destroy();
  159. } catch {
  160. /* ignore */
  161. }
  162. }
  163. export default function SafeApexCharts(props: SafeApexChartsProps) {
  164. const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props;
  165. const containerRef = useRef<HTMLDivElement>(null);
  166. const chartRef = useRef<{ destroy: () => void } | null>(null);
  167. let sanitized = sanitizeSeries(series);
  168. const cats = options?.xaxis?.categories;
  169. if (isAxisChart(type) && Array.isArray(cats) && cats.length > 0) {
  170. sanitized = alignSeriesToCategoryCount(sanitized, cats.length);
  171. }
  172. let chartOptions = options;
  173. let renderSeries: ApexChartProps["series"] = sanitized;
  174. let showPlaceholder = shouldShowPlaceholder(type, options, sanitized, allowZeroSeries);
  175. if (!showPlaceholder && isRadialChart(type) && isNumberArray(sanitized)) {
  176. const prev = Array.isArray(options?.labels) ? (options!.labels as unknown[]) : [];
  177. const pairs = sanitized.map((v, i) => {
  178. const n = Number.isFinite(v) ? v : 0;
  179. const raw = prev[i];
  180. const label = raw != null && String(raw).trim() !== "" ? String(raw) : `項目 ${i + 1}`;
  181. return { v: Math.max(0, n), label };
  182. });
  183. const nonzero = pairs.filter((p) => p.v > 0);
  184. if (nonzero.length === 0) {
  185. showPlaceholder = true;
  186. } else {
  187. renderSeries = nonzero.map((p) => p.v);
  188. chartOptions = { ...options, labels: nonzero.map((p) => p.label) };
  189. }
  190. }
  191. const chartType = String(type ?? "line");
  192. const configSnapshot = [
  193. String(chartRevision ?? ""),
  194. chartType,
  195. String(showPlaceholder),
  196. safeStringify(renderSeries ?? null),
  197. safeStringify(cats ?? null),
  198. safeStringify(chartOptions?.labels ?? null),
  199. String(height ?? ""),
  200. String(width ?? ""),
  201. ].join("|");
  202. useEffect(() => {
  203. if (showPlaceholder) {
  204. destroyChart(chartRef);
  205. return;
  206. }
  207. const el = containerRef.current;
  208. if (!el) return;
  209. let disposed = false;
  210. void import("apexcharts").then((mod) => {
  211. if (disposed || containerRef.current !== el) return;
  212. const ApexChartsCtor = mod.default;
  213. const config = buildApexConfig(chartOptions as ApexOptions, chartType, height, width, renderSeries);
  214. let instance: { destroy: () => void; render: () => Promise<void> };
  215. try {
  216. instance = new ApexChartsCtor(el, config);
  217. } catch {
  218. return;
  219. }
  220. chartRef.current = instance;
  221. if (disposed || containerRef.current !== el) {
  222. if (chartRef.current === instance) {
  223. destroyChart(chartRef);
  224. }
  225. return;
  226. }
  227. void instance.render().catch(() => {
  228. /* ignore */
  229. });
  230. });
  231. return () => {
  232. disposed = true;
  233. destroyChart(chartRef);
  234. };
  235. }, [configSnapshot]);
  236. const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240;
  237. const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object };
  238. if (showPlaceholder) {
  239. return (
  240. <Typography color="text.secondary" sx={{ py: 3 }}>
  241. {EMPTY_MESSAGE}
  242. </Typography>
  243. );
  244. }
  245. return (
  246. <Box
  247. ref={containerRef}
  248. className={dom.className}
  249. id={dom.id}
  250. style={dom.style}
  251. sx={{
  252. width: "100%",
  253. minHeight: minH,
  254. position: "relative",
  255. ...dom.sx,
  256. }}
  257. />
  258. );
  259. }