panelui-native 0.13.0 → 0.14.0
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/README.md +39 -23
- package/lib/module/components/button/index.js +12 -9
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/chip/index.js +313 -0
- package/lib/module/components/chip/index.js.map +1 -0
- package/lib/module/components/frame/index.js +41 -39
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +772 -0
- package/lib/module/components/heatmap-chart/index.js.map +1 -0
- package/lib/module/components/input/index.js +4 -0
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/scroll-canvas/index.js +175 -0
- package/lib/module/components/scroll-canvas/index.js.map +1 -0
- package/lib/module/components/scroll-text/index.js +213 -0
- package/lib/module/components/scroll-text/index.js.map +1 -0
- package/lib/module/components/select/index.js +20 -24
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +2 -5
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/switch/index.js +15 -17
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/thinking-orb/index.js +741 -0
- package/lib/module/components/thinking-orb/index.js.map +1 -0
- package/lib/module/components/toggle-button/index.js +12 -3
- package/lib/module/components/toggle-button/index.js.map +1 -1
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +99 -59
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-reveal-progress.js +75 -0
- package/lib/module/hooks/use-reveal-progress.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/primitives/keyboard-avoider.js +21 -2
- package/lib/module/primitives/keyboard-avoider.js.map +1 -1
- package/lib/module/primitives/scroll-progress.js +114 -0
- package/lib/module/primitives/scroll-progress.js.map +1 -0
- package/lib/typescript/src/components/button/index.d.ts +3 -4
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/chip/index.d.ts +304 -0
- package/lib/typescript/src/components/chip/index.d.ts.map +1 -0
- package/lib/typescript/src/components/frame/index.d.ts +15 -13
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts +260 -0
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/input/index.d.ts +19 -4
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scroll-canvas/index.d.ts +66 -0
- package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-text/index.d.ts +46 -0
- package/lib/typescript/src/components/scroll-text/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/thinking-orb/index.d.ts +28 -0
- package/lib/typescript/src/components/thinking-orb/index.d.ts.map +1 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts +12 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +2 -1
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +16 -3
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts +56 -0
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +15 -7
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +30 -3
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
- package/lib/typescript/src/primitives/scroll-progress.d.ts +61 -0
- package/lib/typescript/src/primitives/scroll-progress.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/button/index.tsx +13 -11
- package/src/components/frame/index.tsx +39 -50
- package/src/components/heatmap-chart/index.tsx +1024 -0
- package/src/components/input/index.tsx +23 -4
- package/src/components/scroll-canvas/index.tsx +220 -0
- package/src/components/scroll-text/index.tsx +277 -0
- package/src/components/select/index.tsx +4 -9
- package/src/components/slider/index.tsx +4 -7
- package/src/components/switch/index.tsx +5 -8
- package/src/components/thinking-orb/index.tsx +792 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/use-keyboard-avoidance.ts +111 -65
- package/src/hooks/use-reveal-progress.ts +106 -0
- package/src/index.ts +38 -0
- package/src/native/index.ts +15 -7
- package/src/primitives/keyboard-avoider.tsx +41 -4
- package/src/primitives/scroll-progress.tsx +167 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeatmapChart — a calendar of bins, shaded by how much happened in each.
|
|
3
|
+
*
|
|
4
|
+
* The contribution grid: one column per period (usually a week), one row per
|
|
5
|
+
* bin inside it (usually a weekday), and a colour ramp carrying the count. It
|
|
6
|
+
* answers "when was this busy" at a glance, which no line can — a year of daily
|
|
7
|
+
* numbers plotted as a series is a hairball, and as a grid it is a pattern.
|
|
8
|
+
*
|
|
9
|
+
* Composed rather than configured, so a chart that wants no axis simply does
|
|
10
|
+
* not have one:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <HeatmapChart data={weeks}>
|
|
14
|
+
* <HeatmapChart.YAxis />
|
|
15
|
+
* <HeatmapChart.XAxis />
|
|
16
|
+
* <HeatmapChart.Cells />
|
|
17
|
+
* <HeatmapChart.Tooltip />
|
|
18
|
+
* <HeatmapChart.Legend />
|
|
19
|
+
* </HeatmapChart>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The parts sort themselves into a real layout rather than stacking over the
|
|
23
|
+
* plot. That is the difference from a line chart, where the axis floats over
|
|
24
|
+
* the drawing: here the labels and the legend sit *beside* and *below* the
|
|
25
|
+
* grid, so they take up room, and the grid is sized with them accounted for.
|
|
26
|
+
* Only the cells and the rules between them are SVG; every label is a React
|
|
27
|
+
* Native view, because SVG text ignores the platform's text scaling and the
|
|
28
|
+
* theme's font.
|
|
29
|
+
*
|
|
30
|
+
* The ramp is one colour at five opacities rather than five colours. A heatmap
|
|
31
|
+
* reads as *more* and *less* of one thing, and five distinct hues read as five
|
|
32
|
+
* different things — which is what the `--color-chart-*` tokens are for, and
|
|
33
|
+
* why they are not used here. The base is `--color-chart-1`, so the ramp
|
|
34
|
+
* follows the theme, and `levelColors` replaces it outright when a brand needs
|
|
35
|
+
* its own.
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
Children,
|
|
39
|
+
createContext,
|
|
40
|
+
forwardRef,
|
|
41
|
+
isValidElement,
|
|
42
|
+
useContext,
|
|
43
|
+
useEffect,
|
|
44
|
+
useMemo,
|
|
45
|
+
useRef,
|
|
46
|
+
useState,
|
|
47
|
+
type ReactNode,
|
|
48
|
+
} from 'react';
|
|
49
|
+
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
50
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
51
|
+
import Animated, {
|
|
52
|
+
Easing,
|
|
53
|
+
runOnJS,
|
|
54
|
+
useAnimatedProps,
|
|
55
|
+
useReducedMotion,
|
|
56
|
+
useSharedValue,
|
|
57
|
+
withTiming,
|
|
58
|
+
} from 'react-native-reanimated';
|
|
59
|
+
import Svg, { ClipPath, Defs, G, Line as SvgLine, Rect } from 'react-native-svg';
|
|
60
|
+
import { useCSSVariable } from 'uniwind';
|
|
61
|
+
import { Text } from '../../primitives/text';
|
|
62
|
+
import { cn } from '../../utils/cn';
|
|
63
|
+
|
|
64
|
+
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rows in a week — the default, and what the calendar helper and the weekday
|
|
68
|
+
* labels assume. The grid itself takes a `rows` prop, so an hourly band or a
|
|
69
|
+
* single-row uptime strip is the same component with a different number.
|
|
70
|
+
*/
|
|
71
|
+
const DAYS_IN_WEEK = 7;
|
|
72
|
+
|
|
73
|
+
/** Opacity of the base colour at each activity level. Index 0 is "nothing". */
|
|
74
|
+
const LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1] as const;
|
|
75
|
+
|
|
76
|
+
/** Room left for the weekday labels when the y-axis does not ask for its own. */
|
|
77
|
+
const DEFAULT_AXIS_WIDTH = 26;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Height assumed for the readout when placing it above a cell. It is a single
|
|
81
|
+
* line of `xs` text in a padded box, so it does not vary — and measuring it
|
|
82
|
+
* would put the tooltip a frame behind the finger.
|
|
83
|
+
*/
|
|
84
|
+
const TOOLTIP_HEIGHT = 26;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How long the readout waits before claiming the touch, in milliseconds.
|
|
88
|
+
*
|
|
89
|
+
* Long enough that a swipe scrolls the chart instead of reading it, short
|
|
90
|
+
* enough that a deliberate press does not feel like it was ignored.
|
|
91
|
+
*/
|
|
92
|
+
const DEFAULT_HOLD = 180;
|
|
93
|
+
|
|
94
|
+
/** Weekday names, indexed from Sunday, as the `Date` API numbers them. */
|
|
95
|
+
const WEEKDAYS = [
|
|
96
|
+
'Sunday',
|
|
97
|
+
'Monday',
|
|
98
|
+
'Tuesday',
|
|
99
|
+
'Wednesday',
|
|
100
|
+
'Thursday',
|
|
101
|
+
'Friday',
|
|
102
|
+
'Saturday',
|
|
103
|
+
] as const;
|
|
104
|
+
|
|
105
|
+
const MONTHS = [
|
|
106
|
+
'Jan',
|
|
107
|
+
'Feb',
|
|
108
|
+
'Mar',
|
|
109
|
+
'Apr',
|
|
110
|
+
'May',
|
|
111
|
+
'Jun',
|
|
112
|
+
'Jul',
|
|
113
|
+
'Aug',
|
|
114
|
+
'Sep',
|
|
115
|
+
'Oct',
|
|
116
|
+
'Nov',
|
|
117
|
+
'Dec',
|
|
118
|
+
] as const;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where a part belongs in the layout. Read off the component itself, so
|
|
122
|
+
* composition stays a flat list of children instead of four nested slots the
|
|
123
|
+
* caller has to remember the order of.
|
|
124
|
+
*/
|
|
125
|
+
type Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend';
|
|
126
|
+
|
|
127
|
+
export type HeatmapLayout = 'fluid' | 'fill';
|
|
128
|
+
|
|
129
|
+
/** One bin inside a column — usually a single day. */
|
|
130
|
+
export interface HeatmapBin {
|
|
131
|
+
/** Row index within the column, `0` to `6`. */
|
|
132
|
+
bin: number;
|
|
133
|
+
/** How much happened. The ramp is derived from these across the whole chart. */
|
|
134
|
+
count: number;
|
|
135
|
+
/** The day this bin stands for. Used by the axis labels and the tooltip. */
|
|
136
|
+
date?: Date;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One column — usually a week. Missing bins are drawn as empty cells. */
|
|
140
|
+
export interface HeatmapColumn {
|
|
141
|
+
/** Column index across the chart. */
|
|
142
|
+
bin: number;
|
|
143
|
+
bins: HeatmapBin[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A cell resolved to its place in the grid, as the tooltip receives it. */
|
|
147
|
+
export interface HeatmapCell {
|
|
148
|
+
column: number;
|
|
149
|
+
row: number;
|
|
150
|
+
count: number;
|
|
151
|
+
level: number;
|
|
152
|
+
date?: Date;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface Grid {
|
|
156
|
+
/** Side of one cell, in pixels. */
|
|
157
|
+
size: number;
|
|
158
|
+
gap: number;
|
|
159
|
+
columns: number;
|
|
160
|
+
rows: number;
|
|
161
|
+
width: number;
|
|
162
|
+
height: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface HeatmapContextValue {
|
|
166
|
+
data: HeatmapColumn[];
|
|
167
|
+
grid: Grid;
|
|
168
|
+
/** `[level0, level1, …level4]`, already resolved to colours. */
|
|
169
|
+
ramp: string[];
|
|
170
|
+
/** Opacity to paint each level's colour at. All ones for a supplied ramp. */
|
|
171
|
+
opacities: number[];
|
|
172
|
+
levelOf: (count: number) => number;
|
|
173
|
+
cellAt: (column: number, row: number) => HeatmapCell;
|
|
174
|
+
cornerRadius: number;
|
|
175
|
+
inactiveOpacity: number;
|
|
176
|
+
weekStartDay: number;
|
|
177
|
+
clipId: string;
|
|
178
|
+
activeCell: HeatmapCell | null;
|
|
179
|
+
setActiveCell: (cell: HeatmapCell | null) => void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const HeatmapContext = createContext<HeatmapContextValue | null>(null);
|
|
183
|
+
|
|
184
|
+
function useHeatmap(component: string): HeatmapContextValue {
|
|
185
|
+
const context = useContext(HeatmapContext);
|
|
186
|
+
if (!context) {
|
|
187
|
+
throw new Error(`${component} must be used within a <HeatmapChart>`);
|
|
188
|
+
}
|
|
189
|
+
return context;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The cell under the finger, for something rendered *inside* the chart. A
|
|
194
|
+
* readout in the card's header is outside this provider — use
|
|
195
|
+
* `onActiveCellChange` for that.
|
|
196
|
+
*/
|
|
197
|
+
export function useHeatmapChart() {
|
|
198
|
+
const { activeCell } = useHeatmap('useHeatmapChart');
|
|
199
|
+
return { activeCell };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* -------------------------------------------------------------------------- */
|
|
203
|
+
/* Data helpers */
|
|
204
|
+
/* -------------------------------------------------------------------------- */
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build a year of columns from a flat list of dated counts.
|
|
208
|
+
*
|
|
209
|
+
* Every heatmap starts as "I have some dates and some numbers", and the
|
|
210
|
+
* bucketing into weeks is the same arithmetic every time — including the two
|
|
211
|
+
* parts that are easy to get wrong: the leading blanks before the first day of
|
|
212
|
+
* the first week, and days with no entry at all, which must still be drawn as
|
|
213
|
+
* empty cells or the calendar develops holes.
|
|
214
|
+
*/
|
|
215
|
+
export function buildHeatmapCalendar(
|
|
216
|
+
entries: { date: Date; count: number }[],
|
|
217
|
+
options: { start?: Date; end?: Date; weekStartDay?: number } = {}
|
|
218
|
+
): HeatmapColumn[] {
|
|
219
|
+
const weekStartDay = options.weekStartDay ?? 0;
|
|
220
|
+
if (!entries.length && !options.start) return [];
|
|
221
|
+
|
|
222
|
+
const times = entries.map((entry) => entry.date.getTime());
|
|
223
|
+
const start = options.start ?? new Date(Math.min(...times));
|
|
224
|
+
const end = options.end ?? new Date(Math.max(...times));
|
|
225
|
+
|
|
226
|
+
const byDay = new Map<string, number>();
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const key = dayKey(entry.date);
|
|
229
|
+
byDay.set(key, (byDay.get(key) ?? 0) + entry.count);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Back up to the first day of the week the range starts in, so column 0 is a
|
|
233
|
+
// whole week and every row lines up with a weekday for the rest of the chart.
|
|
234
|
+
const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
|
235
|
+
cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));
|
|
236
|
+
|
|
237
|
+
const columns: HeatmapColumn[] = [];
|
|
238
|
+
let column: HeatmapBin[] = [];
|
|
239
|
+
|
|
240
|
+
while (cursor <= end || column.length) {
|
|
241
|
+
const row = (cursor.getDay() - weekStartDay + 7) % 7;
|
|
242
|
+
const date = new Date(cursor);
|
|
243
|
+
// Before the range starts and after it ends the cell exists but has no
|
|
244
|
+
// reading — `count: 0` and no date, so the tooltip stays quiet on it.
|
|
245
|
+
const inRange = date >= startOfDay(start) && date <= end;
|
|
246
|
+
column.push({
|
|
247
|
+
bin: row,
|
|
248
|
+
count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,
|
|
249
|
+
date: inRange ? date : undefined,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (row === DAYS_IN_WEEK - 1) {
|
|
253
|
+
columns.push({ bin: columns.length, bins: column });
|
|
254
|
+
column = [];
|
|
255
|
+
if (cursor > end) break;
|
|
256
|
+
}
|
|
257
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return columns;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function startOfDay(date: Date) {
|
|
264
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function dayKey(date: Date) {
|
|
268
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Four thresholds from the data, so a chart of single-digit counts and a chart
|
|
273
|
+
* of thousands both use the whole ramp. Quartiles of the *non-zero* counts:
|
|
274
|
+
* zero is its own level, and counting it would drag every threshold down to
|
|
275
|
+
* nothing on a sparse chart.
|
|
276
|
+
*/
|
|
277
|
+
function deriveLevels(data: HeatmapColumn[]): number[] {
|
|
278
|
+
const counts: number[] = [];
|
|
279
|
+
for (const column of data) {
|
|
280
|
+
for (const bin of column.bins) {
|
|
281
|
+
if (bin.count > 0) counts.push(bin.count);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!counts.length) return [1, 2, 3, 4];
|
|
285
|
+
counts.sort((a, b) => a - b);
|
|
286
|
+
const at = (fraction: number) =>
|
|
287
|
+
counts[Math.min(counts.length - 1, Math.floor(counts.length * fraction))]!;
|
|
288
|
+
const thresholds = [1, at(0.25), at(0.5), at(0.75)];
|
|
289
|
+
// Ties collapse the ramp — nudge each threshold past the one below it so
|
|
290
|
+
// four distinct levels stay four distinct levels.
|
|
291
|
+
for (let i = 1; i < thresholds.length; i++) {
|
|
292
|
+
if (thresholds[i]! <= thresholds[i - 1]!) thresholds[i] = thresholds[i - 1]! + 1;
|
|
293
|
+
}
|
|
294
|
+
return thresholds;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* -------------------------------------------------------------------------- */
|
|
298
|
+
/* Root */
|
|
299
|
+
/* -------------------------------------------------------------------------- */
|
|
300
|
+
|
|
301
|
+
export interface HeatmapChartProps extends ViewProps {
|
|
302
|
+
className?: string;
|
|
303
|
+
/** One column per period, with its row bins inside. */
|
|
304
|
+
data: HeatmapColumn[];
|
|
305
|
+
/**
|
|
306
|
+
* `fluid` draws cells at `binSize` and lets the grid be as wide as it needs
|
|
307
|
+
* to be — put it in a horizontal `ScrollView` for a full year. `fill`
|
|
308
|
+
* divides the available width between the columns instead.
|
|
309
|
+
*/
|
|
310
|
+
layout?: HeatmapLayout;
|
|
311
|
+
/** Side of one cell in `fluid` layout, in pixels. */
|
|
312
|
+
binSize?: number;
|
|
313
|
+
/** Space between cells, in pixels. */
|
|
314
|
+
gap?: number;
|
|
315
|
+
/** Corner radius of a cell. */
|
|
316
|
+
cornerRadius?: number;
|
|
317
|
+
/** Which weekday is the top row. `0` is Sunday. Labels follow it. */
|
|
318
|
+
weekStartDay?: number;
|
|
319
|
+
/**
|
|
320
|
+
* Rows per column. Seven for a calendar; use another number for a band that
|
|
321
|
+
* is not a week — twenty-four for hours, one for an uptime strip.
|
|
322
|
+
*/
|
|
323
|
+
rows?: number;
|
|
324
|
+
/**
|
|
325
|
+
* The four counts at which the ramp steps up. Derived from the data's own
|
|
326
|
+
* quartiles when omitted, so a chart of single digits and a chart of
|
|
327
|
+
* thousands both use the whole ramp.
|
|
328
|
+
*/
|
|
329
|
+
levels?: number[];
|
|
330
|
+
/**
|
|
331
|
+
* Five colours — empty, then the four activity levels. Replaces the derived
|
|
332
|
+
* ramp outright. Omit it and the ramp is `--color-chart-1` at five
|
|
333
|
+
* opacities, which follows the theme.
|
|
334
|
+
*/
|
|
335
|
+
levelColors?: string[];
|
|
336
|
+
/** Base colour for the derived ramp. Defaults to the `--color-chart-1` token. */
|
|
337
|
+
color?: string;
|
|
338
|
+
/** Milliseconds for the reveal on mount. */
|
|
339
|
+
animationDuration?: number;
|
|
340
|
+
/** Opacity of every cell that is not the one under the finger. */
|
|
341
|
+
inactiveOpacity?: number;
|
|
342
|
+
/**
|
|
343
|
+
* The cell under the finger as it moves, and `null` when it lifts. This is
|
|
344
|
+
* how a readout above the chart gets its value — that readout is outside the
|
|
345
|
+
* chart, so it cannot use `useHeatmapChart`.
|
|
346
|
+
*/
|
|
347
|
+
onActiveCellChange?: (cell: HeatmapCell | null) => void;
|
|
348
|
+
children?: ReactNode;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapChartRoot(
|
|
352
|
+
{
|
|
353
|
+
className,
|
|
354
|
+
data,
|
|
355
|
+
layout = 'fluid',
|
|
356
|
+
binSize = 12,
|
|
357
|
+
gap = 3,
|
|
358
|
+
cornerRadius = 2,
|
|
359
|
+
weekStartDay = 0,
|
|
360
|
+
rows = DAYS_IN_WEEK,
|
|
361
|
+
levels,
|
|
362
|
+
levelColors,
|
|
363
|
+
color,
|
|
364
|
+
animationDuration = 900,
|
|
365
|
+
inactiveOpacity = 1,
|
|
366
|
+
onActiveCellChange,
|
|
367
|
+
children,
|
|
368
|
+
...props
|
|
369
|
+
},
|
|
370
|
+
ref
|
|
371
|
+
) {
|
|
372
|
+
const [width, setWidth] = useState(0);
|
|
373
|
+
const [activeCell, setActiveCellState] = useState<HeatmapCell | null>(null);
|
|
374
|
+
const clipId = useRef(
|
|
375
|
+
`panelui-heat-${Math.random().toString(36).slice(2, 9)}`
|
|
376
|
+
).current;
|
|
377
|
+
|
|
378
|
+
const reveal = useSharedValue(0);
|
|
379
|
+
const reducedMotion = useReducedMotion();
|
|
380
|
+
|
|
381
|
+
const baseToken = useCSSVariable('--color-chart-1');
|
|
382
|
+
const emptyToken = useCSSVariable('--color-muted');
|
|
383
|
+
const base = color ?? (typeof baseToken === 'string' ? baseToken : '#262626');
|
|
384
|
+
const empty = typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)';
|
|
385
|
+
|
|
386
|
+
const parts = useMemo(() => splitParts(children), [children]);
|
|
387
|
+
// The y-axis is laid out beside the grid rather than over it, so its width
|
|
388
|
+
// has to come out of the grid's before the cells are sized.
|
|
389
|
+
const axisWidth = parts.y ? (parts.y.props.width ?? DEFAULT_AXIS_WIDTH) : 0;
|
|
390
|
+
|
|
391
|
+
const grid = useMemo<Grid>(() => {
|
|
392
|
+
const count = data.length;
|
|
393
|
+
if (!count) return { size: 0, gap, columns: 0, rows, width: 0, height: 0 };
|
|
394
|
+
|
|
395
|
+
const available = Math.max(width - axisWidth, 0);
|
|
396
|
+
const size =
|
|
397
|
+
layout === 'fill' && available > 0
|
|
398
|
+
? Math.max((available - (count - 1) * gap) / count, 1)
|
|
399
|
+
: binSize;
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
size,
|
|
403
|
+
gap,
|
|
404
|
+
columns: count,
|
|
405
|
+
rows,
|
|
406
|
+
width: count * size + (count - 1) * gap,
|
|
407
|
+
height: rows * size + (rows - 1) * gap,
|
|
408
|
+
};
|
|
409
|
+
}, [data.length, width, axisWidth, layout, binSize, gap, rows]);
|
|
410
|
+
|
|
411
|
+
const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);
|
|
412
|
+
|
|
413
|
+
/*
|
|
414
|
+
* One colour at five opacities, unless the caller supplied five colours — in
|
|
415
|
+
* which case the opacities are dropped, since dimming a colour someone chose
|
|
416
|
+
* on purpose is not a ramp, it is a bug.
|
|
417
|
+
*/
|
|
418
|
+
const { ramp, opacities } = useMemo(
|
|
419
|
+
() =>
|
|
420
|
+
levelColors
|
|
421
|
+
? { ramp: levelColors, opacities: levelColors.map(() => 1) }
|
|
422
|
+
: { ramp: [empty, base, base, base, base], opacities: [...LEVEL_OPACITY] },
|
|
423
|
+
[levelColors, empty, base]
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const levelOf = useMemo(
|
|
427
|
+
() => (count: number) => {
|
|
428
|
+
if (count <= 0) return 0;
|
|
429
|
+
let level = 1;
|
|
430
|
+
for (let i = 1; i < thresholds.length; i++) {
|
|
431
|
+
if (count >= thresholds[i]!) level = i + 1;
|
|
432
|
+
}
|
|
433
|
+
return Math.min(level, 4);
|
|
434
|
+
},
|
|
435
|
+
[thresholds]
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const cellAt = useMemo(
|
|
439
|
+
() => (columnIndex: number, row: number): HeatmapCell => {
|
|
440
|
+
const bin = data[columnIndex]?.bins.find((candidate) => candidate.bin === row);
|
|
441
|
+
const count = bin?.count ?? 0;
|
|
442
|
+
return { column: columnIndex, row, count, level: levelOf(count), date: bin?.date };
|
|
443
|
+
},
|
|
444
|
+
[data, levelOf]
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const setActiveCell = useMemo(
|
|
448
|
+
() => (cell: HeatmapCell | null) => {
|
|
449
|
+
setActiveCellState(cell);
|
|
450
|
+
onActiveCellChange?.(cell);
|
|
451
|
+
},
|
|
452
|
+
[onActiveCellChange]
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// Plays once, when there is both a grid to reveal and data to reveal in it.
|
|
456
|
+
const revealed = useRef(false);
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
if (revealed.current || grid.width <= 0) return;
|
|
459
|
+
revealed.current = true;
|
|
460
|
+
if (reducedMotion) {
|
|
461
|
+
reveal.value = 1;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
reveal.value = withTiming(1, {
|
|
465
|
+
duration: animationDuration,
|
|
466
|
+
easing: Easing.out(Easing.cubic),
|
|
467
|
+
});
|
|
468
|
+
}, [grid.width, reducedMotion, animationDuration, reveal]);
|
|
469
|
+
|
|
470
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
471
|
+
const next = event.nativeEvent.layout.width;
|
|
472
|
+
setWidth((current) => (Math.abs(current - next) < 1 ? current : next));
|
|
473
|
+
props.onLayout?.(event);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const context = useMemo<HeatmapContextValue>(
|
|
477
|
+
() => ({
|
|
478
|
+
data,
|
|
479
|
+
grid,
|
|
480
|
+
ramp,
|
|
481
|
+
opacities,
|
|
482
|
+
levelOf,
|
|
483
|
+
cellAt,
|
|
484
|
+
cornerRadius,
|
|
485
|
+
inactiveOpacity,
|
|
486
|
+
weekStartDay,
|
|
487
|
+
clipId,
|
|
488
|
+
activeCell,
|
|
489
|
+
setActiveCell,
|
|
490
|
+
}),
|
|
491
|
+
[
|
|
492
|
+
data,
|
|
493
|
+
grid,
|
|
494
|
+
ramp,
|
|
495
|
+
opacities,
|
|
496
|
+
levelOf,
|
|
497
|
+
cellAt,
|
|
498
|
+
cornerRadius,
|
|
499
|
+
inactiveOpacity,
|
|
500
|
+
weekStartDay,
|
|
501
|
+
clipId,
|
|
502
|
+
activeCell,
|
|
503
|
+
setActiveCell,
|
|
504
|
+
]
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
/*
|
|
508
|
+
* One clip wiping left to right, rather than an animation per column. The
|
|
509
|
+
* effect is the same — columns arriving in order — and it costs one animated
|
|
510
|
+
* value instead of one per week, which for a year is fifty-two.
|
|
511
|
+
*/
|
|
512
|
+
const clipProps = useAnimatedProps(() => ({ width: grid.width * reveal.value }));
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<HeatmapContext.Provider value={context}>
|
|
516
|
+
<View
|
|
517
|
+
{...props}
|
|
518
|
+
ref={ref}
|
|
519
|
+
onLayout={onLayout}
|
|
520
|
+
className={cn('w-full', className)}
|
|
521
|
+
>
|
|
522
|
+
{parts.x ? (
|
|
523
|
+
<View style={{ paddingLeft: axisWidth }}>{parts.x}</View>
|
|
524
|
+
) : null}
|
|
525
|
+
|
|
526
|
+
<View className="flex-row">
|
|
527
|
+
{parts.y}
|
|
528
|
+
<View style={{ width: grid.width, height: grid.height }}>
|
|
529
|
+
{grid.width > 0 ? (
|
|
530
|
+
<>
|
|
531
|
+
<Svg width={grid.width} height={grid.height} style={StyleSheet.absoluteFill}>
|
|
532
|
+
<Defs>
|
|
533
|
+
<ClipPath id={clipId}>
|
|
534
|
+
<AnimatedRect x={0} y={0} height={grid.height} animatedProps={clipProps} />
|
|
535
|
+
</ClipPath>
|
|
536
|
+
</Defs>
|
|
537
|
+
<G clipPath={`url(#${clipId})`}>
|
|
538
|
+
{parts.rules}
|
|
539
|
+
{parts.cells}
|
|
540
|
+
</G>
|
|
541
|
+
</Svg>
|
|
542
|
+
{parts.tooltip}
|
|
543
|
+
</>
|
|
544
|
+
) : null}
|
|
545
|
+
</View>
|
|
546
|
+
</View>
|
|
547
|
+
|
|
548
|
+
{parts.legend}
|
|
549
|
+
</View>
|
|
550
|
+
</HeatmapContext.Provider>
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
interface Parts {
|
|
555
|
+
cells: ReactNode[];
|
|
556
|
+
rules: ReactNode[];
|
|
557
|
+
x: ReactNode | null;
|
|
558
|
+
y: React.ReactElement<{ width?: number }> | null;
|
|
559
|
+
tooltip: ReactNode[];
|
|
560
|
+
legend: ReactNode[];
|
|
561
|
+
columns: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Sorts the children into the places the layout has for them. */
|
|
565
|
+
function splitParts(children: ReactNode): Parts {
|
|
566
|
+
const parts: Parts = {
|
|
567
|
+
cells: [],
|
|
568
|
+
rules: [],
|
|
569
|
+
x: null,
|
|
570
|
+
y: null,
|
|
571
|
+
tooltip: [],
|
|
572
|
+
legend: [],
|
|
573
|
+
columns: 0,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
Children.forEach(children, (child, index) => {
|
|
577
|
+
if (!isValidElement(child)) return;
|
|
578
|
+
const slot = (child.type as { slot?: Slot }).slot ?? 'cells';
|
|
579
|
+
const keyed = <ChildSlot key={index}>{child}</ChildSlot>;
|
|
580
|
+
|
|
581
|
+
if (slot === 'x-axis') parts.x = keyed;
|
|
582
|
+
else if (slot === 'y-axis') parts.y = child as React.ReactElement<{ width?: number }>;
|
|
583
|
+
else if (slot === 'rules') parts.rules.push(keyed);
|
|
584
|
+
else if (slot === 'tooltip') parts.tooltip.push(keyed);
|
|
585
|
+
else if (slot === 'legend') parts.legend.push(keyed);
|
|
586
|
+
else parts.cells.push(keyed);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
return parts;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Identity wrapper, purely so the partitioned arrays can carry keys. */
|
|
593
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
594
|
+
return <>{children}</>;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/* -------------------------------------------------------------------------- */
|
|
598
|
+
/* Cells */
|
|
599
|
+
/* -------------------------------------------------------------------------- */
|
|
600
|
+
|
|
601
|
+
export interface HeatmapCellsProps {
|
|
602
|
+
/** Corner radius of a cell. Falls back to the chart's. */
|
|
603
|
+
cornerRadius?: number;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The grid itself. Every row of every column is drawn, including the ones with
|
|
608
|
+
* nothing in them — a calendar with holes in it stops being a calendar.
|
|
609
|
+
*/
|
|
610
|
+
function HeatmapCells({ cornerRadius }: HeatmapCellsProps) {
|
|
611
|
+
const chart = useHeatmap('HeatmapChart.Cells');
|
|
612
|
+
const { grid, data, ramp, opacities, levelOf, activeCell, inactiveOpacity } = chart;
|
|
613
|
+
const radius = cornerRadius ?? chart.cornerRadius;
|
|
614
|
+
const step = grid.size + grid.gap;
|
|
615
|
+
|
|
616
|
+
return (
|
|
617
|
+
<G>
|
|
618
|
+
{data.map((column, columnIndex) =>
|
|
619
|
+
Array.from({ length: grid.rows }, (_unused, row) => {
|
|
620
|
+
const bin = column.bins.find((candidate) => candidate.bin === row);
|
|
621
|
+
const level = levelOf(bin?.count ?? 0);
|
|
622
|
+
const dimmed =
|
|
623
|
+
activeCell !== null &&
|
|
624
|
+
!(activeCell.column === columnIndex && activeCell.row === row);
|
|
625
|
+
|
|
626
|
+
return (
|
|
627
|
+
<Rect
|
|
628
|
+
key={`${columnIndex}-${row}`}
|
|
629
|
+
x={columnIndex * step}
|
|
630
|
+
y={row * step}
|
|
631
|
+
width={grid.size}
|
|
632
|
+
height={grid.size}
|
|
633
|
+
rx={radius}
|
|
634
|
+
ry={radius}
|
|
635
|
+
fill={ramp[level]}
|
|
636
|
+
fillOpacity={opacities[level]! * (dimmed ? inactiveOpacity : 1)}
|
|
637
|
+
/>
|
|
638
|
+
);
|
|
639
|
+
})
|
|
640
|
+
)}
|
|
641
|
+
</G>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
HeatmapCells.slot = 'cells' as Slot;
|
|
645
|
+
|
|
646
|
+
/* -------------------------------------------------------------------------- */
|
|
647
|
+
/* Rules */
|
|
648
|
+
/* -------------------------------------------------------------------------- */
|
|
649
|
+
|
|
650
|
+
export interface HeatmapSeparatorProps {
|
|
651
|
+
/**
|
|
652
|
+
* `quarter` draws a rule every thirteen columns; a number draws one every
|
|
653
|
+
* that many columns.
|
|
654
|
+
*/
|
|
655
|
+
every?: 'quarter' | number;
|
|
656
|
+
color?: string;
|
|
657
|
+
/** Dash pattern, e.g. `"2,4"`. Omit for a solid rule. */
|
|
658
|
+
dashArray?: string;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Vertical rules grouping the columns — quarters, months, sprints. */
|
|
662
|
+
function HeatmapSeparator({ every = 'quarter', color, dashArray }: HeatmapSeparatorProps) {
|
|
663
|
+
const { grid } = useHeatmap('HeatmapChart.Separator');
|
|
664
|
+
const token = useCSSVariable('--color-border');
|
|
665
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.3)');
|
|
666
|
+
const interval = every === 'quarter' ? 13 : every;
|
|
667
|
+
const step = grid.size + grid.gap;
|
|
668
|
+
|
|
669
|
+
if (interval <= 0) return null;
|
|
670
|
+
|
|
671
|
+
const lines: number[] = [];
|
|
672
|
+
for (let column = interval; column < grid.columns; column += interval) {
|
|
673
|
+
lines.push(column);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<G>
|
|
678
|
+
{lines.map((column) => {
|
|
679
|
+
const x = column * step - grid.gap / 2;
|
|
680
|
+
return (
|
|
681
|
+
<SvgLine
|
|
682
|
+
key={column}
|
|
683
|
+
x1={x}
|
|
684
|
+
y1={0}
|
|
685
|
+
x2={x}
|
|
686
|
+
y2={grid.height}
|
|
687
|
+
stroke={stroke}
|
|
688
|
+
strokeWidth={1}
|
|
689
|
+
strokeDasharray={dashArray}
|
|
690
|
+
/>
|
|
691
|
+
);
|
|
692
|
+
})}
|
|
693
|
+
</G>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
HeatmapSeparator.slot = 'rules' as Slot;
|
|
697
|
+
|
|
698
|
+
/* -------------------------------------------------------------------------- */
|
|
699
|
+
/* Axes */
|
|
700
|
+
/* -------------------------------------------------------------------------- */
|
|
701
|
+
|
|
702
|
+
export interface HeatmapXAxisProps {
|
|
703
|
+
className?: string;
|
|
704
|
+
/**
|
|
705
|
+
* Label a column. Given the first dated bin in it, so a month name can be
|
|
706
|
+
* derived. Return an empty string to leave the column unlabelled.
|
|
707
|
+
*/
|
|
708
|
+
formatLabel?: (date: Date, column: number) => string;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Month labels above the grid.
|
|
713
|
+
*
|
|
714
|
+
* A label is emitted where the month changes rather than at a fixed interval,
|
|
715
|
+
* because months are not the same length — spacing them evenly puts "Mar" over
|
|
716
|
+
* a week in February.
|
|
717
|
+
*/
|
|
718
|
+
function HeatmapXAxis({ className, formatLabel }: HeatmapXAxisProps) {
|
|
719
|
+
const { data, grid } = useHeatmap('HeatmapChart.XAxis');
|
|
720
|
+
const step = grid.size + grid.gap;
|
|
721
|
+
|
|
722
|
+
const labels = useMemo(() => {
|
|
723
|
+
const out: { column: number; label: string }[] = [];
|
|
724
|
+
let lastMonth = -1;
|
|
725
|
+
|
|
726
|
+
data.forEach((column, index) => {
|
|
727
|
+
const date = column.bins.find((bin) => bin.date)?.date;
|
|
728
|
+
if (!date) return;
|
|
729
|
+
const month = date.getMonth();
|
|
730
|
+
if (month === lastMonth) return;
|
|
731
|
+
lastMonth = month;
|
|
732
|
+
const label = formatLabel ? formatLabel(date, index) : MONTHS[month]!;
|
|
733
|
+
if (label) out.push({ column: index, label });
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// The first month is usually a stub of a week or two, and a label over it
|
|
737
|
+
// collides with the next one. Drop it when it has no room.
|
|
738
|
+
if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();
|
|
739
|
+
return out;
|
|
740
|
+
}, [data, formatLabel]);
|
|
741
|
+
|
|
742
|
+
return (
|
|
743
|
+
<View className={cn('h-4', className)} style={{ width: grid.width }}>
|
|
744
|
+
{labels.map(({ column, label }) => (
|
|
745
|
+
<Text
|
|
746
|
+
key={`${column}-${label}`}
|
|
747
|
+
size="xs"
|
|
748
|
+
muted
|
|
749
|
+
className="absolute"
|
|
750
|
+
style={{ left: column * step }}
|
|
751
|
+
>
|
|
752
|
+
{label}
|
|
753
|
+
</Text>
|
|
754
|
+
))}
|
|
755
|
+
</View>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
HeatmapXAxis.slot = 'x-axis' as Slot;
|
|
759
|
+
|
|
760
|
+
export interface HeatmapYAxisProps {
|
|
761
|
+
className?: string;
|
|
762
|
+
/** Width reserved for the labels. The grid is sized around it. */
|
|
763
|
+
width?: number;
|
|
764
|
+
/** Which rows get a label. Every other row is the usual choice. */
|
|
765
|
+
tickFilter?: 'all' | 'odd' | 'even';
|
|
766
|
+
/** `initial` is the single letter; `full` is the abbreviated name. */
|
|
767
|
+
labelFormat?: 'initial' | 'full';
|
|
768
|
+
/**
|
|
769
|
+
* Row labels, top to bottom. Overrides the weekday names — for a grid whose
|
|
770
|
+
* rows are not days.
|
|
771
|
+
*/
|
|
772
|
+
labels?: string[];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Weekday labels down the left of the grid. */
|
|
776
|
+
function HeatmapYAxis({
|
|
777
|
+
className,
|
|
778
|
+
width = DEFAULT_AXIS_WIDTH,
|
|
779
|
+
tickFilter = 'odd',
|
|
780
|
+
labelFormat = 'full',
|
|
781
|
+
labels,
|
|
782
|
+
}: HeatmapYAxisProps) {
|
|
783
|
+
const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis');
|
|
784
|
+
const step = grid.size + grid.gap;
|
|
785
|
+
|
|
786
|
+
return (
|
|
787
|
+
<View className={className} style={{ width, height: grid.height }}>
|
|
788
|
+
{Array.from({ length: grid.rows }, (_unused, row) => {
|
|
789
|
+
if (tickFilter === 'odd' && row % 2 === 0) return null;
|
|
790
|
+
if (tickFilter === 'even' && row % 2 === 1) return null;
|
|
791
|
+
|
|
792
|
+
const name = labels
|
|
793
|
+
? (labels[row] ?? '')
|
|
794
|
+
: WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK]!;
|
|
795
|
+
return (
|
|
796
|
+
<Text
|
|
797
|
+
key={row}
|
|
798
|
+
size="xs"
|
|
799
|
+
muted
|
|
800
|
+
className="absolute"
|
|
801
|
+
// Centred on the cell rather than aligned to its top edge, so a
|
|
802
|
+
// label reads as belonging to the row it sits beside.
|
|
803
|
+
style={{ top: row * step + grid.size / 2 - 7 }}
|
|
804
|
+
>
|
|
805
|
+
{labels
|
|
806
|
+
? name
|
|
807
|
+
: labelFormat === 'initial'
|
|
808
|
+
? name.slice(0, 1)
|
|
809
|
+
: name.slice(0, 3)}
|
|
810
|
+
</Text>
|
|
811
|
+
);
|
|
812
|
+
})}
|
|
813
|
+
</View>
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
HeatmapYAxis.slot = 'y-axis' as Slot;
|
|
817
|
+
|
|
818
|
+
/* -------------------------------------------------------------------------- */
|
|
819
|
+
/* Tooltip */
|
|
820
|
+
/* -------------------------------------------------------------------------- */
|
|
821
|
+
|
|
822
|
+
export interface HeatmapTooltipProps {
|
|
823
|
+
className?: string;
|
|
824
|
+
/** The line shown for a cell. Defaults to the count and the date. */
|
|
825
|
+
formatLabel?: (cell: HeatmapCell) => string;
|
|
826
|
+
/**
|
|
827
|
+
* How long a press has to be held before the readout takes over, in
|
|
828
|
+
* milliseconds.
|
|
829
|
+
*
|
|
830
|
+
* It is not zero, and cannot be: a full year of columns lives inside a
|
|
831
|
+
* horizontal scroller, and a readout that claims the touch on the first pixel
|
|
832
|
+
* of movement means the chart can never be scrolled. Holding first is what
|
|
833
|
+
* separates "I am moving the chart" from "I am reading it". Set `0` only for
|
|
834
|
+
* a chart that is not inside a scroll view at all.
|
|
835
|
+
*/
|
|
836
|
+
activateAfterLongPress?: number;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* A readout following the finger across the grid.
|
|
841
|
+
*
|
|
842
|
+
* It lives in the view layer, over the SVG: a gesture handler cannot be
|
|
843
|
+
* attached to an SVG node, and SVG text ignores the platform's text scaling.
|
|
844
|
+
* The cell under the finger is resolved on the UI thread and only crosses back
|
|
845
|
+
* into JS when it changes, so a drag across a year costs a handful of
|
|
846
|
+
* re-renders rather than one per frame.
|
|
847
|
+
*/
|
|
848
|
+
function HeatmapTooltip({
|
|
849
|
+
className,
|
|
850
|
+
formatLabel,
|
|
851
|
+
activateAfterLongPress = DEFAULT_HOLD,
|
|
852
|
+
}: HeatmapTooltipProps) {
|
|
853
|
+
const { grid, activeCell, setActiveCell, cellAt } = useHeatmap(
|
|
854
|
+
'HeatmapChart.Tooltip'
|
|
855
|
+
);
|
|
856
|
+
const step = grid.size + grid.gap;
|
|
857
|
+
|
|
858
|
+
// Mirrored for the worklet, which cannot read the JS closure's latest value.
|
|
859
|
+
const lastKey = useSharedValue(-1);
|
|
860
|
+
|
|
861
|
+
const resolve = useMemo(
|
|
862
|
+
() => (column: number, row: number) => setActiveCell(cellAt(column, row)),
|
|
863
|
+
[cellAt, setActiveCell]
|
|
864
|
+
);
|
|
865
|
+
const clear = useMemo(() => () => setActiveCell(null), [setActiveCell]);
|
|
866
|
+
|
|
867
|
+
/*
|
|
868
|
+
* The cell is resolved here, on the UI thread, and only crosses into JS when
|
|
869
|
+
* it *changes*. Sending every frame's coordinates over and picking the cell
|
|
870
|
+
* in React would cost a re-render per frame for a value that changes maybe
|
|
871
|
+
* fifty times across a whole drag.
|
|
872
|
+
*/
|
|
873
|
+
const pick = (x: number, y: number) => {
|
|
874
|
+
'worklet';
|
|
875
|
+
const column = Math.floor(x / step);
|
|
876
|
+
const row = Math.floor(y / step);
|
|
877
|
+
if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows) return;
|
|
878
|
+
const key = column * grid.rows + row;
|
|
879
|
+
if (key === lastKey.value) return;
|
|
880
|
+
lastKey.value = key;
|
|
881
|
+
runOnJS(resolve)(column, row);
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
/*
|
|
885
|
+
* The readout takes over only once the press has been held.
|
|
886
|
+
*
|
|
887
|
+
* `onStart` rather than `onBegin` is the other half of it: `onBegin` fires on
|
|
888
|
+
* touch-down whatever happens next, so picking there would light a cell and
|
|
889
|
+
* dim the rest of the grid for the first moment of every scroll swipe — the
|
|
890
|
+
* gesture would yield correctly and still leave a flicker behind it.
|
|
891
|
+
*/
|
|
892
|
+
const pan = Gesture.Pan()
|
|
893
|
+
.minDistance(0)
|
|
894
|
+
.activateAfterLongPress(activateAfterLongPress)
|
|
895
|
+
.onStart((event) => {
|
|
896
|
+
'worklet';
|
|
897
|
+
pick(event.x, event.y);
|
|
898
|
+
})
|
|
899
|
+
.onUpdate((event) => {
|
|
900
|
+
'worklet';
|
|
901
|
+
pick(event.x, event.y);
|
|
902
|
+
})
|
|
903
|
+
.onFinalize(() => {
|
|
904
|
+
'worklet';
|
|
905
|
+
// Fires whether or not the gesture ever activated, so a swipe that only
|
|
906
|
+
// scrolled must not report a cell change it never made.
|
|
907
|
+
if (lastKey.value === -1) return;
|
|
908
|
+
lastKey.value = -1;
|
|
909
|
+
runOnJS(clear)();
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const label = activeCell
|
|
913
|
+
? formatLabel
|
|
914
|
+
? formatLabel(activeCell)
|
|
915
|
+
: defaultTooltipLabel(activeCell)
|
|
916
|
+
: null;
|
|
917
|
+
|
|
918
|
+
// Flipped to the left of the finger past the halfway mark, so the readout
|
|
919
|
+
// never runs off the edge it is closest to — and dropped below the cell in
|
|
920
|
+
// the top two rows, where there is no room above it inside the chart.
|
|
921
|
+
const anchorX = activeCell ? activeCell.column * step + grid.size / 2 : 0;
|
|
922
|
+
const flipped = anchorX > grid.width / 2;
|
|
923
|
+
const below = (activeCell?.row ?? 0) < 2;
|
|
924
|
+
const anchorY = activeCell
|
|
925
|
+
? below
|
|
926
|
+
? activeCell.row * step + grid.size + 6
|
|
927
|
+
: activeCell.row * step - TOOLTIP_HEIGHT - 6
|
|
928
|
+
: 0;
|
|
929
|
+
|
|
930
|
+
return (
|
|
931
|
+
<>
|
|
932
|
+
<GestureDetector gesture={pan}>
|
|
933
|
+
<View style={StyleSheet.absoluteFill} />
|
|
934
|
+
</GestureDetector>
|
|
935
|
+
|
|
936
|
+
{activeCell && label ? (
|
|
937
|
+
<View
|
|
938
|
+
pointerEvents="none"
|
|
939
|
+
className={cn(
|
|
940
|
+
'absolute rounded-lg border border-border bg-popover px-2 py-1 shadow-md',
|
|
941
|
+
className
|
|
942
|
+
)}
|
|
943
|
+
style={{
|
|
944
|
+
top: anchorY,
|
|
945
|
+
[flipped ? 'right' : 'left']: flipped ? grid.width - anchorX : anchorX,
|
|
946
|
+
}}
|
|
947
|
+
>
|
|
948
|
+
<Text size="xs" className="text-popover-foreground">
|
|
949
|
+
{label}
|
|
950
|
+
</Text>
|
|
951
|
+
</View>
|
|
952
|
+
) : null}
|
|
953
|
+
</>
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
HeatmapTooltip.slot = 'tooltip' as Slot;
|
|
957
|
+
|
|
958
|
+
function defaultTooltipLabel(cell: HeatmapCell) {
|
|
959
|
+
const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;
|
|
960
|
+
if (!cell.date) return 'No data';
|
|
961
|
+
const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;
|
|
962
|
+
return `${count} on ${date}`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/* -------------------------------------------------------------------------- */
|
|
966
|
+
/* Legend */
|
|
967
|
+
/* -------------------------------------------------------------------------- */
|
|
968
|
+
|
|
969
|
+
export interface HeatmapLegendProps {
|
|
970
|
+
className?: string;
|
|
971
|
+
/** Text at the low end of the ramp. */
|
|
972
|
+
lessLabel?: string;
|
|
973
|
+
/** Text at the high end. */
|
|
974
|
+
moreLabel?: string;
|
|
975
|
+
/** Side of a swatch, in pixels. Defaults to the chart's cell size. */
|
|
976
|
+
swatchSize?: number;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** The `Less ▢▢▢▢▢ More` key, under the grid. */
|
|
980
|
+
function HeatmapLegend({
|
|
981
|
+
className,
|
|
982
|
+
lessLabel = 'Less',
|
|
983
|
+
moreLabel = 'More',
|
|
984
|
+
swatchSize,
|
|
985
|
+
}: HeatmapLegendProps) {
|
|
986
|
+
const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Legend');
|
|
987
|
+
const size = swatchSize ?? Math.max(grid.size, 10);
|
|
988
|
+
|
|
989
|
+
return (
|
|
990
|
+
<View className={cn('flex-row items-center gap-1.5 pt-2', className)}>
|
|
991
|
+
<View className="flex-1" />
|
|
992
|
+
<Text size="xs" muted>
|
|
993
|
+
{lessLabel}
|
|
994
|
+
</Text>
|
|
995
|
+
{ramp.map((fill, level) => (
|
|
996
|
+
<Svg key={level} width={size} height={size}>
|
|
997
|
+
<Rect
|
|
998
|
+
x={0}
|
|
999
|
+
y={0}
|
|
1000
|
+
width={size}
|
|
1001
|
+
height={size}
|
|
1002
|
+
rx={cornerRadius}
|
|
1003
|
+
ry={cornerRadius}
|
|
1004
|
+
fill={fill}
|
|
1005
|
+
fillOpacity={opacities[level]}
|
|
1006
|
+
/>
|
|
1007
|
+
</Svg>
|
|
1008
|
+
))}
|
|
1009
|
+
<Text size="xs" muted>
|
|
1010
|
+
{moreLabel}
|
|
1011
|
+
</Text>
|
|
1012
|
+
</View>
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
HeatmapLegend.slot = 'legend' as Slot;
|
|
1016
|
+
|
|
1017
|
+
export const HeatmapChart = Object.assign(HeatmapChartRoot, {
|
|
1018
|
+
Cells: HeatmapCells,
|
|
1019
|
+
Separator: HeatmapSeparator,
|
|
1020
|
+
XAxis: HeatmapXAxis,
|
|
1021
|
+
YAxis: HeatmapYAxis,
|
|
1022
|
+
Tooltip: HeatmapTooltip,
|
|
1023
|
+
Legend: HeatmapLegend,
|
|
1024
|
+
});
|