panelui-native 0.40.0 → 0.41.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 (41) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/card/index.js +172 -3
  3. package/lib/module/components/card/index.js.map +1 -1
  4. package/lib/module/components/drawer/index.js +13 -4
  5. package/lib/module/components/drawer/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +72 -12
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/pagination/index.js +19 -9
  9. package/lib/module/components/pagination/index.js.map +1 -1
  10. package/lib/module/components/panelside/index.js +68 -18
  11. package/lib/module/components/panelside/index.js.map +1 -1
  12. package/lib/module/components/text-animation/index.js +857 -0
  13. package/lib/module/components/text-animation/index.js.map +1 -0
  14. package/lib/module/icons/index.js +25 -7
  15. package/lib/module/icons/index.js.map +1 -1
  16. package/lib/module/index.js +1 -0
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/typescript/src/components/card/index.d.ts +20 -0
  19. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/drawer/index.d.ts +13 -1
  21. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/map/index.d.ts +37 -2
  23. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
  25. package/lib/typescript/src/components/panelside/index.d.ts +50 -4
  26. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/text-animation/index.d.ts +268 -0
  28. package/lib/typescript/src/components/text-animation/index.d.ts.map +1 -0
  29. package/lib/typescript/src/icons/index.d.ts +6 -0
  30. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  31. package/lib/typescript/src/index.d.ts +4 -3
  32. package/lib/typescript/src/index.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/card/index.tsx +227 -2
  35. package/src/components/drawer/index.tsx +31 -9
  36. package/src/components/map/index.tsx +158 -14
  37. package/src/components/pagination/index.tsx +18 -6
  38. package/src/components/panelside/index.tsx +134 -19
  39. package/src/components/text-animation/index.tsx +1093 -0
  40. package/src/icons/index.tsx +23 -4
  41. package/src/index.ts +13 -1
@@ -1,5 +1,35 @@
1
- import { forwardRef } from 'react';
2
- import { View, type ViewProps } from 'react-native';
1
+ /**
2
+ * Card — a content surface, and the layer that decorates it.
3
+ *
4
+ * ```tsx
5
+ * <Card>
6
+ * <Card.Header>
7
+ * <Card.Title>Living room sofa</Card.Title>
8
+ * <Card.Description>Three seats, oat linen</Card.Description>
9
+ * </Card.Header>
10
+ * <Card.Footer>
11
+ * <Button fullWidth>Buy now</Button>
12
+ * </Card.Footer>
13
+ * </Card>
14
+ * ```
15
+ *
16
+ * All the padding lives on the slots and none of it on the root, which is why
17
+ * a card whose media reaches its own corners needs nothing but
18
+ * `overflow-hidden`.
19
+ */
20
+ import { forwardRef, useEffect, useMemo } from 'react';
21
+ import { StyleSheet, View, type ViewProps } from 'react-native';
22
+ import Animated, {
23
+ Easing,
24
+ cancelAnimation,
25
+ useAnimatedStyle,
26
+ useReducedMotion,
27
+ useSharedValue,
28
+ withRepeat,
29
+ withTiming,
30
+ } from 'react-native-reanimated';
31
+ import { LinearGradient } from 'expo-linear-gradient';
32
+ import { useCSSVariable } from 'uniwind';
3
33
  import { cn } from '../../utils/cn';
4
34
  import { Text, type TextProps } from '../../primitives/text';
5
35
 
@@ -55,10 +85,205 @@ const CardFooter = forwardRef<View, CardProps>(({ className, ...props }, ref) =>
55
85
  ));
56
86
  CardFooter.displayName = 'Card.Footer';
57
87
 
