@finos/legend-lego 2.0.202 → 2.0.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/lib/index.css +2 -2
  2. package/lib/index.css.map +1 -1
  3. package/lib/legend-ai/LegendAIDocEnrichment.d.ts +34 -1
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +890 -1
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts +36 -0
  8. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIServiceRetrieval.js +179 -0
  10. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -0
  11. package/lib/legend-ai/LegendAITypes.d.ts +62 -2
  12. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  13. package/lib/legend-ai/LegendAITypes.js +56 -30
  14. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  15. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +29 -6
  16. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  17. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +9 -0
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  19. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +5 -0
  20. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  21. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +18 -2
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  23. package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -1
  24. package/lib/legend-ai/components/LegendAIAnalysisPanel.js +27 -8
  25. package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -1
  26. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +1 -0
  27. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -1
  28. package/lib/legend-ai/components/LegendAIAnalysisUtils.js +144 -22
  29. package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -1
  30. package/lib/legend-ai/components/LegendAICharts.d.ts +4 -0
  31. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  32. package/lib/legend-ai/components/LegendAICharts.js +60 -1
  33. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  34. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  35. package/lib/legend-ai/components/LegendAIChat.js +46 -17
  36. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  37. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +1 -1
  38. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -1
  39. package/lib/legend-ai/components/LegendAIChatHelpers.js +220 -15
  40. package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -1
  41. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  42. package/lib/legend-ai/components/LegendAIChatInput.js +1 -0
  43. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  44. package/lib/legend-ai/components/LegendAIScopeSelector.d.ts.map +1 -1
  45. package/lib/legend-ai/components/LegendAIScopeSelector.js +5 -2
  46. package/lib/legend-ai/components/LegendAIScopeSelector.js.map +1 -1
  47. package/lib/legend-ai/index.d.ts +4 -3
  48. package/lib/legend-ai/index.d.ts.map +1 -1
  49. package/lib/legend-ai/index.js +4 -3
  50. package/lib/legend-ai/index.js.map +1 -1
  51. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +30 -13
  52. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  53. package/lib/legend-ai/stores/LegendAIChatProcessors.js +776 -216
  54. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  55. package/lib/legend-ai/stores/LegendAIChatState.d.ts +2 -2
  56. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  57. package/lib/legend-ai/stores/LegendAIChatState.js +26 -25
  58. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/legend-ai/LegendAIDocEnrichment.ts +1198 -1
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +228 -0
  62. package/src/legend-ai/LegendAITypes.ts +138 -32
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +62 -0
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +28 -0
  65. package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +36 -17
  66. package/src/legend-ai/components/LegendAIAnalysisUtils.ts +178 -22
  67. package/src/legend-ai/components/LegendAICharts.tsx +160 -0
  68. package/src/legend-ai/components/LegendAIChat.tsx +107 -14
  69. package/src/legend-ai/components/LegendAIChatHelpers.ts +334 -20
  70. package/src/legend-ai/components/LegendAIChatInput.tsx +2 -1
  71. package/src/legend-ai/components/LegendAIScopeSelector.tsx +7 -2
  72. package/src/legend-ai/index.ts +22 -1
  73. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1205 -321
  74. package/src/legend-ai/stores/LegendAIChatState.ts +80 -69
  75. package/tsconfig.json +1 -0
@@ -18,7 +18,11 @@ import { useMemo } from 'react';
18
18
  import { LegendAIChartType } from '../LegendAI_LegendApplicationPlugin_Extension.js';
19
19
  import type { LegendAIGridData } from '../LegendAITypes.js';
20
20
  import { analyzeGridData } from './LegendAIAnalysisUtils.js';
21
- import { LegendAIBarChart, LegendAIDonutChart } from './LegendAICharts.js';
21
+ import {
22
+ LegendAIBarChart,
23
+ LegendAIDonutChart,
24
+ LegendAILineChart,
25
+ } from './LegendAICharts.js';
22
26
 
