| 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 |