88
+ /* -------------------------------------------------------------------------- */
89
+ /* Card.Wash */
90
+ /* -------------------------------------------------------------------------- */
91
+
92
+ /**
93
+ * How sharply the wash dissolves upward. The exponent is the whole look: at 1
94
+ * the colour is already at half strength across the middle of the card and
95
+ * reads as a tinted panel, and it takes about a cube before the top is
96
+ * genuinely untouched and the colour arrives in the bottom third.
97
+ */
98
+ const WASH_CURVE = 3;
99
+ /**
100
+ * Stops the curve is sampled at. Ten is enough that the straight lines the
101
+ * renderer draws between them are not a shape anyone can see, and the fade
102
+ * stays continuous — stepping it would put a horizontal seam at every step.
103
+ */
104
+ const WASH_STOPS = 10;
105
+ /** How long one breath takes at `speed={1}`. */
106
+ const WASH_PERIOD = 4200;
107
+ /**
108
+ * How far the wash swells and settles over one breath, as a fraction of
109
+ * `intensity`. Small on purpose: the movement should be noticed only by not
110
+ * being there, and anything larger reads as a light being switched.
111
+ */
112
+ const WASH_BREATH = 0.22;
113
+
114
+ function isToken(value: string | undefined): value is string {
115
+ return typeof value === 'string' && value.startsWith('--');
116
+ }
117
+
118
+ /**
119
+ * Gradients need a transparent stop of the *same* colour — `transparent` is
120
+ * black at zero alpha on Android, which shows as a grey smear.
121
+ */
122
+ function withAlpha(color: string, alpha: number): string {
123
+ if (color.startsWith('#')) {
124
+ const hex = color.slice(1);
125
+ const full =
126
+ hex.length === 3
127
+ ? hex
128
+ .split('')
129
+ .map((c) => c + c)
130
+ .join('')
131
+ : hex.slice(0, 6);
132
+ const r = parseInt(full.slice(0, 2), 16);
133
+ const g = parseInt(full.slice(2, 4), 16);
134
+ const b = parseInt(full.slice(4, 6), 16);
135
+ if (Number.isNaN(r + g + b)) return color;
136
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
137
+ }
138
+
139
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
140
+ if (channels) {
141
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
142
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
143
+ }
144
+
145
+ return color;
146
+ }
147
+
148
+ export interface CardWashProps extends Omit<ViewProps, 'children'> {
149
+ className?: string;
150
+ /**
151
+ * The colour the wash rises in. A theme token name such as
152
+ * `--color-primary`, or any colour string. Defaults to `--color-primary`.
153
+ */
154
+ tint?: string;
155
+ /**
156
+ * How opaque the wash gets at the bottom edge, 0–1. It is meant to be small:
157
+ * past about 0.25 the card reads as tinted rather than washed, which is a
158
+ * different thing and usually not the one you wanted.
159
+ */
160
+ intensity?: number;
161
+ /**
162
+ * Multiplier on how slowly the wash breathes. `0` holds it still, which is
163
+ * also what a reduced-motion setting does.
164
+ */
165
+ speed?: number;
166
+ }
167
+
168
+ /**
169
+ * A decorative layer for the back of a card: the tint rising from its bottom
170
+ * edge and fading out before it reaches the top.
171
+ *
172
+ * It fills its parent absolutely and takes no touches, so it goes first inside
173
+ * a `Card` and everything else is drawn over it. The card needs
174
+ * `overflow-hidden` — the layer reaches the root's edges, and the root is the
175
+ * thing with the corner radius.
176
+ *
177
+ * ```tsx
178
+ * <Card className="overflow-hidden">
179
+ * <Card.Wash tint="--color-primary" />
180
+ * <Card.Header>…</Card.Header>
181
+ * </Card>
182
+ * ```
183
+ *
184
+ * ## Why the rise is a curve and not a fade
185
+ *
186
+ * On a straight ramp the colour is already at half strength across the middle
187
+ * of the card, and what you see is a panel with a tint on it. On a curve there
188
+ * is nothing at all across the top two thirds and then the colour arrives —
189
+ * which is the difference between a card that has been coloured in and a card
190
+ * something is rising into.
191
+ */
192
+ const CardWash = forwardRef<View, CardWashProps>(
193
+ (
194
+ {
195
+ className,
196
+ tint = '--color-primary',
197
+ intensity = 0.16,
198
+ speed = 1,
199
+ style,
200
+ ...props
201
+ },
202
+ ref
203
+ ) => {
204
+ const token = useCSSVariable(isToken(tint) ? tint : '--color-primary');
205
+ const resolved = typeof token === 'string' ? token : undefined;
206
+ const color = (isToken(tint) ? resolved : tint) ?? resolved ?? '#0a0a0a';
207
+
208
+ const reduced = useReducedMotion();
209
+ const still = reduced || speed <= 0;
210
+
211
+ const breath = useSharedValue(0);
212
+
213
+ useEffect(() => {
214
+ if (still) {
215
+ breath.value = 0;
216
+ return;
217
+ }
218
+ breath.value = 0;
219
+ breath.value = withRepeat(
220
+ withTiming(1, {
221
+ duration: WASH_PERIOD / speed,
222
+ easing: Easing.inOut(Easing.ease),
223
+ }),
224
+ -1,
225
+ true
226
+ );
227
+ return () => cancelAnimation(breath);
228
+ }, [breath, speed, still]);
229
+
230
+ /*
231
+ * The breath is the layer's opacity rather than its colours: rebuilding
232
+ * ten `rgba()` strings per frame would be a JavaScript-thread animation of
233
+ * the kind this library does not do, and a view's opacity is a property
234
+ * the compositor already knows how to move.
235
+ */
236
+ const breathing = useAnimatedStyle(() => ({
237
+ opacity: 1 - WASH_BREATH + breath.value * WASH_BREATH,
238
+ }));
239
+
240
+ const { colors, locations } = useMemo(() => {
241
+ const out: string[] = [];
242
+ const at: number[] = [];
243
+ for (let i = 0; i < WASH_STOPS; i += 1) {
244
+ const t = i / (WASH_STOPS - 1);
245
+ out.push(withAlpha(color, Math.pow(t, WASH_CURVE) * intensity));
246
+ at.push(t);
247
+ }
248
+ return {
249
+ colors: out as unknown as readonly [string, string, ...string[]],
250
+ locations: at as unknown as readonly [number, number, ...number[]],
251
+ };
252
+ }, [color, intensity]);
253
+
254
+ return (
255
+ <Animated.View
256
+ ref={ref}
257
+ // Decorative and non-interactive: it is the back of the card, and a
258
+ // screen reader announcing "image" over every card would be noise.
259
+ pointerEvents="none"
260
+ accessibilityElementsHidden
261
+ importantForAccessibility="no-hide-descendants"
262
+ className={cn('absolute inset-0 overflow-hidden', className)}
263
+ style={[still ? undefined : breathing, style]}
264
+ {...props}
265
+ >
266
+ {/*
267
+ * Every stop is the tint at a different alpha, never `transparent`:
268
+ * that is black at zero alpha on Android, and a wash faded to it
269
+ * arrives through a grey smear on the way.
270
+ */}
271
+ <LinearGradient
272
+ colors={colors}
273
+ locations={locations}
274
+ style={StyleSheet.absoluteFill}
275
+ />
276
+ </Animated.View>
277
+ );
278
+ }
279
+ );
280
+ CardWash.displayName = 'Card.Wash';
281
+
58
282
  export const Card = Object.assign(CardRoot, {
59
283
  Header: CardHeader,
60
284
  Title: CardTitle,
61
285
  Description: CardDescription,
62
286
  Content: CardContent,
63
287
  Footer: CardFooter,
288
+ Wash: CardWash,
64
289
  });
