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