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.
 
 

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