@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.
Files changed (58) hide show
  1. package/package.json +13 -6
  2. package/src/components/actions/Button/__snapshots__/Button.test.tsx.snap +1 -1
  3. package/src/components/actions/IconButton/__snapshots__/IconButton.test.tsx.snap +1 -1
  4. package/src/components/charts/AreaChart/AreaChart.stories.tsx +11 -7
  5. package/src/components/charts/AreaChart/index.tsx +114 -63
  6. package/src/components/charts/BarChart/BarChart.stories.tsx +33 -10
  7. package/src/components/charts/BarChart/index.tsx +280 -147
  8. package/src/components/charts/EmptyChart.tsx +45 -0
  9. package/src/components/charts/GeoMercator/GeoMercator.stories.tsx +15 -9
  10. package/src/components/charts/GeoMercator/index.tsx +15 -172
  11. package/src/components/charts/Heatmap/Heatmap.stories.tsx +167 -33
  12. package/src/components/charts/Heatmap/index.tsx +427 -193
  13. package/src/components/charts/LineChart/LineChart.stories.tsx +77 -3
  14. package/src/components/charts/LineChart/index.tsx +200 -79
  15. package/src/components/charts/PieChart/PieChart.stories.tsx +128 -20
  16. package/src/components/charts/PieChart/index.tsx +353 -59
  17. package/src/components/charts/PointHighlight.tsx +2 -2
  18. package/src/components/charts/RadarChart/RadarChart.stories.tsx +14 -5
  19. package/src/components/charts/RadarChart/index.tsx +94 -45
  20. package/src/components/charts/RadialBarChart/RadialBarChart.stories.tsx +26 -1
  21. package/src/components/charts/RadialBarChart/index.tsx +100 -34
  22. package/src/components/charts/RadialLineChart/RadialLineChart.stories.tsx +19 -2
  23. package/src/components/charts/RadialLineChart/index.tsx +116 -26
  24. package/src/components/charts/index.tsx +0 -26
  25. package/src/components/charts/util.ts +50 -12
  26. package/src/components/data/CountryList/index.tsx +146 -0
  27. package/src/components/data/Stats/Sparkline.tsx +48 -0
  28. package/src/components/data/Stats/Stat.tsx +15 -4
  29. package/src/components/data/Stats/context.tsx +1 -1
  30. package/src/components/data/Stats/index.tsx +8 -3
  31. package/src/components/data/index.ts +3 -0
  32. package/src/components/feedback/IconTooltip/index.tsx +9 -6
  33. package/src/components/feedback/ProgressBar/ProgressBar.stories.tsx +13 -14
  34. package/src/components/feedback/ProgressBar/index.tsx +1 -1
  35. package/src/components/inputs/DurationField/__tests__/Generic.test.tsx +12 -0
  36. package/src/components/visual/Card/CardTitle.tsx +7 -1
  37. package/src/components/visual/Card/index.tsx +0 -4
  38. package/src/helpers/formatNumber.ts +6 -0
  39. package/src/helpers/index.ts +1 -0
  40. package/vite.config.mts +4 -0
  41. package/src/components/charts/echarts/EChartsArea.stories.tsx +0 -139
  42. package/src/components/charts/echarts/EChartsArea.tsx +0 -139
  43. package/src/components/charts/echarts/EChartsBar.stories.tsx +0 -141
  44. package/src/components/charts/echarts/EChartsBar.tsx +0 -133
  45. package/src/components/charts/echarts/EChartsBase.tsx +0 -264
  46. package/src/components/charts/echarts/EChartsFunnel.stories.tsx +0 -164
  47. package/src/components/charts/echarts/EChartsFunnel.tsx +0 -114
  48. package/src/components/charts/echarts/EChartsHeatmap.stories.tsx +0 -168
  49. package/src/components/charts/echarts/EChartsHeatmap.tsx +0 -141
  50. package/src/components/charts/echarts/EChartsLine.stories.tsx +0 -132
  51. package/src/components/charts/echarts/EChartsLine.tsx +0 -111
  52. package/src/components/charts/echarts/EChartsPie.stories.tsx +0 -131
  53. package/src/components/charts/echarts/EChartsPie.tsx +0 -124
  54. package/src/components/charts/echarts/EChartsRadialBar.stories.tsx +0 -124
  55. package/src/components/charts/echarts/EChartsRadialBar.tsx +0 -118
  56. package/src/components/charts/echarts/EChartsScatter.stories.tsx +0 -166
  57. package/src/components/charts/echarts/EChartsScatter.tsx +0 -135
  58. 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 { AxisTop, AxisLeft } from '@visx/axis';
