@principal-ai/principal-view-react 0.14.14 → 0.14.16
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/dist/components/SequenceDiagramRenderer.d.ts +61 -0
- package/dist/components/SequenceDiagramRenderer.d.ts.map +1 -0
- package/dist/components/SequenceDiagramRenderer.js +184 -0
- package/dist/components/SequenceDiagramRenderer.js.map +1 -0
- package/dist/components/dashboard/DashboardRenderer.d.ts +9 -0
- package/dist/components/dashboard/DashboardRenderer.d.ts.map +1 -0
- package/dist/components/dashboard/DashboardRenderer.js +179 -0
- package/dist/components/dashboard/DashboardRenderer.js.map +1 -0
- package/dist/components/dashboard/MetricPanel.d.ts +9 -0
- package/dist/components/dashboard/MetricPanel.d.ts.map +1 -0
- package/dist/components/dashboard/MetricPanel.js +103 -0
- package/dist/components/dashboard/MetricPanel.js.map +1 -0
- package/dist/components/dashboard/MockDataProvider.d.ts +30 -0
- package/dist/components/dashboard/MockDataProvider.d.ts.map +1 -0
- package/dist/components/dashboard/MockDataProvider.js +270 -0
- package/dist/components/dashboard/MockDataProvider.js.map +1 -0
- package/dist/components/dashboard/components/BarChart.d.ts +9 -0
- package/dist/components/dashboard/components/BarChart.d.ts.map +1 -0
- package/dist/components/dashboard/components/BarChart.js +167 -0
- package/dist/components/dashboard/components/BarChart.js.map +1 -0
- package/dist/components/dashboard/components/LineChart.d.ts +9 -0
- package/dist/components/dashboard/components/LineChart.d.ts.map +1 -0
- package/dist/components/dashboard/components/LineChart.js +141 -0
- package/dist/components/dashboard/components/LineChart.js.map +1 -0
- package/dist/components/dashboard/components/MetricCard.d.ts +8 -0
- package/dist/components/dashboard/components/MetricCard.d.ts.map +1 -0
- package/dist/components/dashboard/components/MetricCard.js +163 -0
- package/dist/components/dashboard/components/MetricCard.js.map +1 -0
- package/dist/components/dashboard/components/SourceLink.d.ts +8 -0
- package/dist/components/dashboard/components/SourceLink.d.ts.map +1 -0
- package/dist/components/dashboard/components/SourceLink.js +39 -0
- package/dist/components/dashboard/components/SourceLink.js.map +1 -0
- package/dist/components/dashboard/components/TimeRangeSelector.d.ts +8 -0
- package/dist/components/dashboard/components/TimeRangeSelector.d.ts.map +1 -0
- package/dist/components/dashboard/components/TimeRangeSelector.js +167 -0
- package/dist/components/dashboard/components/TimeRangeSelector.js.map +1 -0
- package/dist/components/dashboard/components/index.d.ts +6 -0
- package/dist/components/dashboard/components/index.d.ts.map +1 -0
- package/dist/components/dashboard/components/index.js +6 -0
- package/dist/components/dashboard/components/index.js.map +1 -0
- package/dist/components/dashboard/index.d.ts +6 -0
- package/dist/components/dashboard/index.d.ts.map +1 -0
- package/dist/components/dashboard/index.js +8 -0
- package/dist/components/dashboard/index.js.map +1 -0
- package/dist/components/dashboard/types.d.ts +74 -0
- package/dist/components/dashboard/types.d.ts.map +1 -0
- package/dist/components/dashboard/types.js +8 -0
- package/dist/components/dashboard/types.js.map +1 -0
- package/dist/hooks/useSequenceLayout.d.ts +148 -0
- package/dist/hooks/useSequenceLayout.d.ts.map +1 -0
- package/dist/hooks/useSequenceLayout.js +225 -0
- package/dist/hooks/useSequenceLayout.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/SequenceDiagramRenderer.tsx +459 -0
- package/src/components/dashboard/DashboardRenderer.tsx +317 -0
- package/src/components/dashboard/MetricPanel.tsx +254 -0
- package/src/components/dashboard/MockDataProvider.ts +330 -0
- package/src/components/dashboard/components/BarChart.tsx +299 -0
- package/src/components/dashboard/components/LineChart.tsx +279 -0
- package/src/components/dashboard/components/MetricCard.tsx +270 -0
- package/src/components/dashboard/components/SourceLink.tsx +63 -0
- package/src/components/dashboard/components/TimeRangeSelector.tsx +280 -0
- package/src/components/dashboard/components/index.ts +5 -0
- package/src/components/dashboard/index.ts +47 -0
- package/src/components/dashboard/types.ts +126 -0
- package/src/hooks/useSequenceLayout.ts +413 -0
- package/src/index.ts +62 -0
- package/src/stories/SequenceDiagram.stories.tsx +306 -0
- package/src/stories/dashboard/DashboardRenderer.stories.tsx +263 -0
- package/src/stories/dashboard/sample-dashboards/activity-feed-analytics.dashboard.json +300 -0
- package/src/stories/data/graph-converter-test-execution.json +50 -50
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MockDataProvider
|
|
3
|
+
*
|
|
4
|
+
* Provides mock data for dashboard metrics during prototyping.
|
|
5
|
+
* Can use inline _mockData from the dashboard definition or generate realistic data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
DashboardDefinition,
|
|
10
|
+
MetricDefinition,
|
|
11
|
+
MetricData,
|
|
12
|
+
DataProvider,
|
|
13
|
+
TimeSeriesPoint,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
export class MockDataProvider implements DataProvider {
|
|
17
|
+
private data: Record<string, MetricData> = {};
|
|
18
|
+
|
|
19
|
+
constructor(dashboard: DashboardDefinition) {
|
|
20
|
+
for (const metric of dashboard.metrics) {
|
|
21
|
+
this.data[metric.id] = this.resolveData(metric);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(metricId: string): MetricData {
|
|
26
|
+
return this.data[metricId] || { error: `No data for metric: ${metricId}` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getAll(): Record<string, MetricData> {
|
|
30
|
+
return this.data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private resolveData(metric: MetricDefinition): MetricData {
|
|
34
|
+
// Use inline mock data if provided
|
|
35
|
+
if (metric._mockData) {
|
|
36
|
+
return this.transformMockData(metric._mockData, metric);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Generate data based on metric type and query
|
|
40
|
+
return this.generateData(metric);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private transformMockData(
|
|
44
|
+
mockData: NonNullable<MetricDefinition['_mockData']>,
|
|
45
|
+
_metric: MetricDefinition
|
|
46
|
+
): MetricData {
|
|
47
|
+
const result: MetricData = {};
|
|
48
|
+
|
|
49
|
+
if (mockData.current !== undefined) {
|
|
50
|
+
result.current = mockData.current;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (mockData.previous !== undefined) {
|
|
54
|
+
result.previous = mockData.previous;
|
|
55
|
+
// Calculate change percent if not provided
|
|
56
|
+
if (result.current !== undefined) {
|
|
57
|
+
result.changePercent =
|
|
58
|
+
((result.current - mockData.previous) / mockData.previous) * 100;
|
|
59
|
+
result.trend =
|
|
60
|
+
result.changePercent > 0
|
|
61
|
+
? 'up'
|
|
62
|
+
: result.changePercent < 0
|
|
63
|
+
? 'down'
|
|
64
|
+
: 'flat';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (mockData.trend) {
|
|
69
|
+
result.trend = mockData.trend;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (mockData.series) {
|
|
73
|
+
result.series = mockData.series;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mockData.breakdown) {
|
|
77
|
+
result.breakdown = mockData.breakdown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (mockData.histogram) {
|
|
81
|
+
result.histogram = mockData.histogram;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private generateData(metric: MetricDefinition): MetricData {
|
|
88
|
+
const { type, query } = metric;
|
|
89
|
+
|
|
90
|
+
// Time series data
|
|
91
|
+
if (query.timeGroup) {
|
|
92
|
+
return this.generateTimeSeries(metric);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Single value
|
|
96
|
+
if (type === 'gauge') {
|
|
97
|
+
return this.generateGaugeData(metric);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (type === 'counter') {
|
|
101
|
+
return this.generateCounterData(metric);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (type === 'histogram') {
|
|
105
|
+
return this.generateHistogramData(metric);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback
|
|
109
|
+
return {
|
|
110
|
+
current: Math.floor(Math.random() * 1000),
|
|
111
|
+
trend: 'flat',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private generateTimeSeries(metric: MetricDefinition): MetricData {
|
|
116
|
+
const { query } = metric;
|
|
117
|
+
const points = this.getPointCount(query.timeGroup);
|
|
118
|
+
const series: TimeSeriesPoint[] = [];
|
|
119
|
+
|
|
120
|
+
const baseValue = this.getBaseValue(metric);
|
|
121
|
+
const variance = 0.2;
|
|
122
|
+
|
|
123
|
+
// Generate dates
|
|
124
|
+
const dates = this.generateDates(query.timeGroup, points);
|
|
125
|
+
|
|
126
|
+
if (query.groupBy && query.groupBy.length > 0) {
|
|
127
|
+
// Grouped data (e.g., by isMobile)
|
|
128
|
+
const groups = this.getGroupValues(query.groupBy[0]);
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < points; i++) {
|
|
131
|
+
const point: TimeSeriesPoint = { date: dates[i] };
|
|
132
|
+
for (const group of groups) {
|
|
133
|
+
point[group] = Math.floor(
|
|
134
|
+
baseValue * (0.3 + Math.random() * 0.7) * (1 + (Math.random() - 0.5) * variance)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
series.push(point);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
// Simple time series
|
|
141
|
+
for (let i = 0; i < points; i++) {
|
|
142
|
+
series.push({
|
|
143
|
+
date: dates[i],
|
|
144
|
+
value: Math.floor(baseValue * (1 + (Math.random() - 0.5) * variance)),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { series };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private generateGaugeData(metric: MetricDefinition): MetricData {
|
|
153
|
+
const { query } = metric;
|
|
154
|
+
|
|
155
|
+
// Percentage derivations
|
|
156
|
+
if (
|
|
157
|
+
query.derivation === 'percentage' ||
|
|
158
|
+
query.derivation === 'error_rate' ||
|
|
159
|
+
query.derivation === 'success_rate'
|
|
160
|
+
) {
|
|
161
|
+
const current = Math.random() * 100;
|
|
162
|
+
const previous = Math.random() * 100;
|
|
163
|
+
return {
|
|
164
|
+
current: Math.round(current * 10) / 10,
|
|
165
|
+
previous: Math.round(previous * 10) / 10,
|
|
166
|
+
trend: current > previous ? 'up' : current < previous ? 'down' : 'flat',
|
|
167
|
+
changePercent: Math.round(((current - previous) / previous) * 100 * 10) / 10,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Rate
|
|
172
|
+
if (query.derivation === 'rate') {
|
|
173
|
+
const current = Math.floor(Math.random() * 1000);
|
|
174
|
+
const previous = Math.floor(Math.random() * 1000);
|
|
175
|
+
return {
|
|
176
|
+
current,
|
|
177
|
+
previous,
|
|
178
|
+
trend: current > previous ? 'up' : current < previous ? 'down' : 'flat',
|
|
179
|
+
changePercent: Math.round(((current - previous) / previous) * 100),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const current = Math.floor(Math.random() * 10000);
|
|
184
|
+
return { current, trend: 'flat' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private generateCounterData(_metric: MetricDefinition): MetricData {
|
|
188
|
+
const current = Math.floor(Math.random() * 100000);
|
|
189
|
+
const previous = Math.floor(Math.random() * 100000);
|
|
190
|
+
return {
|
|
191
|
+
current,
|
|
192
|
+
previous,
|
|
193
|
+
trend: current > previous ? 'up' : current < previous ? 'down' : 'flat',
|
|
194
|
+
changePercent: Math.round(((current - previous) / previous) * 100),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private generateHistogramData(metric: MetricDefinition): MetricData {
|
|
199
|
+
const { query } = metric;
|
|
200
|
+
|
|
201
|
+
// Percentile value
|
|
202
|
+
if (query.percentile) {
|
|
203
|
+
const values = [50, 120, 200, 350, 500, 800, 1200];
|
|
204
|
+
const percentileIndex = Math.min(
|
|
205
|
+
Math.floor((query.percentile / 100) * values.length),
|
|
206
|
+
values.length - 1
|
|
207
|
+
);
|
|
208
|
+
const current = values[percentileIndex] + Math.floor(Math.random() * 100);
|
|
209
|
+
const previous = values[percentileIndex] + Math.floor(Math.random() * 100);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
current,
|
|
213
|
+
previous,
|
|
214
|
+
trend: current > previous ? 'up' : current < previous ? 'down' : 'flat',
|
|
215
|
+
changePercent: Math.round(((current - previous) / previous) * 100),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Full histogram
|
|
220
|
+
return {
|
|
221
|
+
histogram: {
|
|
222
|
+
buckets: ['0-50ms', '50-100ms', '100-200ms', '200-500ms', '500ms-1s', '>1s'],
|
|
223
|
+
counts: [
|
|
224
|
+
Math.floor(Math.random() * 1000),
|
|
225
|
+
Math.floor(Math.random() * 800),
|
|
226
|
+
Math.floor(Math.random() * 400),
|
|
227
|
+
Math.floor(Math.random() * 200),
|
|
228
|
+
Math.floor(Math.random() * 100),
|
|
229
|
+
Math.floor(Math.random() * 50),
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private getPointCount(timeGroup?: string): number {
|
|
236
|
+
switch (timeGroup) {
|
|
237
|
+
case 'minute':
|
|
238
|
+
return 60;
|
|
239
|
+
case 'hour':
|
|
240
|
+
return 24;
|
|
241
|
+
case 'day':
|
|
242
|
+
return 7;
|
|
243
|
+
case 'week':
|
|
244
|
+
return 12;
|
|
245
|
+
case 'month':
|
|
246
|
+
return 12;
|
|
247
|
+
default:
|
|
248
|
+
return 7;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private getBaseValue(metric: MetricDefinition): number {
|
|
253
|
+
// Estimate reasonable base value based on metric type
|
|
254
|
+
if (metric.query.derivation === 'percentage' || metric.query.derivation === 'error_rate') {
|
|
255
|
+
return 50;
|
|
256
|
+
}
|
|
257
|
+
if (metric.unit === 'views' || metric.unit === 'count') {
|
|
258
|
+
return 10000;
|
|
259
|
+
}
|
|
260
|
+
if (metric.unit === 'milliseconds' || metric.unit === 'ms') {
|
|
261
|
+
return 200;
|
|
262
|
+
}
|
|
263
|
+
return 1000;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private generateDates(timeGroup: string | undefined, count: number): string[] {
|
|
267
|
+
const dates: string[] = [];
|
|
268
|
+
const now = new Date();
|
|
269
|
+
|
|
270
|
+
for (let i = count - 1; i >= 0; i--) {
|
|
271
|
+
const date = new Date(now);
|
|
272
|
+
|
|
273
|
+
switch (timeGroup) {
|
|
274
|
+
case 'minute':
|
|
275
|
+
date.setMinutes(date.getMinutes() - i);
|
|
276
|
+
dates.push(date.toISOString().slice(11, 16)); // HH:MM
|
|
277
|
+
break;
|
|
278
|
+
case 'hour':
|
|
279
|
+
date.setHours(date.getHours() - i);
|
|
280
|
+
dates.push(date.toISOString().slice(11, 13) + ':00'); // HH:00
|
|
281
|
+
break;
|
|
282
|
+
case 'day':
|
|
283
|
+
date.setDate(date.getDate() - i);
|
|
284
|
+
dates.push(date.toISOString().slice(0, 10)); // YYYY-MM-DD
|
|
285
|
+
break;
|
|
286
|
+
case 'week':
|
|
287
|
+
date.setDate(date.getDate() - i * 7);
|
|
288
|
+
dates.push(`W${this.getWeekNumber(date)}`);
|
|
289
|
+
break;
|
|
290
|
+
case 'month':
|
|
291
|
+
date.setMonth(date.getMonth() - i);
|
|
292
|
+
dates.push(date.toISOString().slice(0, 7)); // YYYY-MM
|
|
293
|
+
break;
|
|
294
|
+
default:
|
|
295
|
+
date.setDate(date.getDate() - i);
|
|
296
|
+
dates.push(date.toISOString().slice(0, 10));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return dates;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private getWeekNumber(date: Date): number {
|
|
304
|
+
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
|
305
|
+
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
|
|
306
|
+
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private getGroupValues(groupBy: string): string[] {
|
|
310
|
+
// Common group values based on attribute name
|
|
311
|
+
if (groupBy === 'isMobile') {
|
|
312
|
+
return ['mobile', 'desktop'];
|
|
313
|
+
}
|
|
314
|
+
if (groupBy === 'status' || groupBy === 'status.code') {
|
|
315
|
+
return ['success', 'error'];
|
|
316
|
+
}
|
|
317
|
+
if (groupBy === 'user.tier') {
|
|
318
|
+
return ['free', 'pro', 'enterprise'];
|
|
319
|
+
}
|
|
320
|
+
// Default generic groups
|
|
321
|
+
return ['group_a', 'group_b'];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Create a mock data provider from a dashboard definition
|
|
327
|
+
*/
|
|
328
|
+
export function createMockDataProvider(dashboard: DashboardDefinition): DataProvider {
|
|
329
|
+
return new MockDataProvider(dashboard);
|
|
330
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarChart
|
|
3
|
+
*
|
|
4
|
+
* Simple SVG-based bar chart for grouped/stacked data.
|
|
5
|
+
* For prototyping - can be replaced with a more robust charting library later.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo } from 'react';
|
|
9
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
10
|
+
import type { BarChartProps } from '../types';
|
|
11
|
+
|
|
12
|
+
const CHART_COLORS = [
|
|
13
|
+
'#3b82f6', // blue
|
|
14
|
+
'#22c55e', // green
|
|
15
|
+
'#f59e0b', // amber
|
|
16
|
+
'#ef4444', // red
|
|
17
|
+
'#8b5cf6', // violet
|
|
18
|
+
'#ec4899', // pink
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function BarChart({
|
|
22
|
+
title,
|
|
23
|
+
data,
|
|
24
|
+
xKey = 'date',
|
|
25
|
+
series,
|
|
26
|
+
stacked = false,
|
|
27
|
+
unit,
|
|
28
|
+
height = 200,
|
|
29
|
+
onClick,
|
|
30
|
+
}: BarChartProps) {
|
|
31
|
+
const { theme } = useTheme();
|
|
32
|
+
|
|
33
|
+
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
|
|
34
|
+
const chartWidth = 400;
|
|
35
|
+
const chartHeight = height;
|
|
36
|
+
|
|
37
|
+
const { bars, yAxisLabels, xAxisLabels } = useMemo(() => {
|
|
38
|
+
if (!data || data.length === 0 || !series || series.length === 0) {
|
|
39
|
+
return { bars: [], yAxisLabels: [], xAxisLabels: [], maxValue: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Find max value
|
|
43
|
+
let max = 0;
|
|
44
|
+
for (const point of data) {
|
|
45
|
+
if (stacked) {
|
|
46
|
+
// Sum all series for stacked bars
|
|
47
|
+
let sum = 0;
|
|
48
|
+
for (const key of series) {
|
|
49
|
+
const val = point[key];
|
|
50
|
+
if (typeof val === 'number') sum += val;
|
|
51
|
+
}
|
|
52
|
+
if (sum > max) max = sum;
|
|
53
|
+
} else {
|
|
54
|
+
// Max of individual values for grouped bars
|
|
55
|
+
for (const key of series) {
|
|
56
|
+
const val = point[key];
|
|
57
|
+
if (typeof val === 'number' && val > max) max = val;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
max = max * 1.1; // Add padding
|
|
63
|
+
|
|
64
|
+
const innerWidth = chartWidth - padding.left - padding.right;
|
|
65
|
+
const innerHeight = chartHeight - padding.top - padding.bottom;
|
|
66
|
+
|
|
67
|
+
const barGroupWidth = innerWidth / data.length;
|
|
68
|
+
const barPadding = barGroupWidth * 0.2;
|
|
69
|
+
const barAreaWidth = barGroupWidth - barPadding;
|
|
70
|
+
|
|
71
|
+
const barsResult: Array<{
|
|
72
|
+
x: number;
|
|
73
|
+
y: number;
|
|
74
|
+
width: number;
|
|
75
|
+
height: number;
|
|
76
|
+
color: string;
|
|
77
|
+
label: string;
|
|
78
|
+
value: number;
|
|
79
|
+
}> = [];
|
|
80
|
+
|
|
81
|
+
data.forEach((point, i) => {
|
|
82
|
+
const groupX = padding.left + i * barGroupWidth + barPadding / 2;
|
|
83
|
+
|
|
84
|
+
if (stacked) {
|
|
85
|
+
// Stacked bars
|
|
86
|
+
let currentY = padding.top + innerHeight;
|
|
87
|
+
|
|
88
|
+
series.forEach((key, keyIndex) => {
|
|
89
|
+
const val = point[key];
|
|
90
|
+
if (typeof val === 'number') {
|
|
91
|
+
const barHeight = (val / max) * innerHeight;
|
|
92
|
+
currentY -= barHeight;
|
|
93
|
+
|
|
94
|
+
barsResult.push({
|
|
95
|
+
x: groupX,
|
|
96
|
+
y: currentY,
|
|
97
|
+
width: barAreaWidth,
|
|
98
|
+
height: barHeight,
|
|
99
|
+
color: CHART_COLORS[keyIndex % CHART_COLORS.length],
|
|
100
|
+
label: key,
|
|
101
|
+
value: val,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
// Grouped bars
|
|
107
|
+
const singleBarWidth = barAreaWidth / series.length;
|
|
108
|
+
|
|
109
|
+
series.forEach((key, keyIndex) => {
|
|
110
|
+
const val = point[key];
|
|
111
|
+
if (typeof val === 'number') {
|
|
112
|
+
const barHeight = (val / max) * innerHeight;
|
|
113
|
+
const barX = groupX + keyIndex * singleBarWidth;
|
|
114
|
+
const barY = padding.top + innerHeight - barHeight;
|
|
115
|
+
|
|
116
|
+
barsResult.push({
|
|
117
|
+
x: barX,
|
|
118
|
+
y: barY,
|
|
119
|
+
width: singleBarWidth * 0.9,
|
|
120
|
+
height: barHeight,
|
|
121
|
+
color: CHART_COLORS[keyIndex % CHART_COLORS.length],
|
|
122
|
+
label: key,
|
|
123
|
+
value: val,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Y-axis labels
|
|
131
|
+
const yLabels: Array<{ value: string; y: number }> = [];
|
|
132
|
+
for (let i = 0; i <= 4; i++) {
|
|
133
|
+
const value = (max / 4) * (4 - i);
|
|
134
|
+
const y = padding.top + (i / 4) * innerHeight;
|
|
135
|
+
yLabels.push({
|
|
136
|
+
value: formatNumber(value),
|
|
137
|
+
y,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// X-axis labels
|
|
142
|
+
const xLabels: Array<{ value: string; x: number }> = [];
|
|
143
|
+
data.forEach((point, i) => {
|
|
144
|
+
const x = padding.left + i * barGroupWidth + barGroupWidth / 2;
|
|
145
|
+
xLabels.push({
|
|
146
|
+
value: String(point[xKey] || '').slice(-5), // Show last 5 chars (e.g., MM-DD)
|
|
147
|
+
x,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
bars: barsResult,
|
|
153
|
+
yAxisLabels: yLabels,
|
|
154
|
+
xAxisLabels: xLabels,
|
|
155
|
+
maxValue: max,
|
|
156
|
+
};
|
|
157
|
+
}, [data, series, xKey, stacked, chartWidth, chartHeight]);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
onClick={onClick}
|
|
162
|
+
style={{
|
|
163
|
+
backgroundColor: theme.colors.surface || theme.colors.background,
|
|
164
|
+
border: `1px solid ${theme.colors.border}`,
|
|
165
|
+
borderRadius: theme.radii?.[2] || 8,
|
|
166
|
+
padding: theme.space?.[3] || 16,
|
|
167
|
+
cursor: onClick ? 'pointer' : 'default',
|
|
168
|
+
fontFamily: theme.fonts.body,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{/* Title */}
|
|
172
|
+
<div
|
|
173
|
+
style={{
|
|
174
|
+
fontSize: theme.fontSizes[1],
|
|
175
|
+
fontWeight: theme.fontWeights.medium,
|
|
176
|
+
color: theme.colors.text,
|
|
177
|
+
marginBottom: theme.space?.[3] || 16,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{title}
|
|
181
|
+
{unit && (
|
|
182
|
+
<span style={{ color: theme.colors.textSecondary, fontWeight: theme.fontWeights.body }}>
|
|
183
|
+
{' '}
|
|
184
|
+
({unit})
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{/* Chart */}
|
|
190
|
+
<svg
|
|
191
|
+
width="100%"
|
|
192
|
+
height={chartHeight}
|
|
193
|
+
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
|
194
|
+
preserveAspectRatio="xMidYMid meet"
|
|
195
|
+
>
|
|
196
|
+
{/* Grid lines */}
|
|
197
|
+
{yAxisLabels.map((label, i) => (
|
|
198
|
+
<line
|
|
199
|
+
key={i}
|
|
200
|
+
x1={padding.left}
|
|
201
|
+
y1={label.y}
|
|
202
|
+
x2={chartWidth - padding.right}
|
|
203
|
+
y2={label.y}
|
|
204
|
+
stroke={theme.colors.border}
|
|
205
|
+
strokeDasharray="4 4"
|
|
206
|
+
opacity={0.5}
|
|
207
|
+
/>
|
|
208
|
+
))}
|
|
209
|
+
|
|
210
|
+
{/* Y-axis labels */}
|
|
211
|
+
{yAxisLabels.map((label, i) => (
|
|
212
|
+
<text
|
|
213
|
+
key={i}
|
|
214
|
+
x={padding.left - 8}
|
|
215
|
+
y={label.y}
|
|
216
|
+
textAnchor="end"
|
|
217
|
+
dominantBaseline="middle"
|
|
218
|
+
fontSize={theme.fontSizes[0]}
|
|
219
|
+
fontFamily={theme.fonts.monospace}
|
|
220
|
+
fill={theme.colors.textSecondary}
|
|
221
|
+
>
|
|
222
|
+
{label.value}
|
|
223
|
+
</text>
|
|
224
|
+
))}
|
|
225
|
+
|
|
226
|
+
{/* X-axis labels */}
|
|
227
|
+
{xAxisLabels.map((label, i) => (
|
|
228
|
+
<text
|
|
229
|
+
key={i}
|
|
230
|
+
x={label.x}
|
|
231
|
+
y={chartHeight - padding.bottom + 20}
|
|
232
|
+
textAnchor="middle"
|
|
233
|
+
fontSize={theme.fontSizes[0]}
|
|
234
|
+
fontFamily={theme.fonts.monospace}
|
|
235
|
+
fill={theme.colors.textSecondary}
|
|
236
|
+
>
|
|
237
|
+
{label.value}
|
|
238
|
+
</text>
|
|
239
|
+
))}
|
|
240
|
+
|
|
241
|
+
{/* Bars */}
|
|
242
|
+
{bars.map((bar, i) => (
|
|
243
|
+
<rect
|
|
244
|
+
key={i}
|
|
245
|
+
x={bar.x}
|
|
246
|
+
y={bar.y}
|
|
247
|
+
width={bar.width}
|
|
248
|
+
height={bar.height}
|
|
249
|
+
fill={bar.color}
|
|
250
|
+
rx={2}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</svg>
|
|
254
|
+
|
|
255
|
+
{/* Legend */}
|
|
256
|
+
{series && series.length > 1 && (
|
|
257
|
+
<div
|
|
258
|
+
style={{
|
|
259
|
+
display: 'flex',
|
|
260
|
+
gap: theme.space?.[3] || 16,
|
|
261
|
+
marginTop: theme.space?.[3] || 12,
|
|
262
|
+
justifyContent: 'center',
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
{series.map((s, i) => (
|
|
266
|
+
<div
|
|
267
|
+
key={s}
|
|
268
|
+
style={{
|
|
269
|
+
display: 'flex',
|
|
270
|
+
alignItems: 'center',
|
|
271
|
+
gap: 6,
|
|
272
|
+
fontSize: theme.fontSizes[0],
|
|
273
|
+
color: theme.colors.textSecondary,
|
|
274
|
+
fontFamily: theme.fonts.body,
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
<div
|
|
278
|
+
style={{
|
|
279
|
+
width: 12,
|
|
280
|
+
height: 12,
|
|
281
|
+
backgroundColor: CHART_COLORS[i % CHART_COLORS.length],
|
|
282
|
+
borderRadius: 2,
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
{s}
|
|
286
|
+
</div>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function formatNumber(value: number): string {
|
|
295
|
+
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
|
296
|
+
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
|
297
|
+
if (value % 1 !== 0) return value.toFixed(1);
|
|
298
|
+
return Math.round(value).toString();
|
|
299
|
+
}
|