@@ -103,6 +103,7 @@ const DISMISS_VELOCITY = 650;
103
103
 
104
104
  export type DrawerSide = 'start' | 'end' | 'top' | 'bottom';
105
105
  export type DrawerSize = 'sm' | 'md' | 'lg' | 'full';
106
+ export type DrawerCloseSide = 'start' | 'end';
106
107
 
107
108
  /**
108
109
  * How much of the screen each size asks for, and the cap it is never allowed
@@ -168,7 +169,7 @@ function useDrawer(component: string): DrawerContextValue {
168
169
  * so a header can leave it clear rather than wrap underneath it.
169
170
  */
170
171
  interface DrawerSurfaceValue {
171
- side: DrawerSide;
172
+ closeSide: DrawerCloseSide;
172
173
  showClose: boolean;
173
174
  }
174
175
 
@@ -270,6 +271,17 @@ export interface DrawerContentProps extends ViewProps {
270
271
  swipeToDismiss?: boolean;
271
272
  /** Show a close button in the drawer's inner top corner. Default true. */
272
273
  showClose?: boolean;
274
+ /**
275
+ * Which top corner the close button takes. Logical, like `side`: `end` is the
276
+ * corner text runs toward, so it is the right one in a left-to-right app and
277
+ * the left one in a right-to-left one.
278
+ *
279
+ * Defaults to the corner *away* from the docked edge, so an `end` drawer's
280
+ * button does not sit against the screen edge the panel came out of. Set it
281
+ * when the drawer reads better with the button on the outer corner instead —
282
+ * a panel people close by reaching for the same corner every time.
283
+ */
284
+ closeSide?: DrawerCloseSide;
273
285
  /**
274
286
  * Frost the screen behind the drawer instead of dimming it. Needs the
275
287
  * optional `expo-blur`; without it this dims, rather than failing.
@@ -285,6 +297,7 @@ function DrawerContent({
285
297
  dismissible = true,
286
298
  swipeToDismiss = true,
287
299
  showClose = true,
300
+ closeSide,
288
301
  blur = false,
289
302
  children,
290
303
  ...props
@@ -384,9 +397,17 @@ function DrawerContent({
384
397
  : { transform: [{ translateY: travel.value * outward }] }
385
398
  );
386
399
 
400
+ /*
401
+ * Resolved once and shared, because the button's corner and the header's
402
+ * clearance are the same decision seen from two places. Letting each work it
403
+ * out from `side` is how they drift, and a header padded on the wrong side
404
+ * runs its title straight under the button.
405
+ */
406
+ const closeCorner: DrawerCloseSide = closeSide ?? (side === 'end' ? 'start' : 'end');
407
+
387
408
  const surface = useMemo<DrawerSurfaceValue>(
388
- () => ({ side, showClose }),
389
- [side, showClose]
409
+ () => ({ closeSide: closeCorner, showClose }),
410
+ [closeCorner, showClose]
390
411
  );
391
412
 
392
413
  if (!open) return null;
@@ -508,10 +529,11 @@ function DrawerContent({
508
529
  * Taking the same number the padding does is what keeps it
509
530
  * level with the first line of the header on every side.
510
531
  *
511
- * The corner follows the docked edge. An `end` drawer's own
512
- * edge is the trailing one, so its button moves to the
513
- * leading side rather than sitting against the screen edge
514
- * the drawer came out of.
532
+ * The corner follows the docked edge by default. An `end`
533
+ * drawer's own edge is the trailing one, so its button moves
534
+ * to the leading side rather than sitting against the screen
535
+ * edge the drawer came out of — and `closeSide` overrides
536
+ * that for a panel that wants the same corner every time.
515
537
  */}
516
538
  {showClose ? (
517
539
  <Pressable
@@ -521,7 +543,7 @@ function DrawerContent({
521
543
  hitSlop={8}
522
544
  className={cn(
523
545
  'absolute z-10 h-8 w-8 items-center justify-center rounded-full bg-muted active:opacity-70',
524
- side === 'end' ? 'start-4' : 'end-4'
546
+ closeCorner === 'start' ? 'start-4' : 'end-4'
525
547
  )}
526
548
  style={{ top: padding.paddingTop }}
527
549
  >
@@ -569,7 +591,7 @@ function DrawerHeader({
569
591
  */
570
592
  const clearance = !surface?.showClose
571
593
  ? undefined
572
- : surface.side === 'end'
594
+ : surface.closeSide === 'start'
573
595
  ? 'ps-14'
574
596
  : 'pe-14';
575
597
 
@@ -128,6 +128,43 @@ function useToken(name: string, fallback: string): string {
128
128
  return typeof value === 'string' && value.length > 0 ? value : fallback;
129
129
  }
130
130
 
131
+ /** True for a theme token name, as opposed to a literal colour. */
132
+ function isToken(value: string | undefined): value is string {
133
+ return typeof value === 'string' && value.startsWith('--');
134
+ }
135
+
136
+ /**
137
+ * The same colour at a given alpha, for a ramp built from one hue.
138
+ *
139
+ * Style expressions are handed to the renderer as strings, so this produces
140
+ * `rgba()` rather than reaching for a colour type the layer would not accept.
141
+ */
142
+ function withAlpha(color: string, alpha: number): string {
143
+ if (color.startsWith('#')) {
144
+ const hex = color.slice(1);
145
+ const full =
146
+ hex.length === 3
147
+ ? hex
148
+ .split('')
149
+ .map((c) => c + c)
150
+ .join('')
151
+ : hex.slice(0, 6);
152
+ const r = parseInt(full.slice(0, 2), 16);
153
+ const g = parseInt(full.slice(2, 4), 16);
154
+ const b = parseInt(full.slice(4, 6), 16);
155
+ if (Number.isNaN(r + g + b)) return color;
156
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
157
+ }
158
+
159
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
160
+ if (channels) {
161
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
162
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
163
+ }
164
+
165
+ return color;
166
+ }
167
+
131
168
  interface MapContextValue {
132
169
  mapRef: React.RefObject<MapRef | null>;
133
170
  cameraRef: React.RefObject<CameraRef | null>;
@@ -1025,6 +1062,28 @@ export interface MapHeatmapProps {
1025
1062
  data: unknown;
1026
1063
  /** Feature property to weight each point by. Unweighted when omitted. */
1027
1064
  weight?: string;
1065
+ /**
1066
+ * Base colour of the field — a theme token by name, or a literal. The ramp
1067
+ * is this colour at rising opacity, so density reads as *more of the same
1068
+ * thing* rather than as a change of subject.
1069
+ *
1070
+ * Defaults to `--color-chart-2`, which is a saturated accent in every theme.
1071
+ * It is deliberately not `--color-chart-1`: that is the series colour a chart
1072
+ * is about, and every theme starts it on something close to the foreground —
1073
+ * near-black in a light theme, near-white in a dark one — which over a
1074
+ * basemap is a smudge rather than a measurement.
1075
+ */
1076
+ color?: string;
1077
+ /**
1078
+ * Replace the derived ramp outright, coolest first. For the conventional
1079
+ * heat ramp, where the hue carries the reading as well as the opacity —
1080
+ * worth it when the field sits over varied terrain and one hue at five
1081
+ * opacities stops being separable from what is underneath it.
1082
+ *
1083
+ * The first stop is drawn at the lowest density, the last at the highest.
1084
+ * Density zero stays fully transparent either way.
1085
+ */
1086
+ colors?: string[];
1028
1087
  /**
1029
1088
  * Spread of a single point, in points, at street zoom. Larger blurs more.
1030
1089
  * The drawn radius shrinks as the map zooms out, so a point keeps covering
@@ -1037,6 +1096,12 @@ export interface MapHeatmapProps {
1037
1096
  opacity?: number;
1038
1097
  /** Above this zoom the layer fades out — see the note on the component. */
1039
1098
  maxZoom?: number;
1099
+ /**
1100
+ * Draw the points themselves as the field fades out, coloured from the same
1101
+ * ramp by weight. Without them, zooming past `maxZoom` leaves an empty map:
1102
+ * the layer gets out of the way, and nothing takes its place.
1103
+ */
1104
+ points?: boolean;
1040
1105
  id?: string;
1041
1106
  }
1042
1107
 
@@ -1050,30 +1115,63 @@ export interface MapHeatmapProps {
1050
1115
  *
1051
1116
  * It fades out past `maxZoom` on purpose. Zoomed far enough in, every point is
1052
1117
  * its own island and the blur says less than the points would — so the layer
1053
- * gets out of the way rather than smearing five records across a street.
1118
+ * gets out of the way rather than smearing five records across a street. Set
1119
+ * `points` and the records themselves fade in as it goes, which is the whole
1120
+ * handover: a field while the question is where, marks once it is which.
1054
1121
  *
1055
1122
  * The radius is tied to zoom for the mirror-image reason. Left as a fixed
1056
1123
  * number of screen points it would mean a different distance at every zoom:
1057
1124
  * a blur that reads as a city at street level covers half a continent once
1058
1125
  * the map is pulled out, and a field measured over land ends up sitting in
1059
1126
  * the sea. Scaling it with the projection keeps the claim the same one.
1127
+ *
1128
+ * Intensity is tied to zoom too, and in the other direction. The same points
1129
+ * are packed into fewer pixels the further out the map goes, so a constant
1130
+ * intensity saturates the whole field at world zoom and shows nothing but its
1131
+ * own ceiling.
1060
1132
  */
1061
1133
  function MapHeatmap({
1062
1134
  data,
1063
1135
  weight,
1136
+ color,
1137
+ colors,
1064
1138
  radius = 24,
1065
1139
  intensity = 1,
1066
1140
  opacity = 0.85,
1067
1141
  maxZoom = 15,
1142
+ points = false,
1068
1143
  id = 'heatmap',
1069
1144
  }: MapHeatmapProps) {
1070
- const cool = useToken('--color-muted', 'rgba(0,0,0,0.06)');
1071
- const warm = useToken('--color-chart-1', '#262626');
1072
- const hot = useToken('--color-chart-2', '#666666');
1145
+ const base = useToken(isToken(color) ? color : '--color-chart-2', '#3b82f6');
1146
+ const resolved = (isToken(color) ? base : color) ?? base;
1147
+
1148
+ /*
1149
+ * One colour at rising opacity rather than five colours, which is how every
1150
+ * other ramp in the library is built: a sequential scale is one quantity
1151
+ * getting larger, and five hues say five categories. `colors` is there for
1152
+ * the case where that is not enough.
1153
+ */
1154
+ const ramp = colors?.length
1155
+ ? colors
1156
+ : [
1157
+ withAlpha(resolved, 0.25),
1158
+ withAlpha(resolved, 0.45),
1159
+ withAlpha(resolved, 0.7),
1160
+ withAlpha(resolved, 0.9),
1161
+ resolved,
1162
+ ];
1073
1163
 
1074
1164
  if (!MapLibre) return null;
1075
1165
  const { GeoJSONSource, Layer } = MapLibre;
1076
1166
 
1167
+ // Density zero is transparent rather than the coolest colour, so the map
1168
+ // shows through everywhere nothing was measured instead of the whole
1169
+ // viewport being tinted by the absence of data.
1170
+ const stops = ramp.flatMap((entry, index) => [
1171
+ 0.15 + (0.85 * index) / Math.max(1, ramp.length - 1),
1172
+ entry,
1173
+ ]);
1174
+
1077
1175
  return (
1078
1176
  <GeoJSONSource id={`${id}-source`} data={data}>
1079
1177
  <Layer
@@ -1084,7 +1182,15 @@ function MapHeatmap({
1084
1182
  heatmapWeight: weight
1085
1183
  ? ['interpolate', ['linear'], ['get', weight], 0, 0, 1, 1]
1086
1184
  : 1,
1087
- heatmapIntensity: intensity,
1185
+ heatmapIntensity: [
1186
+ 'interpolate',
1187
+ ['linear'],
1188
+ ['zoom'],
1189
+ 0,
1190
+ intensity * 0.6,
1191
+ maxZoom,
1192
+ intensity * 1.2,
1193
+ ],
1088
1194
  // `radius` is the spread at street zoom; below that it is scaled
1089
1195
  // down so it keeps covering the same ground rather than the same
1090
1196
  // number of pixels. The stops are the projection: each zoom level
@@ -1101,21 +1207,13 @@ function MapHeatmap({
1101
1207
  14,
1102
1208
  radius,
1103
1209
  ],
1104
- // Transparent at zero rather than the coolest colour, so the map
1105
- // shows through everywhere nothing was measured instead of the whole
1106
- // viewport being tinted by the absence of data.
1107
1210
  heatmapColor: [
1108
1211
  'interpolate',
1109
1212
  ['linear'],
1110
1213
  ['heatmap-density'],
1111
1214
  0,
1112
1215
  'rgba(0,0,0,0)',
1113
- 0.2,
1114
- cool,
1115
- 0.6,
1116
- warm,
1117
- 1,
1118
- hot,
1216
+ ...stops,
1119
1217
  ],
1120
1218
  heatmapOpacity: [
1121
1219
  'interpolate',
@@ -1128,6 +1226,52 @@ function MapHeatmap({
1128
1226
  ],
1129
1227
  }}
1130
1228
  />
1229
+
1230
+ {/* The handover, in the two zoom levels the field spends fading: the
1231
+ marks arrive as it leaves, so there is never a zoom with neither. */}
1232
+ {points ? (
1233
+ <Layer
1234
+ id={`${id}-points`}
1235
+ type="circle"
1236
+ minzoom={maxZoom - 2}
1237
+ style={{
1238
+ circleRadius: weight
1239
+ ? ['interpolate', ['linear'], ['get', weight], 0, 3, 1, 9]
1240
+ : 5,
1241
+ circleColor: weight
1242
+ ? [
1243
+ 'interpolate',
1244
+ ['linear'],
1245
+ ['get', weight],
1246
+ 0,
1247
+ ramp[0] as string,
1248
+ 1,
1249
+ ramp[ramp.length - 1] as string,
1250
+ ]
1251
+ : (ramp[ramp.length - 1] as string),
1252
+ circleStrokeWidth: 1,
1253
+ circleStrokeColor: 'rgba(255,255,255,0.75)',
1254
+ circleOpacity: [
1255
+ 'interpolate',
1256
+ ['linear'],
1257
+ ['zoom'],
1258
+ maxZoom - 2,
1259
+ 0,
1260
+ maxZoom,
1261
+ 0.85,
1262
+ ],
1263
+ circleStrokeOpacity: [
1264
+ 'interpolate',
1265
+ ['linear'],
1266
+ ['zoom'],
1267
+ maxZoom - 2,
1268
+ 0,
1269
+ maxZoom,
1270
+ 0.6,
1271
+ ],
1272
+ }}
1273
+ />
1274
+ ) : null}
1131
1275
  </GeoJSONSource>
1132
1276
  );
1133
1277
  }
@@ -65,9 +65,10 @@ export type PaginationSize = 'sm' | 'default';
65
65
  /**
66
66
  * What the small size gives back as padding. 44pt is the smallest target a
67
67
  * finger reliably hits, and the row is allowed to get tighter than that only
68
- * because the reach is restored outside the paint.
68
+ * because the reach is restored outside the paint — so this is exactly the
69
+ * difference between the small target's 32pt and that 44.
69
70
  */
70
- const SMALL_HIT_SLOP = { top: 4, bottom: 4, left: 4, right: 4 } as const;
71
+ const SMALL_HIT_SLOP = { top: 6, bottom: 6, left: 6, right: 6 } as const;
71
72
 
72
73
  const paginationVariants = tv({
73
74
  slots: {
@@ -91,9 +92,9 @@ const paginationVariants = tv({
91
92
  },
92
93
  sm: {
93
94
  list: 'gap-0.5',
94
- item: 'h-9 min-w-9 px-1',
95
+ item: 'h-8 min-w-8 px-1',
95
96
  itemLabel: 'text-xs',
96
- ellipsis: 'h-9 w-9',
97
+ ellipsis: 'h-8 w-8',
97
98
  status: 'text-xs',
98
99
  summary: 'text-xs',
99
100
  },
@@ -304,7 +305,18 @@ const PaginationRoot = forwardRef<View, PaginationProps>(
304
305
  // With nothing on the leading edge the controls are the whole row
305
306
  // and centre in it; with something there they part to the two ends,
306
307
  // which is the shape a table footer wants.
307
- className: cn(children ? 'justify-between gap-3' : 'justify-center', className),
308
+ //
309
+ // Clipped, because the run is a fixed number of targets and a
310
+ // narrow enough container cannot hold it. Overflowing, a centred
311
+ // row hangs the same distance past both edges, and the leading
312
+ // number ends up half off the screen with nothing to say why.
313
+ // Inside its own bounds the shortfall at least looks deliberate,
314
+ // and `siblings`/`boundaries` are the knobs that fix it.
315
+ className: cn(
316
+ 'overflow-hidden',
317
+ children ? 'justify-between gap-3' : 'justify-center',
318
+ className
319
+ ),
308
320
  })}
309
321
  {...props}
310
322
  >
@@ -312,7 +324,7 @@ const PaginationRoot = forwardRef<View, PaginationProps>(
312
324
  <PaginationStatus>{text}</PaginationStatus>
313
325
  ))}
314
326
 
315
- <View className={list()}>
327
+ <View className={list({ className: 'max-w-full' })}>
316
328
  {controls ? <PaginationPrevious label={variant === 'simple'} /> : null}
317
329
 
318
330
  {variant === 'numbers'