panelui-native 0.68.0 → 0.73.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 +13 -2
- package/lib/module/components/accordion/index.js +38 -13
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/area-chart/index.js +1 -0
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/breadcrumb/index.js +2 -1
- package/lib/module/components/breadcrumb/index.js.map +1 -1
- package/lib/module/components/button/index.js +21 -12
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/carousel/index.js +1 -1
- package/lib/module/components/carousel/index.js.map +1 -1
- package/lib/module/components/chip/index.js +2 -2
- package/lib/module/components/chip/index.js.map +1 -1
- package/lib/module/components/code-block/index.js.map +1 -1
- package/lib/module/components/collapsible/index.js +284 -0
- package/lib/module/components/collapsible/index.js.map +1 -0
- package/lib/module/components/color-picker/index.js.map +1 -1
- package/lib/module/components/fab/index.js +7 -6
- package/lib/module/components/fab/index.js.map +1 -1
- package/lib/module/components/flow/index.js +67 -4
- package/lib/module/components/flow/index.js.map +1 -1
- package/lib/module/components/frame/index.js +2 -2
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/grid-item/index.js +2 -2
- package/lib/module/components/grid-item/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +1 -0
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/hex-chart/index.js +1 -0
- package/lib/module/components/hex-chart/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +7 -2
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/item/index.js +2 -2
- package/lib/module/components/item/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +1 -0
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/live-line-chart/index.js +103 -9
- package/lib/module/components/live-line-chart/index.js.map +1 -1
- package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
- package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
- package/lib/module/components/marker/index.js +2 -2
- package/lib/module/components/marker/index.js.map +1 -1
- package/lib/module/components/marquee/index.js +191 -0
- package/lib/module/components/marquee/index.js.map +1 -0
- package/lib/module/components/marquee/marquee-math.js +39 -0
- package/lib/module/components/marquee/marquee-math.js.map +1 -0
- package/lib/module/components/menu/index.js +11 -3
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/meter/index.js +38 -20
- package/lib/module/components/meter/index.js.map +1 -1
- package/lib/module/components/meter/meter-scale.js +62 -8
- package/lib/module/components/meter/meter-scale.js.map +1 -1
- package/lib/module/components/pagination/index.js +27 -11
- package/lib/module/components/pagination/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +7 -2
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/planner/index.js +159 -64
- package/lib/module/components/planner/index.js.map +1 -1
- package/lib/module/components/planner/planner-announcement.js +30 -0
- package/lib/module/components/planner/planner-announcement.js.map +1 -0
- package/lib/module/components/planner/planner-entries.js +15 -7
- package/lib/module/components/planner/planner-entries.js.map +1 -1
- package/lib/module/components/planner/planner-grid-navigation.js +33 -0
- package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
- package/lib/module/components/planner/planner-lifecycle.js +45 -0
- package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
- package/lib/module/components/plot/index.js +105 -42
- package/lib/module/components/plot/index.js.map +1 -1
- package/lib/module/components/popover/index.js +2 -2
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/post/index.js +4 -4
- package/lib/module/components/post/index.js.map +1 -1
- package/lib/module/components/search-bar/index.js +331 -0
- package/lib/module/components/search-bar/index.js.map +1 -0
- package/lib/module/components/table/index.js +3 -4
- package/lib/module/components/table/index.js.map +1 -1
- package/lib/module/components/timeline/index.js +288 -39
- package/lib/module/components/timeline/index.js.map +1 -1
- package/lib/module/components/toggle-button/index.js +1 -1
- package/lib/module/components/toggle-button/index.js.map +1 -1
- package/lib/module/components/tree/index.js +13 -8
- package/lib/module/components/tree/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/focus-restoration-store.js +52 -0
- package/lib/module/primitives/focus-restoration-store.js.map +1 -0
- package/lib/module/primitives/portal.js +36 -2
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/module/utils/chart.js +50 -7
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +6 -2
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/code-block/index.d.ts +3 -3
- package/lib/typescript/src/components/code-block/index.d.ts.map +1 -1
- package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
- package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/fab/index.d.ts +4 -2
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
- package/lib/typescript/src/components/flow/index.d.ts +7 -1
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
- package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/components/marquee/index.d.ts +73 -0
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
- package/lib/typescript/src/components/marquee/marquee-math.d.ts +21 -0
- package/lib/typescript/src/components/marquee/marquee-math.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts +1 -1
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/meter/index.d.ts +7 -4
- package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
- package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
- package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/plan/index.d.ts +2 -2
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
- package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
- package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/components/plot/index.d.ts +59 -5
- package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
- package/lib/typescript/src/components/search-bar/index.d.ts +216 -0
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -0
- package/lib/typescript/src/components/timeline/index.d.ts +35 -2
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tree/index.d.ts +3 -4
- package/lib/typescript/src/components/tree/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +2 -2
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
- package/lib/typescript/src/primitives/portal.d.ts +4 -0
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +28 -3
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +36 -3
- package/src/components/accordion/index.tsx +48 -15
- package/src/components/area-chart/index.tsx +8 -0
- package/src/components/breadcrumb/index.tsx +2 -1
- package/src/components/button/index.tsx +14 -6
- package/src/components/carousel/index.tsx +1 -1
- package/src/components/chip/index.tsx +2 -2
- package/src/components/code-block/index.tsx +3 -3
- package/src/components/collapsible/index.tsx +287 -0
- package/src/components/color-picker/index.tsx +3 -2
- package/src/components/fab/index.tsx +21 -11
- package/src/components/flow/index.tsx +94 -15
- package/src/components/frame/index.tsx +2 -2
- package/src/components/grid-item/index.tsx +2 -2
- package/src/components/heatmap-chart/index.tsx +7 -1
- package/src/components/hex-chart/index.tsx +7 -1
- package/src/components/input-group/index.tsx +7 -2
- package/src/components/item/index.tsx +2 -2
- package/src/components/line-chart/index.tsx +9 -0
- package/src/components/live-line-chart/index.tsx +130 -13
- package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
- package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
- package/src/components/marker/index.tsx +2 -2
- package/src/components/marquee/index.tsx +234 -0
- package/src/components/marquee/marquee-math.ts +37 -0
- package/src/components/menu/index.tsx +11 -3
- package/src/components/meter/index.tsx +49 -26
- package/src/components/meter/meter-scale.ts +84 -8
- package/src/components/pagination/index.tsx +26 -13
- package/src/components/panelside/index.tsx +7 -2
- package/src/components/plan/index.tsx +2 -2
- package/src/components/planner/index.tsx +202 -76
- package/src/components/planner/planner-announcement.ts +37 -0
- package/src/components/planner/planner-entries.ts +16 -7
- package/src/components/planner/planner-grid-navigation.ts +45 -0
- package/src/components/planner/planner-lifecycle.ts +76 -0
- package/src/components/plot/index.tsx +183 -39
- package/src/components/popover/index.tsx +3 -3
- package/src/components/post/index.tsx +4 -4
- package/src/components/search-bar/index.tsx +436 -0
- package/src/components/table/index.tsx +2 -2
- package/src/components/timeline/index.tsx +335 -39
- package/src/components/toggle-button/index.tsx +1 -1
- package/src/components/tree/index.tsx +17 -12
- package/src/hooks/use-keyboard-avoidance.ts +7 -2
- package/src/index.ts +15 -0
- package/src/primitives/focus-restoration-store.ts +70 -0
- package/src/primitives/portal.tsx +34 -2
- package/src/utils/chart.ts +66 -7
|
@@ -30,10 +30,17 @@
|
|
|
30
30
|
* they mean opposite things.
|
|
31
31
|
*
|
|
32
32
|
* The cost is a frame callback for as long as the chart is mounted. It is
|
|
33
|
-
* stopped by `paused`, by `status="loading"
|
|
34
|
-
* started when the platform asks for reduced
|
|
35
|
-
* advances as each point arrives instead,
|
|
36
|
-
* less often.
|
|
33
|
+
* stopped by `paused`, by `status="loading"`, while the app is backgrounded
|
|
34
|
+
* and on unmount, and it is never started when the platform asks for reduced
|
|
35
|
+
* motion — in that case the window advances as each point arrives instead,
|
|
36
|
+
* which is the same picture sampled less often.
|
|
37
|
+
*
|
|
38
|
+
* Screen readers receive one image-role snapshot: its name, current or
|
|
39
|
+
* selected value, direction, time window and paused state. It changes when the
|
|
40
|
+
* React data changes, never on the UI-thread clock frame, and requests no live
|
|
41
|
+
* announcement. The visual axes, badges and tooltip repeat that snapshot and
|
|
42
|
+
* stay out of the accessibility tree; controls placed in Header remain normal
|
|
43
|
+
* controls.
|
|
37
44
|
*
|
|
38
45
|
* ## Colour follows the recent direction
|
|
39
46
|
*
|
|
@@ -56,7 +63,13 @@ import {
|
|
|
56
63
|
useState,
|
|
57
64
|
type ReactNode,
|
|
58
65
|
} from 'react';
|
|
59
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
AppState,
|
|
68
|
+
StyleSheet,
|
|
69
|
+
View,
|
|
70
|
+
type LayoutChangeEvent,
|
|
71
|
+
type ViewProps,
|
|
72
|
+
} from 'react-native';
|
|
60
73
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
61
74
|
import Animated, {
|
|
62
75
|
runOnJS,
|
|
@@ -92,6 +105,13 @@ import {
|
|
|
92
105
|
type Plot,
|
|
93
106
|
} from '../../utils/chart';
|
|
94
107
|
import { cn } from '../../utils/cn';
|
|
108
|
+
import { liveLineAccessibility } from './live-line-accessibility';
|
|
109
|
+
import {
|
|
110
|
+
liveLineClockRuns,
|
|
111
|
+
normalizeLiveLinePoints,
|
|
112
|
+
normalizeLiveLineWindow,
|
|
113
|
+
reconcileLiveLineActivePoint,
|
|
114
|
+
} from './live-line-lifecycle';
|
|
95
115
|
|
|
96
116
|
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
97
117
|
|
|
@@ -224,9 +244,16 @@ function runOf(
|
|
|
224
244
|
|
|
225
245
|
export interface LiveLineChartProps extends ViewProps {
|
|
226
246
|
className?: string;
|
|
227
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* Names the chart's single screen-reader snapshot. Falls back to the Header
|
|
249
|
+
* title, then to "Live line chart".
|
|
250
|
+
*/
|
|
251
|
+
accessibilityLabel?: string;
|
|
252
|
+
/** Additional guidance after the snapshot. No gesture is invented for it. */
|
|
253
|
+
accessibilityHint?: string;
|
|
254
|
+
/** The readings so far. Invalid values are dropped and timestamps are ordered. */
|
|
228
255
|
data: LiveLinePoint[];
|
|
229
|
-
/** How much time the plot spans, in seconds. */
|
|
256
|
+
/** How much time the plot spans, in seconds. Invalid values use 30. */
|
|
230
257
|
window?: number;
|
|
231
258
|
/** Freeze the window where it is. The readings still arrive; the clock stops. */
|
|
232
259
|
paused?: boolean;
|
|
@@ -239,7 +266,7 @@ export interface LiveLineChartProps extends ViewProps {
|
|
|
239
266
|
/**
|
|
240
267
|
* The most readings kept. Older ones are dropped, since they are off the
|
|
241
268
|
* window and cannot come back — an unbounded feed otherwise grows an array
|
|
242
|
-
* for as long as the screen is open.
|
|
269
|
+
* for as long as the screen is open. Must be positive and finite.
|
|
243
270
|
*/
|
|
244
271
|
maxPoints?: number;
|
|
245
272
|
/** Width ÷ height of the plot. */
|
|
@@ -276,6 +303,8 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
276
303
|
momentumColors,
|
|
277
304
|
color,
|
|
278
305
|
onActivePointChange,
|
|
306
|
+
accessibilityLabel,
|
|
307
|
+
accessibilityHint,
|
|
279
308
|
children,
|
|
280
309
|
...props
|
|
281
310
|
},
|
|
@@ -290,12 +319,13 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
290
319
|
const domainMax = useSharedValue(0);
|
|
291
320
|
const activeTime = useSharedValue(-1);
|
|
292
321
|
const reducedMotion = useReducedMotion();
|
|
322
|
+
const [appState, setAppState] = useState(AppState.currentState ?? 'active');
|
|
293
323
|
// Stripped of punctuation: `useId` returns something like `:r1:`, and a
|
|
294
324
|
// colon inside a `url(#…)` reference does not resolve — which in RN SVG is
|
|
295
325
|
// a clip that silently does nothing rather than an error.
|
|
296
326
|
const clipId = `panelui-live-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
297
327
|
|
|
298
|
-
const windowMs =
|
|
328
|
+
const windowMs = normalizeLiveLineWindow(windowSeconds) * 1000;
|
|
299
329
|
const loading = status === 'loading';
|
|
300
330
|
|
|
301
331
|
const hasYAxis = useMemo(() => {
|
|
@@ -308,6 +338,34 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
308
338
|
return found;
|
|
309
339
|
}, [children]);
|
|
310
340
|
|
|
341
|
+
const semanticParts = useMemo(() => {
|
|
342
|
+
let title: string | undefined;
|
|
343
|
+
let value: string | undefined;
|
|
344
|
+
let headerFormat: ((reading: number) => string) | undefined;
|
|
345
|
+
let tipFormat: ((reading: number) => string) | undefined;
|
|
346
|
+
let tooltipFormat: ((reading: number) => string) | undefined;
|
|
347
|
+
Children.forEach(children, (child) => {
|
|
348
|
+
if (!isValidElement(child)) return;
|
|
349
|
+
const part = (child.type as { displayName?: string }).displayName;
|
|
350
|
+
if (part === 'LiveLineChart.Header') {
|
|
351
|
+
const header = child.props as LiveLineChartHeaderProps;
|
|
352
|
+
title ??= header.title;
|
|
353
|
+
value ??= header.value;
|
|
354
|
+
headerFormat ??= header.formatValue;
|
|
355
|
+
} else if (part === 'LiveLineChart.Tip') {
|
|
356
|
+
tipFormat ??= (child.props as LiveLineChartTipProps).formatValue;
|
|
357
|
+
} else if (part === 'LiveLineChart.Tooltip') {
|
|
358
|
+
tooltipFormat ??= (child.props as LiveLineChartTooltipProps).formatValue;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
title,
|
|
363
|
+
value,
|
|
364
|
+
formatLatest: headerFormat ?? tipFormat,
|
|
365
|
+
formatActive: tooltipFormat ?? headerFormat ?? tipFormat,
|
|
366
|
+
};
|
|
367
|
+
}, [children]);
|
|
368
|
+
|
|
311
369
|
const pad = { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
|
|
312
370
|
const plot: Plot = {
|
|
313
371
|
left: pad.left,
|
|
@@ -322,7 +380,7 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
322
380
|
* the one chart nobody bounded to be the one left running overnight.
|
|
323
381
|
*/
|
|
324
382
|
const points = useMemo(
|
|
325
|
-
() => (data
|
|
383
|
+
() => normalizeLiveLinePoints(data, maxPoints),
|
|
326
384
|
[data, maxPoints]
|
|
327
385
|
);
|
|
328
386
|
|
|
@@ -331,6 +389,11 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
331
389
|
values.value = points.map((point) => point.value);
|
|
332
390
|
}, [points, times, values]);
|
|
333
391
|
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
const subscription = AppState.addEventListener('change', setAppState);
|
|
394
|
+
return () => subscription.remove();
|
|
395
|
+
}, []);
|
|
396
|
+
|
|
334
397
|
const liveLatest = points.length ? points[points.length - 1]! : null;
|
|
335
398
|
|
|
336
399
|
/*
|
|
@@ -393,7 +456,7 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
393
456
|
now.value = Date.now();
|
|
394
457
|
}, false);
|
|
395
458
|
|
|
396
|
-
const running =
|
|
459
|
+
const running = liveLineClockRuns({ paused, loading, reducedMotion, appState });
|
|
397
460
|
|
|
398
461
|
useEffect(() => {
|
|
399
462
|
frame.setActive(running);
|
|
@@ -411,6 +474,13 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
411
474
|
now.value = Date.now();
|
|
412
475
|
}, [running, paused, points, now]);
|
|
413
476
|
|
|
477
|
+
// A frame callback is suspended with the app. Land on the wall clock as
|
|
478
|
+
// soon as it becomes active instead of showing the backgrounded window for
|
|
479
|
+
// one frame and relying on the platform to schedule a callback promptly.
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (appState === 'active' && !paused) now.value = Date.now();
|
|
482
|
+
}, [appState, paused, now]);
|
|
483
|
+
|
|
414
484
|
useImperativeHandle(
|
|
415
485
|
ref,
|
|
416
486
|
() => ({
|
|
@@ -434,6 +504,21 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
434
504
|
if (!paused) heldMomentum.current = liveMomentum;
|
|
435
505
|
const momentum = paused ? heldMomentum.current : liveMomentum;
|
|
436
506
|
|
|
507
|
+
const defaultFormat = (reading: number) => compactNumber(reading);
|
|
508
|
+
const semantic = liveLineAccessibility({
|
|
509
|
+
name: accessibilityLabel ?? semanticParts.title,
|
|
510
|
+
status,
|
|
511
|
+
latest,
|
|
512
|
+
activePoint,
|
|
513
|
+
momentum,
|
|
514
|
+
windowSeconds: windowMs / 1000,
|
|
515
|
+
paused,
|
|
516
|
+
now: Date.now(),
|
|
517
|
+
valueOverride: semanticParts.value,
|
|
518
|
+
formatLatest: semanticParts.formatLatest ?? defaultFormat,
|
|
519
|
+
formatActive: semanticParts.formatActive ?? defaultFormat,
|
|
520
|
+
});
|
|
521
|
+
|
|
437
522
|
const base = useSeriesColor(color, 1);
|
|
438
523
|
const successToken = useCSSVariable('--color-success');
|
|
439
524
|
const destructiveToken = useCSSVariable('--color-destructive');
|
|
@@ -458,6 +543,13 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
458
543
|
[onActivePointChange]
|
|
459
544
|
);
|
|
460
545
|
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
const next = reconcileLiveLineActivePoint(activePoint, points);
|
|
548
|
+
if (next === activePoint) return;
|
|
549
|
+
if (!next) activeTime.value = -1;
|
|
550
|
+
setActivePoint(next);
|
|
551
|
+
}, [activePoint, points, activeTime, setActivePoint]);
|
|
552
|
+
|
|
461
553
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
462
554
|
const { width, height } = event.nativeEvent.layout;
|
|
463
555
|
setSize((current) =>
|
|
@@ -519,8 +611,21 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
|
|
|
519
611
|
return (
|
|
520
612
|
<LiveLineChartContext.Provider value={context}>
|
|
521
613
|
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
614
|
+
<View
|
|
615
|
+
accessible
|
|
616
|
+
accessibilityRole="image"
|
|
617
|
+
accessibilityLabel={semantic.label}
|
|
618
|
+
accessibilityHint={accessibilityHint}
|
|
619
|
+
style={{ position: 'absolute', left: -10_000, width: 1, height: 1 }}
|
|
620
|
+
/>
|
|
522
621
|
{header}
|
|
523
|
-
<View
|
|
622
|
+
<View
|
|
623
|
+
onLayout={onLayout}
|
|
624
|
+
style={{ aspectRatio }}
|
|
625
|
+
className="w-full"
|
|
626
|
+
accessibilityElementsHidden
|
|
627
|
+
importantForAccessibility="no-hide-descendants"
|
|
628
|
+
>
|
|
524
629
|
{plot.width > 0 ? (
|
|
525
630
|
<>
|
|
526
631
|
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
@@ -1026,6 +1131,14 @@ function LiveLineChartTooltip({ formatValue, className }: LiveLineChartTooltipPr
|
|
|
1026
1131
|
[setActivePoint]
|
|
1027
1132
|
);
|
|
1028
1133
|
|
|
1134
|
+
useEffect(
|
|
1135
|
+
() => () => {
|
|
1136
|
+
activeTime.value = -1;
|
|
1137
|
+
setActivePoint(null);
|
|
1138
|
+
},
|
|
1139
|
+
[activeTime, setActivePoint]
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1029
1142
|
const pan = useMemo(() => {
|
|
1030
1143
|
const resolve = (x: number) => {
|
|
1031
1144
|
'worklet';
|
|
@@ -1259,7 +1372,11 @@ function LiveLineChartHeader({
|
|
|
1259
1372
|
{...props}
|
|
1260
1373
|
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
1261
1374
|
>
|
|
1262
|
-
<View
|
|
1375
|
+
<View
|
|
1376
|
+
className="flex-1 gap-0.5"
|
|
1377
|
+
accessibilityElementsHidden
|
|
1378
|
+
importantForAccessibility="no-hide-descendants"
|
|
1379
|
+
>
|
|
1263
1380
|
{title ? (
|
|
1264
1381
|
<Text size="xs" muted>
|
|
1265
1382
|
{title}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface LiveLineAccessiblePoint {
|
|
2
|
+
time: number;
|
|
3
|
+
value: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface LiveLineAccessibilityInput {
|
|
7
|
+
name?: string;
|
|
8
|
+
status: 'loading' | 'ready';
|
|
9
|
+
latest: LiveLineAccessiblePoint | null;
|
|
10
|
+
activePoint: LiveLineAccessiblePoint | null;
|
|
11
|
+
momentum: 'up' | 'down' | 'flat';
|
|
12
|
+
windowSeconds: number;
|
|
13
|
+
paused: boolean;
|
|
14
|
+
now: number;
|
|
15
|
+
valueOverride?: string;
|
|
16
|
+
formatLatest: (value: number) => string;
|
|
17
|
+
formatActive: (value: number) => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One bounded snapshot for a stream, rather than one node per arriving point. */
|
|
21
|
+
export function liveLineAccessibility({
|
|
22
|
+
name,
|
|
23
|
+
status,
|
|
24
|
+
latest,
|
|
25
|
+
activePoint,
|
|
26
|
+
momentum,
|
|
27
|
+
windowSeconds,
|
|
28
|
+
paused,
|
|
29
|
+
now,
|
|
30
|
+
valueOverride,
|
|
31
|
+
formatLatest,
|
|
32
|
+
formatActive,
|
|
33
|
+
}: LiveLineAccessibilityInput) {
|
|
34
|
+
const parts = [name?.trim() || 'Live line chart'];
|
|
35
|
+
|
|
36
|
+
if (status === 'loading') {
|
|
37
|
+
parts.push('Loading');
|
|
38
|
+
if (paused) parts.push('Paused');
|
|
39
|
+
return { label: parts.join('. ') };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!latest) {
|
|
43
|
+
parts.push('No readings');
|
|
44
|
+
if (paused) parts.push('Paused');
|
|
45
|
+
return { label: parts.join('. ') };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (activePoint) {
|
|
49
|
+
const secondsAgo = Math.max(0, Math.round((now - activePoint.time) / 1000));
|
|
50
|
+
parts.push(`Selected value, ${formatActive(activePoint.value)}`);
|
|
51
|
+
parts.push(secondsAgo === 0 ? 'Just now' : `${secondsAgo} seconds ago`);
|
|
52
|
+
} else {
|
|
53
|
+
parts.push(`Current value, ${valueOverride ?? formatLatest(latest.value)}`);
|
|
54
|
+
const direction = momentum === 'up' ? 'rising' : momentum === 'down' ? 'falling' : 'steady';
|
|
55
|
+
parts.push(`Trend, ${direction}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
parts.push(`${windowSeconds}-second window`);
|
|
59
|
+
if (paused) parts.push('Paused');
|
|
60
|
+
return { label: parts.join('. ') };
|
|
61
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AppStateStatus } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export interface LiveLineReading {
|
|
4
|
+
time: number;
|
|
5
|
+
value: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_LIVE_LINE_WINDOW = 30;
|
|
9
|
+
export const DEFAULT_LIVE_LINE_MAX_POINTS = 500;
|
|
10
|
+
|
|
11
|
+
export function normalizeLiveLineWindow(seconds: number): number {
|
|
12
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
13
|
+
? Math.max(seconds, 1)
|
|
14
|
+
: DEFAULT_LIVE_LINE_WINDOW;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeLiveLineMaxPoints(maxPoints: number): number {
|
|
18
|
+
return Number.isFinite(maxPoints) && maxPoints > 0
|
|
19
|
+
? Math.max(1, Math.floor(maxPoints))
|
|
20
|
+
: DEFAULT_LIVE_LINE_MAX_POINTS;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Builds the one ordered, finite buffer every renderer and interaction reads.
|
|
25
|
+
* For duplicate timestamps the last input wins, matching a corrected reading
|
|
26
|
+
* arriving with the same identity as the one it replaces.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeLiveLinePoints<T extends LiveLineReading>(
|
|
29
|
+
data: readonly T[],
|
|
30
|
+
maxPoints: number,
|
|
31
|
+
): T[] {
|
|
32
|
+
const byTime = new Map<number, T>();
|
|
33
|
+
for (const point of data) {
|
|
34
|
+
if (Number.isFinite(point.time) && Number.isFinite(point.value)) {
|
|
35
|
+
byTime.set(point.time, point);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...byTime.values()]
|
|
39
|
+
.sort((left, right) => left.time - right.time)
|
|
40
|
+
.slice(-normalizeLiveLineMaxPoints(maxPoints));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function liveLineClockRuns(options: {
|
|
44
|
+
paused: boolean;
|
|
45
|
+
loading: boolean;
|
|
46
|
+
reducedMotion: boolean;
|
|
47
|
+
appState: AppStateStatus;
|
|
48
|
+
}): boolean {
|
|
49
|
+
return (
|
|
50
|
+
!options.paused &&
|
|
51
|
+
!options.loading &&
|
|
52
|
+
!options.reducedMotion &&
|
|
53
|
+
options.appState === 'active'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function reconcileLiveLineActivePoint<T extends LiveLineReading>(
|
|
58
|
+
active: T | null,
|
|
59
|
+
points: readonly T[],
|
|
60
|
+
): T | null {
|
|
61
|
+
if (!active) return null;
|
|
62
|
+
return points.find((point) => point.time === active.time) ?? null;
|
|
63
|
+
}
|
|
@@ -100,21 +100,21 @@ const MarkerRoot = forwardRef<View, MarkerProps>(
|
|
|
100
100
|
const body = !onPress ? (
|
|
101
101
|
<View
|
|
102
102
|
ref={ref}
|
|
103
|
+
{...(props as ViewProps)}
|
|
103
104
|
accessibilityState={{ disabled: !!disabled }}
|
|
104
105
|
className={root({ className })}
|
|
105
|
-
{...(props as ViewProps)}
|
|
106
106
|
>
|
|
107
107
|
{inner}
|
|
108
108
|
</View>
|
|
109
109
|
) : (
|
|
110
110
|
<AnimatedPressable
|
|
111
111
|
ref={ref}
|
|
112
|
+
{...props}
|
|
112
113
|
accessibilityRole="button"
|
|
113
114
|
accessibilityState={{ disabled: !!disabled }}
|
|
114
115
|
disabled={disabled}
|
|
115
116
|
onPress={onPress}
|
|
116
117
|
className={root({ className })}
|
|
117
|
-
{...props}
|
|
118
118
|
>
|
|
119
119
|
{inner}
|
|
120
120
|
</AnimatedPressable>
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marquee — content that travels across its container and never runs out.
|
|
3
|
+
*
|
|
4
|
+
* For a strip of logos, a ticker of prices, a row of testimonials: anything
|
|
5
|
+
* whose job is to keep moving past a boundary rather than to be scrolled.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Marquee spacing={24} speed={40}>
|
|
9
|
+
* <View className="flex-row gap-6">
|
|
10
|
+
* {sponsors.map((s) => <Logo key={s.id} src={s.logo} />)}
|
|
11
|
+
* </View>
|
|
12
|
+
* </Marquee>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## How it loops
|
|
16
|
+
*
|
|
17
|
+
* The content is measured once, then laid out end to end enough times to cover
|
|
18
|
+
* the container twice over. One track holds every copy and it is that track,
|
|
19
|
+
* not the copies, that moves — a single animated node for any amount of
|
|
20
|
+
* content. It travels exactly one copy-and-gap and starts over, so the state it
|
|
21
|
+
* ends on is the state it began on and the seam never shows.
|
|
22
|
+
*
|
|
23
|
+
* The travel is a linear timing driven on the UI thread, so it costs nothing
|
|
24
|
+
* per frame in JavaScript and keeps running while the thread is busy.
|
|
25
|
+
*
|
|
26
|
+
* ## Measurement needs room
|
|
27
|
+
*
|
|
28
|
+
* The copy that gets measured sits in a hidden scroller, because that is what
|
|
29
|
+
* lets the content report the width it *wants* rather than the width the
|
|
30
|
+
* container would give it. Content with no intrinsic size of its own — a child
|
|
31
|
+
* stretched to `flex-1`, an image with no dimensions — measures as nothing and
|
|
32
|
+
* the marquee will not start.
|
|
33
|
+
*
|
|
34
|
+
* ## Reading direction and reduced motion
|
|
35
|
+
*
|
|
36
|
+
* A horizontal marquee travels toward the end of the line, so it reverses in a
|
|
37
|
+
* right-to-left subtree. `reverse` flips it again from wherever it landed.
|
|
38
|
+
*
|
|
39
|
+
* With the operating system set to reduce motion the content is rendered once
|
|
40
|
+
* and held still. A ticker that never stops is the exact thing that setting is
|
|
41
|
+
* there to turn off, so this is not a shorter animation — it is none.
|
|
42
|
+
*
|
|
43
|
+
* Screen readers get one copy. The rest are duplicates of content already
|
|
44
|
+
* announced, and hearing a sponsor list four times over is not thoroughness.
|
|
45
|
+
*/
|
|
46
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
47
|
+
import {
|
|
48
|
+
ScrollView,
|
|
49
|
+
StyleSheet,
|
|
50
|
+
View,
|
|
51
|
+
type LayoutChangeEvent,
|
|
52
|
+
type ViewProps,
|
|
53
|
+
} from 'react-native';
|
|
54
|
+
import Animated, {
|
|
55
|
+
cancelAnimation,
|
|
56
|
+
Easing,
|
|
57
|
+
useAnimatedStyle,
|
|
58
|
+
useReducedMotion,
|
|
59
|
+
useSharedValue,
|
|
60
|
+
withRepeat,
|
|
61
|
+
withTiming,
|
|
62
|
+
} from 'react-native-reanimated';
|
|
63
|
+
import { useDirectionSign } from '../../hooks/use-direction';
|
|
64
|
+
import { cn } from '../../utils/cn';
|
|
65
|
+
import {
|
|
66
|
+
DEFAULT_MARQUEE_SPEED,
|
|
67
|
+
marqueeCopyCount,
|
|
68
|
+
normalizeMarqueeSpeed,
|
|
69
|
+
} from './marquee-math';
|
|
70
|
+
|
|
71
|
+
export type MarqueeDirection = 'horizontal' | 'vertical';
|
|
72
|
+
|
|
73
|
+
export interface MarqueeProps extends Omit<ViewProps, 'children'> {
|
|
74
|
+
className?: string;
|
|
75
|
+
/** The content to repeat. Measured once, then tiled along the axis. */
|
|
76
|
+
children: ReactNode;
|
|
77
|
+
/**
|
|
78
|
+
* Travel speed in points per second. 40 by default, which is slow enough
|
|
79
|
+
* that a word stays readable as it crosses. The cycle time follows from this
|
|
80
|
+
* and the measured content, so longer content takes proportionally longer
|
|
81
|
+
* rather than moving faster.
|
|
82
|
+
*/
|
|
83
|
+
speed?: number;
|
|
84
|
+
/** Gap between the end of one copy and the start of the next. */
|
|
85
|
+
spacing?: number;
|
|
86
|
+
/** Axis the content travels along. */
|
|
87
|
+
direction?: MarqueeDirection;
|
|
88
|
+
/**
|
|
89
|
+
* Send it the other way: toward the start of the line, or upward. Applied
|
|
90
|
+
* after the reading direction, not instead of it.
|
|
91
|
+
*/
|
|
92
|
+
reverse?: boolean;
|
|
93
|
+
/** Set false to hold the content where it is. */
|
|
94
|
+
playing?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function Marquee({
|
|
98
|
+
className,
|
|
99
|
+
children,
|
|
100
|
+
speed: speedProp = DEFAULT_MARQUEE_SPEED,
|
|
101
|
+
spacing = 0,
|
|
102
|
+
direction = 'horizontal',
|
|
103
|
+
reverse = false,
|
|
104
|
+
playing = true,
|
|
105
|
+
style,
|
|
106
|
+
onLayout,
|
|
107
|
+
...props
|
|
108
|
+
}: MarqueeProps) {
|
|
109
|
+
const horizontal = direction === 'horizontal';
|
|
110
|
+
const reducedMotion = useReducedMotion();
|
|
111
|
+
// Only the horizontal axis has a reading direction to follow; up is up.
|
|
112
|
+
const sign = useDirectionSign();
|
|
113
|
+
const flip = horizontal ? sign : 1;
|
|
114
|
+
|
|
115
|
+
const [viewport, setViewport] = useState(0);
|
|
116
|
+
const [content, setContent] = useState(0);
|
|
117
|
+
const speed = normalizeMarqueeSpeed(speedProp);
|
|
118
|
+
|
|
119
|
+
// The distance from one copy to the same point on the next, and therefore
|
|
120
|
+
// both the layout step and the exact loop length. They are the same number
|
|
121
|
+
// on purpose: taking the gap from anywhere else is how a seam appears.
|
|
122
|
+
const layout = useMemo(
|
|
123
|
+
() => marqueeCopyCount(viewport, content, spacing),
|
|
124
|
+
[viewport, content, spacing]
|
|
125
|
+
);
|
|
126
|
+
const { period } = layout;
|
|
127
|
+
|
|
128
|
+
const copies = useMemo(() => {
|
|
129
|
+
// Enough to span the container, plus one trailing into view and one
|
|
130
|
+
// already past it — the two the travel consumes before the loop restarts.
|
|
131
|
+
return Array.from({ length: layout.count }, (_, index) => index);
|
|
132
|
+
}, [layout.count]);
|
|
133
|
+
|
|
134
|
+
const offset = useSharedValue(0);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
cancelAnimation(offset);
|
|
138
|
+
offset.value = 0;
|
|
139
|
+
if (reducedMotion || !playing || period <= 0 || speed <= 0) return undefined;
|
|
140
|
+
offset.value = withRepeat(
|
|
141
|
+
withTiming(period, {
|
|
142
|
+
duration: (period / speed) * 1000,
|
|
143
|
+
easing: Easing.linear,
|
|
144
|
+
}),
|
|
145
|
+
-1,
|
|
146
|
+
false
|
|
147
|
+
);
|
|
148
|
+
return () => cancelAnimation(offset);
|
|
149
|
+
}, [offset, period, playing, reducedMotion, speed]);
|
|
150
|
+
|
|
151
|
+
const trackStyle = useAnimatedStyle(() => {
|
|
152
|
+
const travel = (reverse ? offset.value : -offset.value) * flip;
|
|
153
|
+
return {
|
|
154
|
+
transform: [horizontal ? { translateX: travel } : { translateY: travel }],
|
|
155
|
+
};
|
|
156
|
+
}, [horizontal, reverse, flip]);
|
|
157
|
+
|
|
158
|
+
// The container's own measurement is what sizes the loop, so an `onLayout`
|
|
159
|
+
// passed in is called alongside it rather than replacing it.
|
|
160
|
+
const onContainerLayout = (event: LayoutChangeEvent) => {
|
|
161
|
+
const { width, height } = event.nativeEvent.layout;
|
|
162
|
+
setViewport(horizontal ? width : height);
|
|
163
|
+
onLayout?.(event);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const onContentLayout = (event: LayoutChangeEvent) => {
|
|
167
|
+
const { width, height } = event.nativeEvent.layout;
|
|
168
|
+
setContent(horizontal ? width : height);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Nothing to loop, so nothing to clip or clone: render the content plainly
|
|
172
|
+
// and let it sit where it falls.
|
|
173
|
+
if (reducedMotion) {
|
|
174
|
+
return (
|
|
175
|
+
<View
|
|
176
|
+
className={cn('overflow-hidden', className)}
|
|
177
|
+
style={style}
|
|
178
|
+
onLayout={onLayout}
|
|
179
|
+
{...props}
|
|
180
|
+
>
|
|
181
|
+
{children}
|
|
182
|
+
</View>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<View
|
|
188
|
+
className={cn('overflow-hidden', className)}
|
|
189
|
+
style={style}
|
|
190
|
+
onLayout={onContainerLayout}
|
|
191
|
+
{...props}
|
|
192
|
+
>
|
|
193
|
+
{/* Measured, never seen. A scroller on this axis is what frees the
|
|
194
|
+
content to report its own size instead of the container's. */}
|
|
195
|
+
<ScrollView
|
|
196
|
+
horizontal={horizontal}
|
|
197
|
+
scrollEnabled={false}
|
|
198
|
+
style={styles.measure}
|
|
199
|
+
pointerEvents="none"
|
|
200
|
+
accessibilityElementsHidden
|
|
201
|
+
importantForAccessibility="no-hide-descendants"
|
|
202
|
+
>
|
|
203
|
+
<View onLayout={onContentLayout}>{children}</View>
|
|
204
|
+
</ScrollView>
|
|
205
|
+
|
|
206
|
+
<Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
|
|
207
|
+
{copies.map((index) => {
|
|
208
|
+
// Index 1 is the copy that starts flush with the container's edge,
|
|
209
|
+
// and it is the one a screen reader is given.
|
|
210
|
+
const spoken = index === 1;
|
|
211
|
+
const at = (index - 1) * period;
|
|
212
|
+
return (
|
|
213
|
+
<View
|
|
214
|
+
key={index}
|
|
215
|
+
style={[styles.copy, horizontal ? { left: at } : { top: at }]}
|
|
216
|
+
pointerEvents="box-none"
|
|
217
|
+
accessibilityElementsHidden={!spoken}
|
|
218
|
+
importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
|
|
219
|
+
>
|
|
220
|
+
{children}
|
|
221
|
+
</View>
|
|
222
|
+
);
|
|
223
|
+
})}
|
|
224
|
+
</Animated.View>
|
|
225
|
+
</View>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const styles = StyleSheet.create({
|
|
230
|
+
/* Laid out so it measures, hidden so it does not draw, and behind everything
|
|
231
|
+
so it can never intercept anything. */
|
|
232
|
+
measure: { opacity: 0, zIndex: -1 },
|
|
233
|
+
copy: { position: 'absolute' },
|
|
234
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind the loop, kept out of the component so it can be tested
|
|
3
|
+
* without rendering one.
|
|
4
|
+
*
|
|
5
|
+
* Every value here reaches a Reanimated timing or a layout offset, and both
|
|
6
|
+
* treat `NaN` and `Infinity` as a frame that never resolves rather than as an
|
|
7
|
+
* error. A marquee handed a bad number should hold still, not wedge — so the
|
|
8
|
+
* guards live at the boundary, once, instead of at each use.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Points per second. Slow enough that a word stays readable as it crosses,
|
|
13
|
+
* which is the speed a ticker is actually for.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_MARQUEE_SPEED = 40;
|
|
16
|
+
|
|
17
|
+
export function normalizeMarqueeSpeed(value: number): number {
|
|
18
|
+
if (!Number.isFinite(value)) return DEFAULT_MARQUEE_SPEED;
|
|
19
|
+
return Math.max(value, 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeMarqueeSpacing(value: number): number {
|
|
23
|
+
if (!Number.isFinite(value)) return 0;
|
|
24
|
+
return Math.max(value, 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function marqueeCopyCount(
|
|
28
|
+
viewport: number,
|
|
29
|
+
content: number,
|
|
30
|
+
spacing: number
|
|
31
|
+
): { period: number; count: number } {
|
|
32
|
+
const safeViewport = Number.isFinite(viewport) ? Math.max(viewport, 0) : 0;
|
|
33
|
+
const safeContent = Number.isFinite(content) ? Math.max(content, 0) : 0;
|
|
34
|
+
const period = safeContent > 0 ? safeContent + normalizeMarqueeSpacing(spacing) : 0;
|
|
35
|
+
if (period <= 0 || safeViewport <= 0) return { period, count: 0 };
|
|
36
|
+
return { period, count: Math.ceil(safeViewport / period) + 2 };
|
|
37
|
+
}
|