23
27
  export const LegendAIAnalysisPanel = (props: {
24
28
  gridData: LegendAIGridData;
@@ -27,21 +31,42 @@ export const LegendAIAnalysisPanel = (props: {
27
31
  }): React.ReactNode => {
28
32
  const { gridData, summary, SummaryRenderer } = props;
29
33
 
30
- const { metrics, chartType, chartData, numericColumnName } = useMemo(
31
- () => analyzeGridData(gridData),
32
- [gridData],
33
- );
34
+ const {
35
+ metrics,
36
+ chartType,
37
+ chartData,
38
+ numericColumnName,
39
+ categoryColumnName,
40
+ } = useMemo(() => analyzeGridData(gridData), [gridData]);
34
41
 
35
- let chartTitle: string | undefined;
36
- if (numericColumnName !== undefined) {
37
- chartTitle =
38
- chartType === LegendAIChartType.PIE
42
+ const chartTitle = useMemo(() => {
43
+ if (chartType === LegendAIChartType.LINE && numericColumnName) {
44
+ return categoryColumnName
45
+ ? `${numericColumnName} over ${categoryColumnName}`
46
+ : `${numericColumnName} Trend`;
47
+ }
48
+ if (numericColumnName !== undefined) {
49
+ return chartType === LegendAIChartType.PIE
39
50
  ? `${numericColumnName} Distribution`
40
51
  : `Top ${chartData.length} by ${numericColumnName}`;
41
- }
52
+ }
53
+ if (categoryColumnName !== undefined) {
54
+ return `${categoryColumnName} Distribution`;
55
+ }
56
+ return undefined;
57
+ }, [chartType, numericColumnName, categoryColumnName, chartData.length]);
42
58
 
43
59
  const chartTitleProp = chartTitle === undefined ? {} : { title: chartTitle };
44
60
 
61
+ let chartElement: React.ReactNode;
62
+ if (chartType === LegendAIChartType.LINE) {
63
+ chartElement = <LegendAILineChart data={chartData} {...chartTitleProp} />;
64
+ } else if (chartType === LegendAIChartType.PIE) {
65
+ chartElement = <LegendAIDonutChart data={chartData} {...chartTitleProp} />;
66
+ } else {
67
+ chartElement = <LegendAIBarChart data={chartData} {...chartTitleProp} />;
68
+ }
69
+
45
70
  return (
46
71
  <div className="legend-ai-analysis">
47
72
  {metrics.length > 0 && (
@@ -65,13 +90,7 @@ export const LegendAIAnalysisPanel = (props: {
65
90
  )}
66
91
 
67
92
  {chartData.length > 0 && (
68
- <div className="legend-ai-analysis__chart-section">
69
- {chartType === LegendAIChartType.PIE ? (
70
- <LegendAIDonutChart data={chartData} {...chartTitleProp} />
71
- ) : (
72
- <LegendAIBarChart data={chartData} {...chartTitleProp} />
73
- )}
74
- </div>
93
+ <div className="legend-ai-analysis__chart-section">{chartElement}</div>
75
94
  )}
76
95
 
77
96
  <div className="legend-ai-analysis__narrative">
@@ -26,15 +26,17 @@ const CHART_PALETTE_COUNT = 10;
26
26
  const MAX_CHART_ITEMS = 10;
27
27
  const TOP_N_ITEMS = 5;
28
28
  const MAX_PROFILE_SAMPLE = 1000;
29
- const MAX_KEY_METRICS = 4;
29
+ const MAX_KEY_METRICS = 6;
30
30
  const MAX_METRIC_COLUMNS = 5;
31
31
  const MAX_BAR_CHART_ROWS = 20;
32
32
  const MAX_PIE_CHART_ROWS = 6;
33
+ const CONCENTRATION_THRESHOLD = 0.6;
33
34
 
34
35
  interface ColumnProfile {
35
36
  name: string;
36
37
  isNumeric: boolean;
37
38
  isString: boolean;
39
+ isDate: boolean;
38
40
  uniqueCount: number;
39
41
  nonNullCount: number;
40
42
  numericValues: number[];
@@ -45,6 +47,52 @@ export interface LegendAIGridAnalysis {
45
47
  chartType: LegendAIChartType;
46
48
  chartData: LegendAIChartDataPoint[];
47
49
  numericColumnName: string | undefined;
50
+ categoryColumnName: string | undefined;
51
+ }
52
+
53
+ const DATE_NAME_PATTERN = /(?:date|time|timestamp)|(?:_dt$)/i;
54
+
55
+ function looksLikeDateValue(v: unknown): boolean {
56
+ if (!isString(v)) {
57
+ return false;
58
+ }
59
+ // ISO date patterns: 2024-01-15, 2024-01-15T10:30:00, etc.
60
+ return /^\d{4}-\d{2}-\d{2}/.test(v);
61
+ }
62
+
63
+ function profileRowValues(
64
+ rows: Record<string, unknown>[],
65
+ field: string,
66
+ ): {
67
+ nonNullCount: number;
68
+ stringCount: number;
69
+ dateCount: number;
70
+ numericValues: number[];
71
+ unique: Set<string>;
72
+ } {
73
+ let nonNullCount = 0;
74
+ let stringCount = 0;
75
+ let dateCount = 0;
76
+ const numericValues: number[] = [];
77
+ const unique = new Set<string>();
78
+ for (const r of rows) {
79
+ const v = r[field];
80
+ if (v === null || v === undefined) {
81
+ continue;
82
+ }
83
+ nonNullCount++;
84
+ unique.add(isString(v) ? v : JSON.stringify(v));
85
+ if (isNumber(v)) {
86
+ numericValues.push(v);
87
+ }
88
+ if (isString(v)) {
89
+ stringCount++;
90
+ if (looksLikeDateValue(v)) {
91
+ dateCount++;
92
+ }
93
+ }
94
+ }
95
+ return { nonNullCount, stringCount, dateCount, numericValues, unique };
48
96
  }
49
97
 
50
98
  function profileColumns(gridData: LegendAIGridData): ColumnProfile[] {
@@ -55,29 +103,16 @@ function profileColumns(gridData: LegendAIGridData): ColumnProfile[] {
55
103
 
56
104
  return gridData.columnDefs.map((col) => {
57
105
  const field = col.field ?? col.colId ?? '';
58
- let nonNullCount = 0;
59
- let stringCount = 0;
60
- const numericValues: number[] = [];
61
- const unique = new Set<string>();
62
-
63
- for (const r of rows) {
64
- const v = r[field];
65
- if (v !== null && v !== undefined) {
66
- nonNullCount++;
67
- unique.add(String(v));
68
- if (isNumber(v)) {
69
- numericValues.push(v);
70
- }
71
- if (isString(v)) {
72
- stringCount++;
73
- }
74
- }
75
- }
76
-
106
+ const { nonNullCount, stringCount, dateCount, numericValues, unique } =
107
+ profileRowValues(rows, field);
108
+ const isDateCol =
109
+ (dateCount > 0 && dateCount >= nonNullCount * 0.8) ||
110
+ (nonNullCount === 0 && DATE_NAME_PATTERN.test(field));
77
111
  return {
78
112
  name: field,
79
113
  isNumeric: numericValues.length === nonNullCount && nonNullCount > 0,
80
114
  isString: nonNullCount > 0 && stringCount === nonNullCount,
115
+ isDate: isDateCol,
81
116
  uniqueCount: unique.size,
82
117
  nonNullCount,
83
118
  numericValues,
@@ -143,9 +178,76 @@ function computeNumericMetrics(
143
178
  }
144
179
  }
145
180
 
181
+ function computeConcentrationMetric(
182
+ profiles: ColumnProfile[],
183
+ gridData: LegendAIGridData,
184
+ ): LegendAIKeyMetric | undefined {
185
+ const catCol = findBestCategoricalColumn(profiles, gridData.rowData.length);
186
+ if (!catCol || catCol.uniqueCount < 3) {
187
+ return undefined;
188
+ }
189
+ const freqMap = new Map<string, number>();
190
+ for (const row of gridData.rowData) {
191
+ const val = String(row[catCol.name] ?? '');
192
+ if (val.length > 0) {
193
+ freqMap.set(val, (freqMap.get(val) ?? 0) + 1);
194
+ }
195
+ }
196
+ const sorted = [...freqMap.values()].sort((a, b) => b - a);
197
+ const topCount = Math.min(3, sorted.length);
198
+ let topSum = 0;
199
+ for (let i = 0; i < topCount; i++) {
200
+ const count = sorted[i];
201
+ if (count !== undefined) {
202
+ topSum += count;
203
+ }
204
+ }
205
+ const pct = topSum / gridData.rowData.length;
206
+ if (pct >= CONCENTRATION_THRESHOLD) {
207
+ return {
208
+ label: `Top ${topCount} ${catCol.name}`,
209
+ value: `${Math.round(pct * 100)}%`,
210
+ detail: 'of all records',
211
+ };
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ function computeDateRangeMetric(
217
+ profiles: ColumnProfile[],
218
+ gridData: LegendAIGridData,
219
+ ): LegendAIKeyMetric | undefined {
220
+ const dateCol = profiles.find((c) => c.isDate);
221
+ if (!dateCol) {
222
+ return undefined;
223
+ }
224
+ let minDate: string | undefined;
225
+ let maxDate: string | undefined;
226
+ for (const row of gridData.rowData) {
227
+ const v = row[dateCol.name];
228
+ if (isString(v) && v.length >= 10) {
229
+ const d = v.slice(0, 10);
230
+ if (!minDate || d < minDate) {
231
+ minDate = d;
232
+ }
233
+ if (!maxDate || d > maxDate) {
234
+ maxDate = d;
235
+ }
236
+ }
237
+ }
238
+ if (minDate && maxDate && minDate !== maxDate) {
239
+ return {
240
+ label: 'Date Range',
241
+ value: `${minDate} – ${maxDate}`,
242
+ };
243
+ }
244
+ return undefined;
245
+ }
246
+
146
247
  function computeKeyMetricsFromProfiles(
147
248
  profiles: ColumnProfile[],
148
249
  rowCount: number,
250
+ gridData?: LegendAIGridData,
149
251
  ): LegendAIKeyMetric[] {
150
252
  const metrics: LegendAIKeyMetric[] = [];
151
253
 
@@ -154,9 +256,11 @@ function computeKeyMetricsFromProfiles(
154
256
  value: rowCount.toLocaleString(),
155
257
  });
156
258
 
157
- const numericCol = profiles.find(
259
+ // Primary numeric column
260
+ const numericCols = profiles.filter(
158
261
  (c) => c.isNumeric && c.numericValues.length > 0,
159
262
  );
263
+ const numericCol = numericCols[0];
160
264
  if (numericCol) {
161
265
  computeNumericMetrics(numericCol, metrics);
162
266
  }
@@ -171,6 +275,36 @@ function computeKeyMetricsFromProfiles(
171
275
  });
172
276
  }
173
277
 
278
+ // Second numeric column (if distinct from primary)
279
+ const secondNumeric = numericCols[1];
280
+ if (secondNumeric && metrics.length < MAX_KEY_METRICS) {
281
+ let sum2 = 0;
282
+ for (const n of secondNumeric.numericValues) {
283
+ sum2 += n;
284
+ }
285
+ const avg2 = sum2 / secondNumeric.numericValues.length;
286
+ metrics.push({
287
+ label: `Avg ${secondNumeric.name}`,
288
+ value: formatNumber(avg2),
289
+ });
290
+ }
291
+
292
+ // Date range metric
293
+ if (gridData && metrics.length < MAX_KEY_METRICS) {
294
+ const dateMetric = computeDateRangeMetric(profiles, gridData);
295
+ if (dateMetric) {
296
+ metrics.push(dateMetric);
297
+ }
298
+ }
299
+
300
+ // Concentration metric (top-3 share)
301
+ if (gridData && metrics.length < MAX_KEY_METRICS) {
302
+ const concentrationMetric = computeConcentrationMetric(profiles, gridData);
303
+ if (concentrationMetric) {
304
+ metrics.push(concentrationMetric);
305
+ }
306
+ }
307
+
174
308
  return metrics.slice(0, MAX_KEY_METRICS);
175
309
  }
176
310
 
@@ -180,6 +314,7 @@ export function computeKeyMetrics(
180
314
  return computeKeyMetricsFromProfiles(
181
315
  profileColumns(gridData),
182
316
  gridData.rowData.length,
317
+ gridData,
183
318
  );
184
319
  }
185
320
 
@@ -189,6 +324,12 @@ function inferChartTypeFromProfiles(
189
324
  ): LegendAIChartType {
190
325
  const numericCols = profiles.filter((c) => c.isNumeric);
191
326
  const stringCols = profiles.filter((c) => c.isString && c.uniqueCount > 1);
327
+ const dateCols = profiles.filter((c) => c.isDate);
328
+
329
+ // Date + numeric → LINE chart (time series)
330
+ if (dateCols.length >= 1 && numericCols.length >= 1 && rowCount > 1) {
331
+ return LegendAIChartType.LINE;
332
+ }
192
333
 
193
334
  if (
194
335
  stringCols.length >= 1 &&
@@ -318,6 +459,20 @@ export function findNumericColumnName(
318
459
  return findNumericColumnNameFromProfiles(profileColumns(gridData), gridData);
319
460
  }
320
461
 
462
+ function findCategoryColumnNameFromProfiles(
463
+ profiles: ColumnProfile[],
464
+ gridData: LegendAIGridData,
465
+ ): string | undefined {
466
+ const catCol = findBestCategoricalColumn(profiles, gridData.rowData.length);
467
+ if (!catCol) {
468
+ return undefined;
469
+ }
470
+ const colDef = gridData.columnDefs.find(
471
+ (c) => (c.field ?? c.colId ?? '') === catCol.name,
472
+ );
473
+ return colDef?.headerName ?? colDef?.field;
474
+ }
475
+
321
476
  export function analyzeGridData(
322
477
  gridData: LegendAIGridData,
323
478
  ): LegendAIGridAnalysis {
@@ -325,12 +480,13 @@ export function analyzeGridData(
325
480
  const rowCount = gridData.rowData.length;
326
481
  const chartType = inferChartTypeFromProfiles(profiles, rowCount);
327
482
  return {
328
- metrics: computeKeyMetricsFromProfiles(profiles, rowCount),
483
+ metrics: computeKeyMetricsFromProfiles(profiles, rowCount, gridData),
329
484
  chartType,
330
485
  chartData:
331
486
  chartType === LegendAIChartType.NONE
332
487
  ? []
333
488
  : computeChartDataFromProfiles(profiles, gridData),
334
489
  numericColumnName: findNumericColumnNameFromProfiles(profiles, gridData),
490
+ categoryColumnName: findCategoryColumnNameFromProfiles(profiles, gridData),
335
491
  };
336
492
  }
@@ -164,3 +164,163 @@ export const LegendAIDonutChart = (props: {
164
164
  </div>
165
165
  );
166
166
  };
167
+
168
+ // ─── Line chart ──────────────────────────────────────────────────────────────
169
+
170
+ const LINE_SVG_WIDTH = 400;
171
+ const LINE_SVG_HEIGHT = 180;
172
+ const LINE_PADDING = { top: 16, right: 16, bottom: 28, left: 52 };
173
+
174
+ export const LegendAILineChart = (props: {
175
+ data: LegendAIChartDataPoint[];
176
+ title?: string;
177
+ }): React.ReactNode => {
178
+ const { data, title } = props;
179
+
180
+ const { points, polyline, minVal, maxVal } = useMemo(() => {
181
+ if (data.length === 0) {
182
+ return { points: [], polyline: '', minVal: 0, maxVal: 0 };
183
+ }
184
+ const plotW = LINE_SVG_WIDTH - LINE_PADDING.left - LINE_PADDING.right;
185
+ const plotH = LINE_SVG_HEIGHT - LINE_PADDING.top - LINE_PADDING.bottom;
186
+ const firstVal = data[0];
187
+ if (!firstVal) {
188
+ return { points: [], polyline: '', minVal: 0, maxVal: 0 };
189
+ }
190
+ let lo = firstVal.value;
191
+ let hi = lo;
192
+ for (const d of data) {
193
+ if (d.value < lo) {
194
+ lo = d.value;
195
+ }
196
+ if (d.value > hi) {
197
+ hi = d.value;
198
+ }
199
+ }
200
+ const range = hi - lo || 1;
201
+ const pts = data.map((d, i) => {
202
+ const x =
203
+ LINE_PADDING.left +
204
+ (data.length > 1 ? (i / (data.length - 1)) * plotW : plotW / 2);
205
+ const y = LINE_PADDING.top + (1 - (d.value - lo) / range) * plotH;
206
+ return { x, y, ...d };
207
+ });
208
+ return {
209
+ points: pts,
210
+ polyline: pts.map((p) => `${p.x},${p.y}`).join(' '),
211
+ minVal: lo,
212
+ maxVal: hi,
213
+ };
214
+ }, [data]);
215
+
216
+ if (data.length === 0) {
217
+ return null;
218
+ }
219
+
220
+ const plotBottom = LINE_SVG_HEIGHT - LINE_PADDING.bottom;
221
+ const color = getChartColor(0);
222
+
223
+ return (
224
+ <div className="legend-ai-chart legend-ai-chart--line">
225
+ {title !== undefined && title.length > 0 && (
226
+ <div className="legend-ai-chart__title">{title}</div>
227
+ )}
228
+ <svg
229
+ viewBox={`0 0 ${LINE_SVG_WIDTH} ${LINE_SVG_HEIGHT}`}
230
+ className="legend-ai-chart__line-svg"
231
+ preserveAspectRatio="xMidYMid meet"
232
+ >
233
+ {/* Y-axis labels */}
234
+ <text
235
+ x={LINE_PADDING.left - 6}
236
+ y={LINE_PADDING.top + 4}
237
+ className="legend-ai-chart__axis-label"
238
+ textAnchor="end"
239
+ >
240
+ {Number.isInteger(maxVal)
241
+ ? maxVal.toLocaleString()
242
+ : maxVal.toFixed(1)}
243
+ </text>
244
+ <text
245
+ x={LINE_PADDING.left - 6}
246
+ y={plotBottom + 4}
247
+ className="legend-ai-chart__axis-label"
248
+ textAnchor="end"
249
+ >
250
+ {Number.isInteger(minVal)
251
+ ? minVal.toLocaleString()
252
+ : minVal.toFixed(1)}
253
+ </text>
254
+
255
+ {/* Grid lines */}
256
+ <line
257
+ x1={LINE_PADDING.left}
258
+ y1={LINE_PADDING.top}
259
+ x2={LINE_SVG_WIDTH - LINE_PADDING.right}
260
+ y2={LINE_PADDING.top}
261
+ className="legend-ai-chart__grid-line"
262
+ />
263
+ <line
264
+ x1={LINE_PADDING.left}
265
+ y1={plotBottom}
266
+ x2={LINE_SVG_WIDTH - LINE_PADDING.right}
267
+ y2={plotBottom}
268
+ className="legend-ai-chart__grid-line"
269
+ />
270
+
271
+ {/* Line */}
272
+ <polyline
273
+ points={polyline}
274
+ fill="none"
275
+ stroke={color}
276
+ strokeWidth="2"
277
+ className="legend-ai-chart__line-path"
278
+ />
279
+
280
+ {/* Dots */}
281
+ {points.map((p) => (
282
+ <circle
283
+ key={`${p.label}-${String(p.value)}`}
284
+ cx={p.x}
285
+ cy={p.y}
286
+ r={3}
287
+ fill={color}
288
+ className="legend-ai-chart__line-dot"
289
+ />
290
+ ))}
291
+
292
+ {/* X-axis labels (first, mid, last) */}
293
+ {points.length > 0 &&
294
+ (() => {
295
+ const firstPt = points[0];
296
+ const lastPt = points[points.length - 1];
297
+ if (!firstPt || !lastPt) {
298
+ return null;
299
+ }
300
+ return (
301
+ <>
302
+ <text
303
+ x={firstPt.x}
304
+ y={plotBottom + 16}
305
+ className="legend-ai-chart__axis-label"
306
+ textAnchor="start"
307
+ >
308
+ {firstPt.label.slice(0, 10)}
309
+ </text>
310
+ {points.length > 2 && (
311
+ <text
312
+ x={lastPt.x}
313
+ y={plotBottom + 16}
314
+ className="legend-ai-chart__axis-label"
315
+ textAnchor="end"
316
+ >
317
+ {lastPt.label.slice(0, 10)}
318
+ </text>
319
+ )}
320
+ </>
321
+ );
322
+ })()}
323
+ </svg>
324
+ </div>
325
+ );
326
+ };