@takaro/lib-components 0.4.9 → 0.4.11
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/package.json +13 -6
- package/src/components/actions/Button/__snapshots__/Button.test.tsx.snap +1 -1
- package/src/components/actions/IconButton/__snapshots__/IconButton.test.tsx.snap +1 -1
- package/src/components/charts/AreaChart/AreaChart.stories.tsx +11 -7
- package/src/components/charts/AreaChart/index.tsx +114 -63
- package/src/components/charts/BarChart/BarChart.stories.tsx +33 -10
- package/src/components/charts/BarChart/index.tsx +280 -147
- package/src/components/charts/EmptyChart.tsx +45 -0
- package/src/components/charts/GeoMercator/GeoMercator.stories.tsx +15 -9
- package/src/components/charts/GeoMercator/index.tsx +15 -172
- package/src/components/charts/Heatmap/Heatmap.stories.tsx +167 -33
- package/src/components/charts/Heatmap/index.tsx +427 -193
- package/src/components/charts/LineChart/LineChart.stories.tsx +77 -3
- package/src/components/charts/LineChart/index.tsx +200 -79
- package/src/components/charts/PieChart/PieChart.stories.tsx +128 -20
- package/src/components/charts/PieChart/index.tsx +353 -59
- package/src/components/charts/PointHighlight.tsx +2 -2
- package/src/components/charts/RadarChart/RadarChart.stories.tsx +14 -5
- package/src/components/charts/RadarChart/index.tsx +94 -45
- package/src/components/charts/RadialBarChart/RadialBarChart.stories.tsx +26 -1
- package/src/components/charts/RadialBarChart/index.tsx +100 -34
- package/src/components/charts/RadialLineChart/RadialLineChart.stories.tsx +19 -2
- package/src/components/charts/RadialLineChart/index.tsx +116 -26
- package/src/components/charts/index.tsx +0 -26
- package/src/components/charts/util.ts +50 -12
- package/src/components/data/CountryList/index.tsx +146 -0
- package/src/components/data/Stats/Sparkline.tsx +48 -0
- package/src/components/data/Stats/Stat.tsx +15 -4
- package/src/components/data/Stats/context.tsx +1 -1
- package/src/components/data/Stats/index.tsx +8 -3
- package/src/components/data/index.ts +3 -0
- package/src/components/feedback/IconTooltip/index.tsx +9 -6
- package/src/components/feedback/ProgressBar/ProgressBar.stories.tsx +13 -14
- package/src/components/feedback/ProgressBar/index.tsx +1 -1
- package/src/components/inputs/DurationField/__tests__/Generic.test.tsx +12 -0
- package/src/components/visual/Card/CardTitle.tsx +7 -1
- package/src/components/visual/Card/index.tsx +0 -4
- package/src/helpers/formatNumber.ts +6 -0
- package/src/helpers/index.ts +1 -0
- package/vite.config.mts +4 -0
- package/src/components/charts/echarts/EChartsArea.stories.tsx +0 -139
- package/src/components/charts/echarts/EChartsArea.tsx +0 -139
- package/src/components/charts/echarts/EChartsBar.stories.tsx +0 -141
- package/src/components/charts/echarts/EChartsBar.tsx +0 -133
- package/src/components/charts/echarts/EChartsBase.tsx +0 -264
- package/src/components/charts/echarts/EChartsFunnel.stories.tsx +0 -164
- package/src/components/charts/echarts/EChartsFunnel.tsx +0 -114
- package/src/components/charts/echarts/EChartsHeatmap.stories.tsx +0 -168
- package/src/components/charts/echarts/EChartsHeatmap.tsx +0 -141
- package/src/components/charts/echarts/EChartsLine.stories.tsx +0 -132
- package/src/components/charts/echarts/EChartsLine.tsx +0 -111
- package/src/components/charts/echarts/EChartsPie.stories.tsx +0 -131
- package/src/components/charts/echarts/EChartsPie.tsx +0 -124
- package/src/components/charts/echarts/EChartsRadialBar.stories.tsx +0 -124
- package/src/components/charts/echarts/EChartsRadialBar.tsx +0 -118
- package/src/components/charts/echarts/EChartsScatter.stories.tsx +0 -166
- package/src/components/charts/echarts/EChartsScatter.tsx +0 -135
- package/src/components/charts/echarts/index.ts +0 -26
|
@@ -1,227 +1,461 @@
|
|
|
1
1
|
import { ParentSize } from '@visx/responsive';
|
|
2
2
|
import { Group } from '@visx/group';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { useTooltipInPortal } from '@visx/tooltip';
|
|
6
|
-
import { scaleLinear, scaleBand } from '@visx/scale';
|
|
3
|
+
import { useTooltip, TooltipWithBounds } from '@visx/tooltip';
|
|
4
|
+
import { localPoint } from '@visx/event';
|
|
7
5
|
|
|
8
|
-
import { InnerChartProps,
|
|
6
|
+
import { InnerChartProps, getDefaultTooltipStyles, ChartProps, TooltipConfig } from '../util';
|
|
9
7
|
import { useTheme } from '../../../hooks';
|
|
8
|
+
import { useMemo, useCallback } from 'react';
|
|
9
|
+
import { EmptyChart } from '../EmptyChart';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', ''] as const;
|
|
12
|
+
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const;
|
|
13
|
+
|
|
14
|
+
interface NormalizedCell<T = any> {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
value: number;
|
|
18
|
+
displayValue: any;
|
|
19
|
+
xLabel?: string;
|
|
20
|
+
yLabel?: string;
|
|
21
|
+
originalData?: T;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
interface
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
interface ProcessedData<T = any> {
|
|
25
|
+
cells: NormalizedCell<T>[];
|
|
26
|
+
xLabels: { label: string; index: number }[];
|
|
27
|
+
yLabels: { label: string; index: number }[];
|
|
28
|
+
maxValue: number;
|
|
29
|
+
gridWidth: number;
|
|
30
|
+
gridHeight: number;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
|
-
|
|
33
|
+
// Calendar mode props
|
|
34
|
+
type CalendarModeProps<T> = {
|
|
35
|
+
data: T[];
|
|
36
|
+
dateAccessor: (d: T) => Date;
|
|
37
|
+
valueAccessor: (d: T) => number;
|
|
38
|
+
startDate?: Date;
|
|
39
|
+
endDate?: Date;
|
|
40
|
+
showMonthLabels?: boolean;
|
|
41
|
+
xAccessor?: never;
|
|
42
|
+
yAccessor?: never;
|
|
43
|
+
xCategories?: never;
|
|
44
|
+
yCategories?: never;
|
|
45
|
+
};
|
|
23
46
|
|
|
24
|
-
|
|
25
|
-
name: string;
|
|
47
|
+
type CategoricalModeProps<T> = {
|
|
26
48
|
data: T[];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
49
|
+
xAccessor: (d: T) => number | string;
|
|
50
|
+
yAccessor: (d: T) => number | string;
|
|
51
|
+
valueAccessor?: (d: T) => number;
|
|
52
|
+
xCategories?: string[];
|
|
53
|
+
yCategories?: string[];
|
|
54
|
+
dateAccessor?: never;
|
|
55
|
+
startDate?: never;
|
|
56
|
+
endDate?: never;
|
|
57
|
+
showMonthLabels?: never;
|
|
58
|
+
};
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
xAccessor,
|
|
40
|
-
zAccessor,
|
|
41
|
-
variant,
|
|
42
|
-
tooltipAccessor,
|
|
43
|
-
name,
|
|
44
|
-
margin = defaultMargin,
|
|
45
|
-
}: HeatmapProps<T>) => {
|
|
46
|
-
return (
|
|
47
|
-
<>
|
|
48
|
-
<ParentSize>
|
|
49
|
-
{(parent) => (
|
|
50
|
-
<Chart<T>
|
|
51
|
-
name={name}
|
|
52
|
-
data={data}
|
|
53
|
-
width={parent.width}
|
|
54
|
-
variant={variant}
|
|
55
|
-
height={parent.height}
|
|
56
|
-
margin={margin}
|
|
57
|
-
yAccessor={yAccessor}
|
|
58
|
-
xAccessor={xAccessor}
|
|
59
|
-
zAccessor={zAccessor}
|
|
60
|
-
tooltipAccessor={tooltipAccessor}
|
|
61
|
-
/>
|
|
62
|
-
)}
|
|
63
|
-
</ParentSize>
|
|
64
|
-
</>
|
|
65
|
-
);
|
|
60
|
+
type SharedHeatmapProps<T> = {
|
|
61
|
+
colors?: [string, string, string, string, string];
|
|
62
|
+
showDayLabels?: boolean;
|
|
63
|
+
tooltip?: TooltipConfig<T>;
|
|
66
64
|
};
|
|
67
65
|
|
|
68
|
-
type
|
|
69
|
-
|
|
70
|
-
const Chart = <T,>({
|
|
71
|
-
data,
|
|
72
|
-
width,
|
|
73
|
-
height,
|
|
74
|
-
margin = defaultMargin,
|
|
75
|
-
xAccessor,
|
|
76
|
-
yAccessor,
|
|
77
|
-
zAccessor,
|
|
78
|
-
tooltipAccessor,
|
|
79
|
-
}: InnerHeatmapProps<T>) => {
|
|
80
|
-
const theme = useTheme();
|
|
66
|
+
export type HeatmapProps<T> = ChartProps & SharedHeatmapProps<T> & (CalendarModeProps<T> | CategoricalModeProps<T>);
|
|
81
67
|
|
|
82
|
-
|
|
83
|
-
detectBounds: true,
|
|
84
|
-
scroll: true,
|
|
85
|
-
});
|
|
68
|
+
const defaultMargin = { top: 30, right: 10, bottom: 10, left: 40 };
|
|
86
69
|
|
|
87
|
-
|
|
70
|
+
const getDefaultStartDate = () => {
|
|
71
|
+
const date = new Date();
|
|
72
|
+
date.setDate(date.getDate() - 364); // 52 weeks = 364 days
|
|
73
|
+
// Round to previous Sunday
|
|
74
|
+
date.setDate(date.getDate() - date.getDay());
|
|
75
|
+
return date;
|
|
76
|
+
};
|
|
88
77
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
const getDefaultEndDate = () => new Date();
|
|
79
|
+
|
|
80
|
+
const processCalendarData = <T,>(
|
|
81
|
+
data: T[],
|
|
82
|
+
dateAccessor: (d: T) => Date,
|
|
83
|
+
valueAccessor: (d: T) => number,
|
|
84
|
+
startDate: Date,
|
|
85
|
+
endDate: Date,
|
|
86
|
+
): ProcessedData<T> => {
|
|
87
|
+
const dateValueMap = new Map<string, number>();
|
|
88
|
+
const dateDataMap = new Map<string, T>();
|
|
89
|
+
data.forEach((d) => {
|
|
90
|
+
const date = dateAccessor(d);
|
|
91
|
+
const dateStr = date.toISOString().split('T')[0];
|
|
92
|
+
const value = valueAccessor(d);
|
|
93
|
+
dateValueMap.set(dateStr, (dateValueMap.get(dateStr) || 0) + value);
|
|
94
|
+
// Store the first data item for this date for tooltip purposes
|
|
95
|
+
if (!dateDataMap.has(dateStr)) {
|
|
96
|
+
dateDataMap.set(dateStr, d);
|
|
97
|
+
}
|
|
96
98
|
});
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
|
|
100
|
+
const cellsData: NormalizedCell<T>[] = [];
|
|
101
|
+
const monthLabelsData: { label: string; index: number }[] = [];
|
|
102
|
+
const currentDate = new Date(startDate);
|
|
103
|
+
let weekIndex = 0;
|
|
104
|
+
let lastMonth = -1;
|
|
105
|
+
let lastDayIndex = 0;
|
|
106
|
+
|
|
107
|
+
while (currentDate <= endDate) {
|
|
108
|
+
const dayIndex = currentDate.getDay(); // 0 (Sunday) to 6 (Saturday)
|
|
109
|
+
const dateStr = currentDate.toISOString().split('T')[0];
|
|
110
|
+
const value = dateValueMap.get(dateStr) || 0;
|
|
111
|
+
|
|
112
|
+
cellsData.push({
|
|
113
|
+
x: weekIndex,
|
|
114
|
+
y: dayIndex,
|
|
115
|
+
value,
|
|
116
|
+
displayValue: new Date(currentDate),
|
|
117
|
+
originalData: dateDataMap.get(dateStr),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Track month changes for labels
|
|
121
|
+
if (dayIndex === 0 && currentDate.getMonth() !== lastMonth) {
|
|
122
|
+
lastMonth = currentDate.getMonth();
|
|
123
|
+
if (weekIndex >= 2 || currentDate.getDate() <= 7) {
|
|
124
|
+
monthLabelsData.push({
|
|
125
|
+
label: MONTH_LABELS[lastMonth],
|
|
126
|
+
index: weekIndex,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
lastDayIndex = dayIndex;
|
|
132
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
133
|
+
|
|
134
|
+
// If we completed a week (Saturday), move to next week column
|
|
135
|
+
if (dayIndex === 6) {
|
|
136
|
+
weekIndex++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const maxValue = Math.max(...cellsData.map((c) => c.value), 1);
|
|
141
|
+
|
|
142
|
+
const dayLabels = DAY_LABELS.map((label, index) => ({ label, index })).filter((l) => l.label);
|
|
143
|
+
|
|
144
|
+
// Only add an extra column if the last day was not Saturday (incomplete week)
|
|
145
|
+
const gridWidth = lastDayIndex === 6 ? weekIndex : weekIndex + 1;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
cells: cellsData,
|
|
149
|
+
xLabels: monthLabelsData,
|
|
150
|
+
yLabels: dayLabels,
|
|
151
|
+
maxValue,
|
|
152
|
+
gridWidth,
|
|
153
|
+
gridHeight: 7,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const processCategoricalData = <T,>(
|
|
158
|
+
data: T[],
|
|
159
|
+
xAccessor: (d: T) => number | string,
|
|
160
|
+
yAccessor: (d: T) => number | string,
|
|
161
|
+
valueAccessor: (d: T) => number = (d: any) => d.value || 0,
|
|
162
|
+
xCategories?: string[],
|
|
163
|
+
yCategories?: string[],
|
|
164
|
+
): ProcessedData<T> => {
|
|
165
|
+
const rawXValues = Array.from(new Set(data.map(xAccessor)));
|
|
166
|
+
const rawYValues = Array.from(new Set(data.map(yAccessor)));
|
|
167
|
+
|
|
168
|
+
const useCustomXCategories = xCategories && xCategories.length > 0;
|
|
169
|
+
const useCustomYCategories = yCategories && yCategories.length > 0;
|
|
170
|
+
|
|
171
|
+
// Build value map and data map using RAW data values as keys
|
|
172
|
+
const valueMap = new Map<string, number>();
|
|
173
|
+
const dataMap = new Map<string, T>();
|
|
174
|
+
data.forEach((d) => {
|
|
175
|
+
const x = xAccessor(d);
|
|
176
|
+
const y = yAccessor(d);
|
|
177
|
+
const key = `${x}-${y}`;
|
|
178
|
+
const value = valueAccessor(d);
|
|
179
|
+
valueMap.set(key, (valueMap.get(key) || 0) + value);
|
|
180
|
+
if (!dataMap.has(key)) {
|
|
181
|
+
dataMap.set(key, d);
|
|
182
|
+
}
|
|
100
183
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
184
|
+
|
|
185
|
+
const xValueToLabel = useCustomXCategories
|
|
186
|
+
? new Map(rawXValues.map((val, _idx) => [val, xCategories![Number(val)] || String(val)]))
|
|
187
|
+
: new Map(rawXValues.map((val) => [val, String(val)]));
|
|
188
|
+
|
|
189
|
+
const yValueToLabel = useCustomYCategories
|
|
190
|
+
? new Map(rawYValues.map((val, _idx) => [val, yCategories![Number(val)] || String(val)]))
|
|
191
|
+
: new Map(rawYValues.map((val) => [val, String(val)]));
|
|
192
|
+
|
|
193
|
+
const cells: NormalizedCell<T>[] = [];
|
|
194
|
+
const sortedRawYValues = rawYValues.sort((a, b) => {
|
|
195
|
+
const aNum = Number(a);
|
|
196
|
+
const bNum = Number(b);
|
|
197
|
+
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
|
|
198
|
+
return String(a).localeCompare(String(b));
|
|
104
199
|
});
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
200
|
+
const sortedRawXValues = rawXValues.sort((a, b) => {
|
|
201
|
+
const aNum = Number(a);
|
|
202
|
+
const bNum = Number(b);
|
|
203
|
+
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
|
|
204
|
+
return String(a).localeCompare(String(b));
|
|
108
205
|
});
|
|
109
206
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
207
|
+
sortedRawYValues.forEach((yVal, yIdx) => {
|
|
208
|
+
sortedRawXValues.forEach((xVal, xIdx) => {
|
|
209
|
+
const key = `${xVal}-${yVal}`;
|
|
210
|
+
cells.push({
|
|
211
|
+
x: xIdx,
|
|
212
|
+
y: yIdx,
|
|
213
|
+
value: valueMap.get(key) || 0,
|
|
214
|
+
displayValue: { x: xVal, y: yVal },
|
|
215
|
+
xLabel: xValueToLabel.get(xVal) || String(xVal),
|
|
216
|
+
yLabel: yValueToLabel.get(yVal) || String(yVal),
|
|
217
|
+
originalData: dataMap.get(key),
|
|
218
|
+
});
|
|
219
|
+
});
|
|
113
220
|
});
|
|
114
221
|
|
|
115
|
-
const
|
|
222
|
+
const maxValue = Math.max(...cells.map((c) => c.value), 1);
|
|
116
223
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
key={`heatmap-rect-${bin.row}-${bin.column}`}
|
|
163
|
-
width={bin.width}
|
|
164
|
-
height={bin.height}
|
|
165
|
-
x={bin.x}
|
|
166
|
-
y={bin.y}
|
|
167
|
-
fill={bin.color}
|
|
168
|
-
fillOpacity={bin.opacity}
|
|
169
|
-
/>
|
|
170
|
-
);
|
|
171
|
-
}),
|
|
172
|
-
)
|
|
173
|
-
}
|
|
174
|
-
</HeatmapRect>
|
|
175
|
-
</Group>
|
|
176
|
-
</svg>
|
|
224
|
+
const xLabels = sortedRawXValues.map((val, index) => ({
|
|
225
|
+
label: xValueToLabel.get(val) || String(val),
|
|
226
|
+
index,
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const yLabels = sortedRawYValues.map((val, index) => ({
|
|
230
|
+
label: yValueToLabel.get(val) || String(val),
|
|
231
|
+
index,
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
cells,
|
|
236
|
+
xLabels,
|
|
237
|
+
yLabels,
|
|
238
|
+
maxValue,
|
|
239
|
+
gridWidth: sortedRawXValues.length,
|
|
240
|
+
gridHeight: sortedRawYValues.length,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const HeatMap = <T,>(props: HeatmapProps<T>) => {
|
|
245
|
+
const { data, name, colors, showDayLabels = true, margin = defaultMargin, animate = true, tooltip } = props;
|
|
246
|
+
const isCategorical = 'xAccessor' in props && props.xAccessor !== undefined;
|
|
247
|
+
const hasData = data && data.length > 0;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<ParentSize>
|
|
251
|
+
{hasData
|
|
252
|
+
? (parent) => (
|
|
253
|
+
<Chart<T>
|
|
254
|
+
{...props}
|
|
255
|
+
name={name}
|
|
256
|
+
data={data}
|
|
257
|
+
width={parent.width}
|
|
258
|
+
height={parent.height}
|
|
259
|
+
margin={margin}
|
|
260
|
+
tooltip={tooltip}
|
|
261
|
+
colors={colors}
|
|
262
|
+
showDayLabels={showDayLabels}
|
|
263
|
+
animate={animate}
|
|
264
|
+
isCategorical={isCategorical}
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
267
|
+
: () => <EmptyChart />}
|
|
268
|
+
</ParentSize>
|
|
177
269
|
);
|
|
178
270
|
};
|
|
179
271
|
|
|
180
|
-
|
|
181
|
-
// In our case, each bins will have a count of 1, since the data will already be aggregated
|
|
182
|
-
// example:
|
|
183
|
-
// [
|
|
184
|
-
// {
|
|
185
|
-
// bin: 0,
|
|
186
|
-
// bins: [
|
|
187
|
-
// { bin: 0, count: 0 },
|
|
188
|
-
// { bin: 4, count: 0 },
|
|
189
|
-
// ]
|
|
190
|
-
// },
|
|
191
|
-
// {
|
|
192
|
-
// bin: 1,
|
|
193
|
-
// bins: [
|
|
194
|
-
// { bin: 0, count: 1 },
|
|
195
|
-
// ]
|
|
196
|
-
// },
|
|
197
|
-
function transformData<T>(
|
|
198
|
-
data: T[],
|
|
199
|
-
xAccessor: (d: T) => number,
|
|
200
|
-
yAccessor: (d: T) => number,
|
|
201
|
-
zAccessor: (d: T) => number,
|
|
202
|
-
tooltipAccessor: (d: T) => string,
|
|
203
|
-
): OuterBin[] {
|
|
204
|
-
const map = new Map<number, Map<number, { count: number; tooltip: string }>>();
|
|
272
|
+
type InnerHeatmapProps<T> = InnerChartProps & HeatmapProps<T> & { isCategorical: boolean };
|
|
205
273
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
274
|
+
const Chart = <T,>(props: InnerHeatmapProps<T>) => {
|
|
275
|
+
const {
|
|
276
|
+
data,
|
|
277
|
+
width,
|
|
278
|
+
height,
|
|
279
|
+
margin = defaultMargin,
|
|
280
|
+
tooltip,
|
|
281
|
+
name,
|
|
282
|
+
colors,
|
|
283
|
+
showDayLabels = true,
|
|
284
|
+
animate = true,
|
|
285
|
+
isCategorical,
|
|
286
|
+
} = props;
|
|
287
|
+
|
|
288
|
+
const tooltipAccessor = tooltip?.accessor;
|
|
289
|
+
const theme = useTheme();
|
|
290
|
+
|
|
291
|
+
const _animate = animate;
|
|
292
|
+
const { hideTooltip, showTooltip, tooltipData, tooltipLeft = 0, tooltipTop = 0 } = useTooltip<NormalizedCell<T>>();
|
|
293
|
+
|
|
294
|
+
const defaultColors: [string, string, string, string, string] = [
|
|
295
|
+
`${theme.colors.backgroundAccent}33`,
|
|
296
|
+
`${theme.colors.primary}33`,
|
|
297
|
+
`${theme.colors.primary}66`,
|
|
298
|
+
`${theme.colors.primary}99`,
|
|
299
|
+
theme.colors.primary,
|
|
300
|
+
];
|
|
211
301
|
|
|
212
|
-
|
|
213
|
-
|
|
302
|
+
const colorScale = colors || defaultColors;
|
|
303
|
+
|
|
304
|
+
const processedData = useMemo(() => {
|
|
305
|
+
if (isCategorical) {
|
|
306
|
+
const catProps = props as InnerHeatmapProps<T> & CategoricalModeProps<T>;
|
|
307
|
+
return processCategoricalData(
|
|
308
|
+
data,
|
|
309
|
+
catProps.xAccessor,
|
|
310
|
+
catProps.yAccessor,
|
|
311
|
+
catProps.valueAccessor,
|
|
312
|
+
catProps.xCategories,
|
|
313
|
+
catProps.yCategories,
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
const calProps = props as InnerHeatmapProps<T> & CalendarModeProps<T>;
|
|
317
|
+
return processCalendarData(
|
|
318
|
+
data,
|
|
319
|
+
calProps.dateAccessor,
|
|
320
|
+
calProps.valueAccessor,
|
|
321
|
+
calProps.startDate || getDefaultStartDate(),
|
|
322
|
+
calProps.endDate || getDefaultEndDate(),
|
|
323
|
+
);
|
|
214
324
|
}
|
|
325
|
+
}, [data, isCategorical, props]);
|
|
215
326
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
327
|
+
const dayLabelWidth = showDayLabels ? (isCategorical ? 40 : 30) : 0;
|
|
328
|
+
const showXLabels = isCategorical || (props as any).showMonthLabels !== false;
|
|
329
|
+
const xLabelHeight = showXLabels ? 20 : 0;
|
|
219
330
|
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
bins: Array.from(innerBins, ([bin, { count, tooltip }]) => ({ bin, count, tooltip })),
|
|
224
|
-
}));
|
|
331
|
+
// Calculate dynamic cell size based on available space
|
|
332
|
+
const availableWidth = width - margin.left - margin.right - dayLabelWidth;
|
|
333
|
+
const availableHeight = height - margin.top - margin.bottom - xLabelHeight;
|
|
225
334
|
|
|
226
|
-
|
|
227
|
-
|
|
335
|
+
const cellGap = 3;
|
|
336
|
+
const maxCellWidth = Math.max(
|
|
337
|
+
(availableWidth - cellGap * (processedData.gridWidth - 1)) / processedData.gridWidth,
|
|
338
|
+
0,
|
|
339
|
+
);
|
|
340
|
+
const maxCellHeight = Math.max(
|
|
341
|
+
(availableHeight - cellGap * (processedData.gridHeight - 1)) / processedData.gridHeight,
|
|
342
|
+
0,
|
|
343
|
+
);
|
|
344
|
+
const cellSize = Math.max(Math.min(maxCellWidth, maxCellHeight, 20), 1);
|
|
345
|
+
|
|
346
|
+
const mapValueToColor = (value: number): string => {
|
|
347
|
+
if (value === 0) return colorScale[0];
|
|
348
|
+
const level = Math.min(4, Math.ceil((value / processedData.maxValue) * 4));
|
|
349
|
+
return colorScale[level];
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const formatDate = (date: Date): string => {
|
|
353
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
354
|
+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
355
|
+
return `${days[date.getDay()]}, ${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handleMouseOver = useCallback(
|
|
359
|
+
(event: React.MouseEvent<SVGRectElement>, cell: NormalizedCell<T>) => {
|
|
360
|
+
const coords = localPoint(event) || { x: 0, y: 0 };
|
|
361
|
+
showTooltip({
|
|
362
|
+
tooltipData: cell,
|
|
363
|
+
tooltipLeft: coords.x,
|
|
364
|
+
tooltipTop: coords.y,
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
[showTooltip],
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
return width < 10 ? null : (
|
|
371
|
+
<>
|
|
372
|
+
<svg width={width} height={height}>
|
|
373
|
+
<Group top={margin.top + xLabelHeight} left={margin.left + dayLabelWidth}>
|
|
374
|
+
{/* X-axis labels (month labels for calendar, category labels for categorical) */}
|
|
375
|
+
{showXLabels &&
|
|
376
|
+
processedData.xLabels
|
|
377
|
+
.filter((label) => {
|
|
378
|
+
// For categorical mode with many labels, show every nth label to prevent overlap
|
|
379
|
+
if (isCategorical && processedData.xLabels.length > 12) {
|
|
380
|
+
// Show every 3rd label for better spacing
|
|
381
|
+
return label.index % 3 === 0;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
})
|
|
385
|
+
.map((label) => (
|
|
386
|
+
<text
|
|
387
|
+
key={`x-${label.index}`}
|
|
388
|
+
x={label.index * (cellSize + cellGap) + (isCategorical ? cellSize / 2 : 0)}
|
|
389
|
+
y={-8}
|
|
390
|
+
fill={theme.colors.text}
|
|
391
|
+
fontSize={theme.fontSize.tiny}
|
|
392
|
+
fontWeight={500}
|
|
393
|
+
textAnchor={isCategorical ? 'middle' : 'start'}
|
|
394
|
+
>
|
|
395
|
+
{label.label}
|
|
396
|
+
</text>
|
|
397
|
+
))}
|
|
398
|
+
|
|
399
|
+
{showDayLabels &&
|
|
400
|
+
processedData.yLabels.map((label) => (
|
|
401
|
+
<text
|
|
402
|
+
key={`y-${label.index}`}
|
|
403
|
+
x={-8}
|
|
404
|
+
y={label.index * (cellSize + cellGap) + cellSize / 2}
|
|
405
|
+
fill={theme.colors.textAlt}
|
|
406
|
+
fontSize={theme.fontSize.tiny}
|
|
407
|
+
textAnchor="end"
|
|
408
|
+
alignmentBaseline="middle"
|
|
409
|
+
>
|
|
410
|
+
{label.label}
|
|
411
|
+
</text>
|
|
412
|
+
))}
|
|
413
|
+
|
|
414
|
+
{processedData.cells.map((cell, i) => (
|
|
415
|
+
<rect
|
|
416
|
+
key={`cell-${i}`}
|
|
417
|
+
x={cell.x * (cellSize + cellGap)}
|
|
418
|
+
y={cell.y * (cellSize + cellGap)}
|
|
419
|
+
width={cellSize}
|
|
420
|
+
height={cellSize}
|
|
421
|
+
rx={2}
|
|
422
|
+
fill={mapValueToColor(cell.value)}
|
|
423
|
+
stroke={theme.colors.backgroundAlt}
|
|
424
|
+
strokeWidth={0.5}
|
|
425
|
+
onMouseOver={(e) => handleMouseOver(e, cell)}
|
|
426
|
+
onMouseLeave={hideTooltip}
|
|
427
|
+
style={{
|
|
428
|
+
cursor: 'pointer',
|
|
429
|
+
transition: 'all 0.1s ease',
|
|
430
|
+
}}
|
|
431
|
+
onMouseEnter={(e) => {
|
|
432
|
+
e.currentTarget.style.stroke = theme.colors.text;
|
|
433
|
+
e.currentTarget.style.strokeWidth = '1.5';
|
|
434
|
+
}}
|
|
435
|
+
onMouseOut={(e) => {
|
|
436
|
+
e.currentTarget.style.stroke = theme.colors.backgroundAlt;
|
|
437
|
+
e.currentTarget.style.strokeWidth = '0.5';
|
|
438
|
+
}}
|
|
439
|
+
/>
|
|
440
|
+
))}
|
|
441
|
+
</Group>
|
|
442
|
+
</svg>
|
|
443
|
+
{tooltipData && (
|
|
444
|
+
<TooltipWithBounds key={name} top={tooltipTop} left={tooltipLeft} style={getDefaultTooltipStyles(theme)}>
|
|
445
|
+
{tooltipAccessor && tooltipData.originalData ? (
|
|
446
|
+
tooltipAccessor(tooltipData.originalData, tooltipData.displayValue, tooltipData.value)
|
|
447
|
+
) : isCategorical ? (
|
|
448
|
+
<>
|
|
449
|
+
{tooltipData.xLabel} - {tooltipData.yLabel}: {tooltipData.value}
|
|
450
|
+
</>
|
|
451
|
+
) : (
|
|
452
|
+
<>
|
|
453
|
+
<strong>{tooltipData.value}</strong> contribution{tooltipData.value !== 1 ? 's' : ''} on{' '}
|
|
454
|
+
{formatDate(tooltipData.displayValue as Date)}
|
|
455
|
+
</>
|
|
456
|
+
)}
|
|
457
|
+
</TooltipWithBounds>
|
|
458
|
+
)}
|
|
459
|
+
</>
|
|
460
|
+
);
|
|
461
|
+
};
|