返回 presentation-ai
chart-utils.ts
1 /**
2 * Shared utility functions for chart components
3 * Provides consistent key detection and label formatting across all chart types
4 */
5
6 type AnyRecord = Record<string, unknown>;
7
8 export type RemovedSankeyCycleLink = {
9 index: number;
10 source: string;
11 target: string;
12 };
13
14 export type SankeyCycleSanitizationResult = {
15 data: unknown;
16 removedLinks: RemovedSankeyCycleLink[];
17 };
18
19 function flowEndpointToText(value: unknown, fallback: string): string {
20 if (typeof value === "string" && value.trim().length > 0) {
21 return value.trim();
22 }
23
24 if (typeof value === "number" && Number.isFinite(value)) {
25 return String(value);
26 }
27
28 return fallback;
29 }
30
31 function hasPathToSource(
32 adjacency: Map<string, Set<string>>,
33 current: string,
34 target: string,
35 ): boolean {
36 const visited = new Set<string>();
37 const queue = [current];
38
39 while (queue.length > 0) {
40 const node = queue.shift();
41 if (!node || visited.has(node)) continue;
42 if (node === target) return true;
43
44 visited.add(node);
45 const neighbors = adjacency.get(node);
46 if (!neighbors) continue;
47
48 for (const neighbor of neighbors) {
49 if (!visited.has(neighbor)) {
50 queue.push(neighbor);
51 }
52 }
53 }
54
55 return false;
56 }
57
58 /**
59 * Sankey charts must be directed acyclic graphs. Keep valid links in their
60 * original order and drop only links that would introduce a cycle.
61 */
62 export function sanitizeSankeyCycleData(
63 chartData: unknown,
64 ): SankeyCycleSanitizationResult {
65 if (!Array.isArray(chartData)) {
66 return { data: chartData, removedLinks: [] };
67 }
68
69 const adjacency = new Map<string, Set<string>>();
70 const validRows: unknown[] = [];
71 const removedLinks: RemovedSankeyCycleLink[] = [];
72
73 chartData.forEach((row, index) => {
74 if (typeof row !== "object" || row === null || Array.isArray(row)) {
75 validRows.push(row);
76 return;
77 }
78
79 const record = row as AnyRecord;
80 const source = flowEndpointToText(record.from ?? record.source, "Source");
81 const target = flowEndpointToText(record.to ?? record.target, "Target");
82 const createsCycle =
83 source === target || hasPathToSource(adjacency, target, source);
84
85 if (createsCycle) {
86 removedLinks.push({ index, source, target });
87 return;
88 }
89
90 const neighbors = adjacency.get(source) ?? new Set<string>();
91 neighbors.add(target);
92 adjacency.set(source, neighbors);
93 validRows.push(row);
94 });
95
96 return { data: validRows, removedLinks };
97 }
98
99 /**
100 * Find the label/name key in chart data
101 * Supports: "label", "name", or first string field
102 */
103 export function getLabelKey(data: unknown[]): string {
104 if (data.length === 0) return "label";
105 const sample = data[0] as AnyRecord;
106 if ("label" in sample) return "label";
107 if ("name" in sample) return "name";
108 // Fallback: find first string field that isn't a common value key
109 const stringKey = Object.keys(sample).find(
110 (key) =>
111 typeof sample[key] === "string" &&
112 !["value", "count", "x", "y", "z"].includes(key.toLowerCase()),
113 );
114 return stringKey ?? "label";
115 }
116
117 /**
118 * Find the first numeric value key in chart data (excludes label key)
119 * Used for single-series charts like Bar, Pie, Radial
120 */
121 export function getValueKey(data: unknown[]): string {
122 if (data.length === 0) return "value";
123 const sample = data[0] as AnyRecord;
124 const labelKey = getLabelKey(data);
125
126 // Find first numeric key that isn't the label
127 const numericKey = Object.keys(sample).find(
128 (key) => key !== labelKey && typeof sample[key] === "number",
129 );
130 return numericKey ?? "value";
131 }
132
133 /**
134 * Find all numeric value keys in chart data (excludes label key)
135 * Used for multi-series charts like Line, Area, Composed
136 */
137 export function getValueKeys(data: unknown[]): string[] {
138 if (data.length === 0) return ["value"];
139 const sample = data[0] as AnyRecord;
140 const labelKey = getLabelKey(data);
141
142 const keys = Object.keys(sample).filter(
143 (key) => key !== labelKey && typeof sample[key] === "number",
144 );
145 return keys.length > 0 ? keys : ["value"];
146 }
147
148 /**
149 * Find the X coordinate key for scatter/bubble charts
150 */
151 export function getXKey(data: unknown[]): string {
152 if (data.length === 0) return "x";
153 const sample = data[0] as AnyRecord;
154 if ("x" in sample) return "x";
155 if ("X" in sample) return "X";
156 return "x";
157 }
158
159 /**
160 * Find the Y coordinate key for scatter/bubble charts
161 */
162 export function getYKey(data: unknown[]): string {
163 if (data.length === 0) return "y";
164 const sample = data[0] as AnyRecord;
165 if ("y" in sample) return "y";
166 if ("Y" in sample) return "Y";
167 return "y";
168 }
169
170 /**
171 * Find the Z (size) key for bubble charts
172 */
173 export function getZKey(data: unknown[]): string {
174 if (data.length === 0) return "z";
175 const sample = data[0] as AnyRecord;
176 if ("z" in sample) return "z";
177 if ("Z" in sample) return "Z";
178 if ("size" in sample) return "size";
179 if ("radius" in sample) return "radius";
180 return "z";
181 }
182
183 /**
184 * Convert a data key to a display-friendly label
185 * e.g., "salesRevenue" -> "Sales Revenue", "value" -> "Value"
186 */
187 export function keyToLabel(key: string): string {
188 // Handle common abbreviations
189 if (key === "x" || key === "X") return "X";
190 if (key === "y" || key === "Y") return "Y";
191 if (key === "z" || key === "Z") return "Size";
192
193 // Convert camelCase/snake_case to Title Case with spaces
194 return key
195 .replace(/([A-Z])/g, " $1") // Add space before capitals
196 .replace(/[_-]/g, " ") // Replace underscores/dashes with spaces
197 .replace(/\s+/g, " ") // Normalize spaces
198 .trim()
199 .split(" ")
200 .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
201 .join(" ");
202 }
203
204 /**
205 * Chart configuration interface for new config options
206 */
207 interface ChartConfigElement {
208 title?: { text?: string; fontSize?: number; color?: string };
209 subtitle?: { text?: string; fontSize?: number; color?: string };
210 xAxis?: {
211 title?: string | { text?: string; enabled?: boolean };
212 label?: { enabled?: boolean };
213 gridLine?: { enabled?: boolean };
214 };
215 yAxis?: {
216 title?: string | { text?: string; enabled?: boolean };
217 label?: { enabled?: boolean };
218 gridLine?: { enabled?: boolean };
219 };
220 legend?: {
221 enabled?: boolean;
222 position?: "top" | "right" | "bottom" | "left";
223 };
224 animation?: { enabled?: boolean; duration?: number };
225 background?: { fill?: string; visible?: boolean };
226 showLegend?: boolean;
227 showGrid?: boolean;
228 showAxisLabels?: boolean;
229 disableAnimation?: boolean;
230 }
231
232 /**
233 * Build common chart configuration options from element properties
234 * This reduces code duplication across chart components
235 */
236 export function buildChartConfigOptions(element: ChartConfigElement) {
237 const titleConfig = element.title;
238 const subtitleConfig = element.subtitle;
239 const xAxisConfig = element.xAxis;
240 const yAxisConfig = element.yAxis;
241 const legendConfig = element.legend;
242 const animationConfig = element.animation;
243 const backgroundConfig = element.background;
244
245 // Legacy computed values for backward compatibility
246 const showLegend = element.showLegend ?? element.legend?.enabled ?? true;
247 const showGrid = element.showGrid ?? true;
248 const showAxisLabels = element.showAxisLabels ?? true;
249 const disableAnimation = element.disableAnimation ?? false;
250 const animationEnabled = animationConfig?.enabled ?? !disableAnimation;
251
252 // Per-axis settings with fallback to legacy settings
253 const xAxisShowLabel = xAxisConfig?.label?.enabled ?? showAxisLabels;
254 const xAxisShowGrid = xAxisConfig?.gridLine?.enabled ?? false; // X-axis grid off by default
255 const yAxisShowLabel = yAxisConfig?.label?.enabled ?? showAxisLabels;
256 const yAxisShowGrid = yAxisConfig?.gridLine?.enabled ?? showGrid;
257
258 // Normalize axis title configs (support legacy string values)
259 const xAxisTitleConfig =
260 typeof xAxisConfig?.title === "string"
261 ? { text: xAxisConfig.title }
262 : (xAxisConfig?.title ?? {});
263 const yAxisTitleConfig =
264 typeof yAxisConfig?.title === "string"
265 ? { text: yAxisConfig.title }
266 : (yAxisConfig?.title ?? {});
267
268 const xAxisTitleEnabled =
269 (xAxisTitleConfig as { enabled?: boolean }).enabled ??
270 Boolean((xAxisTitleConfig as { text?: string }).text);
271 const yAxisTitleEnabled =
272 (yAxisTitleConfig as { enabled?: boolean }).enabled ??
273 Boolean((yAxisTitleConfig as { text?: string }).text);
274
275 return {
276 // Title configuration
277 title: titleConfig?.text
278 ? {
279 text: titleConfig.text,
280 ...(titleConfig.fontSize && { fontSize: titleConfig.fontSize }),
281 ...(titleConfig.color && { color: titleConfig.color }),
282 }
283 : undefined,
284 // Subtitle configuration
285 subtitle: subtitleConfig?.text
286 ? {
287 text: subtitleConfig.text,
288 ...(subtitleConfig.fontSize && { fontSize: subtitleConfig.fontSize }),
289 ...(subtitleConfig.color && { color: subtitleConfig.color }),
290 }
291 : undefined,
292 // Per-axis configuration
293 xAxis: {
294 showLabel: xAxisShowLabel,
295 showGrid: xAxisShowGrid,
296 title: xAxisTitleEnabled
297 ? { text: (xAxisTitleConfig as { text?: string }).text }
298 : undefined,
299 },
300 yAxis: {
301 showLabel: yAxisShowLabel,
302 showGrid: yAxisShowGrid,
303 title: yAxisTitleEnabled
304 ? { text: (yAxisTitleConfig as { text?: string }).text }
305 : undefined,
306 },
307 // Legacy axis title access (backward compatibility)
308 xAxisTitle: xAxisTitleEnabled
309 ? { text: (xAxisTitleConfig as { text?: string }).text }
310 : undefined,
311 yAxisTitle: yAxisTitleEnabled
312 ? { text: (yAxisTitleConfig as { text?: string }).text }
313 : undefined,
314 // Legend configuration
315 legend: {
316 enabled: showLegend,
317 ...(legendConfig?.position && { position: legendConfig.position }),
318 },
319 // Animation configuration
320 animation: {
321 enabled: animationEnabled,
322 ...(animationConfig?.duration && { duration: animationConfig.duration }),
323 },
324 // Background configuration
325 background: {
326 visible: backgroundConfig?.visible ?? false,
327 ...(backgroundConfig?.fill && { fill: backgroundConfig.fill }),
328 },
329 // Legacy computed flags for backward compatibility
330 showAxisLabels,
331 showGrid,
332 showLegend,
333 };
334 }
335
335 lines TYPESCRIPT