4
- import { HeatmapRect } from '@visx/heatmap';
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, Margin } from '../util';
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
- interface InnerBin {
12
- bin: number;
13
- count: number;
14
- tooltip: string;
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 OuterBin {
18
- bin: number;
19
- bins: InnerBin[];
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
- const DATE_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
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
- export interface HeatmapProps<T> {
25
- name: string;
47
+ type CategoricalModeProps<T> = {
26
48
  data: T[];
27
- variant?: 'month';
28
- margin?: Margin;
29
- xAccessor: (d: T) => number;
30
- yAccessor: (d: T) => number;
31
- zAccessor: (d: T) => number;
32
- tooltipAccessor: (d: T) => string;
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
- const defaultMargin = { top: 10, right: 0, bottom: 25, left: 40 };
36
- export const HeatMap = <T,>({
37
- data,
38
- yAccessor,
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 InnerHeatmapProps<T> = InnerChartProps & HeatmapProps<T>;
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
- const { containerRef } = useTooltipInPortal({
83
- detectBounds: true,
84
- scroll: true,
85
- });
68
+ const defaultMargin = { top: 30, right: 10, bottom: 10, left: 40 };
86
69
 
87
- const colorMax = Math.max(...data.map(yAccessor));
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
- const xScale = scaleBand<number>({
90
- domain: [0, 1, 2, 3, 4], // 5 weeks in a month
91
- range: [margin.left, width - margin.right],
92
- });
93
- const yScale = scaleBand<number>({
94
- domain: [0, 1, 2, 3, 4, 5, 6], // 7 days of the week
95
- range: [margin.top, height - margin.bottom],
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
- const colorScale = scaleLinear<string>({
98
- domain: [0, colorMax],
99
- range: [theme.colors.primary, theme.colors.primary],
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
- const opacityScale = scaleLinear<number>({
102
- domain: [0, colorMax],
103
- range: [0.1, 1],
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 xAxisScale = scaleBand<number>({
106
- domain: Array.from({ length: 5 }), // Adjust as needed for weeks in the month
107
- range: [0, width - margin.left - margin.right],
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
- const yAxisScale = scaleBand<string>({
111
- domain: DATE_LABELS,
112
- range: [0, height - margin.top - margin.bottom],
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 transformedData = transformData(data, xAccessor, yAccessor, zAccessor, tooltipAccessor);
222
+ const maxValue = Math.max(...cells.map((c) => c.value), 1);
116
223
 
117
- return width < 10 ? null : (
118
- <svg ref={containerRef} width={width} height={height}>
119
- <AxisTop
120
- scale={xAxisScale}
121
- top={margin.top}
122
- left={margin.left}
123
- tickLength={4}
124
- tickStroke={theme.colors.backgroundAccent}
125
- stroke={theme.colors.backgroundAccent}
126
- tickLabelProps={() => ({
127
- y: -7,
128
- textAnchor: 'middle',
129
- })}
130
- />
131
- <AxisLeft
132
- scale={yAxisScale}
133
- left={margin.left}
134
- orientation="left"
135
- hideTicks
136
- stroke={theme.colors.backgroundAccent}
137
- tickLabelProps={(_value, index) => ({
138
- textAnchor: 'end',
139
- fontSize: theme.fontSize.tiny,
140
- fill: index % 2 === 1 ? theme.colors.text : 'none', // Show label for even indices,
141
- dy: '1.25rem',
142
- })}
143
- />
144
- <Group>
145
- <HeatmapRect<OuterBin, InnerBin>
146
- data={transformedData}
147
- xScale={(d) => xScale(d) ?? 0}
148
- yScale={(bin) => yScale(bin) ?? 0}
149
- count={(bin) => bin.count}
150
- bins={(bin) => bin.bins}
151
- colorScale={colorScale}
152
- opacityScale={opacityScale}
153
- binWidth={xScale.bandwidth()}
154
- binHeight={yScale.bandwidth()}
155
- gap={1}
156
- >
157
- {(heatmap) =>
158
- heatmap.map((heatmapBins) =>
159
- heatmapBins.map((bin) => {
160
- return (
161
- <rect
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
- // transforms data to the structure expected by HeatmapRect
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
- data.forEach((d) => {
207
- const xValue = xAccessor(d);
208
- const yValue = yAccessor(d);
209
- const zValue = zAccessor(d);
210
- const tooltipValue = tooltipAccessor(d);
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
- if (!map.has(xValue)) {
213
- map.set(xValue, new Map<number, { count: number; tooltip: string }>());
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
- const innerMap = map.get(xValue)!;
217
- innerMap.set(yValue, { count: zValue, tooltip: tooltipValue });
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
- // Convert to the structure expected by HeatmapRect
221
- const transformedData: OuterBin[] = Array.from(map, ([bin, innerBins]) => ({
222
- bin,
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
- return transformedData;
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
+ };