@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.
- package/lib/index.css +2 -2
- package/lib/index.css.map +1 -1
- package/lib/legend-ai/LegendAIDocEnrichment.d.ts +34 -1
- package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
- package/lib/legend-ai/LegendAIDocEnrichment.js +890 -1
- package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
- package/lib/legend-ai/LegendAIServiceRetrieval.d.ts +36 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.js +179 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -0
- package/lib/legend-ai/LegendAITypes.d.ts +62 -2
- package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
- package/lib/legend-ai/LegendAITypes.js +56 -30
- package/lib/legend-ai/LegendAITypes.js.map +1 -1
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +29 -6
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +9 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +5 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +18 -2
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisPanel.js +27 -8
- package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +1 -0
- package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisUtils.js +144 -22
- package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -1
- package/lib/legend-ai/components/LegendAICharts.d.ts +4 -0
- package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAICharts.js +60 -1
- package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChat.js +46 -17
- package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +1 -1
- package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChatHelpers.js +220 -15
- package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.js +1 -0
- package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
- package/lib/legend-ai/components/LegendAIScopeSelector.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIScopeSelector.js +5 -2
- package/lib/legend-ai/components/LegendAIScopeSelector.js.map +1 -1
- package/lib/legend-ai/index.d.ts +4 -3
- package/lib/legend-ai/index.d.ts.map +1 -1
- package/lib/legend-ai/index.js +4 -3
- package/lib/legend-ai/index.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +30 -13
- package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatProcessors.js +776 -216
- package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatState.d.ts +2 -2
- package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatState.js +26 -25
- package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
- package/package.json +3 -3
- package/src/legend-ai/LegendAIDocEnrichment.ts +1198 -1
- package/src/legend-ai/LegendAIServiceRetrieval.ts +228 -0
- package/src/legend-ai/LegendAITypes.ts +138 -32
- package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +62 -0
- package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +28 -0
- package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +36 -17
- package/src/legend-ai/components/LegendAIAnalysisUtils.ts +178 -22
- package/src/legend-ai/components/LegendAICharts.tsx +160 -0
- package/src/legend-ai/components/LegendAIChat.tsx +107 -14
- package/src/legend-ai/components/LegendAIChatHelpers.ts +334 -20
- package/src/legend-ai/components/LegendAIChatInput.tsx +2 -1
- package/src/legend-ai/components/LegendAIScopeSelector.tsx +7 -2
- package/src/legend-ai/index.ts +22 -1
- package/src/legend-ai/stores/LegendAIChatProcessors.ts +1205 -321
- package/src/legend-ai/stores/LegendAIChatState.ts +80 -69
- 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 {
|
|
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 {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const {
|
|
35
|
+
metrics,
|
|
36
|
+
chartType,
|
|
37
|
+
chartData,
|
|
38
|
+
numericColumnName,
|
|
39
|
+
categoryColumnName,
|
|
40
|
+
} = useMemo(() => analyzeGridData(gridData), [gridData]);
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 =
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
-
|
|
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
|
+
};
|