@chipmobilesdk/rn-chart 0.2.0 → 0.2.1

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 (38) hide show
  1. package/CHANGELOG.md +126 -115
  2. package/README.md +381 -381
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -136
  7. package/src/contract/errors.ts +129 -133
  8. package/src/contract/format.ts +162 -168
  9. package/src/contract/interaction.ts +70 -71
  10. package/src/contract/marker.ts +75 -75
  11. package/src/contract/props.ts +57 -60
  12. package/src/contract/state.ts +88 -94
  13. package/src/contract/theme.ts +169 -177
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -273
  16. package/src/geometry/distribution.ts +197 -203
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -173
  20. package/src/geometry/markers.ts +55 -57
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -230
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -168
  26. package/src/geometry/types.ts +94 -94
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -622
  30. package/src/react/ChartLegend.tsx +211 -215
  31. package/src/react/ChartThemeProvider.tsx +44 -48
  32. package/src/react/charts.tsx +154 -161
  33. package/src/react/useChartLayout.ts +124 -124
  34. package/src/react/useChartSelection.ts +152 -170
  35. package/src/render/Axis.tsx +120 -122
  36. package/src/render/Marks.tsx +173 -205
  37. package/src/validation/budgets.ts +103 -110
  38. package/src/validation/validateDatasource.ts +174 -174
@@ -1,168 +1,162 @@
1
- /**
2
- * Formatting contract (FR-055 to FR-059).
3
- *
4
- * The package never invents or converts a business unit. It accepts a hint,
5
- * applies a documented locale-neutral default, and uses app-supplied formatter
6
- * output verbatim whenever one is given.
7
- */
8
- import type { Position, Series } from './datasource';
9
- import { isKnown, type ValueState } from './value';
10
-
11
- export type FormatHint =
12
- | { readonly kind: 'number'; readonly decimals?: number }
13
- | { readonly kind: 'percent'; readonly decimals?: number }
14
- | { readonly kind: 'currency'; readonly currency: string }
15
- | {
16
- readonly kind: 'duration';
17
- readonly unit: 'ms' | 's' | 'min' | 'h';
18
- }
19
- | { readonly kind: 'count' }
20
- | { readonly kind: 'custom'; readonly unit: string };
21
-
22
- export interface FormatContext {
23
- readonly hint?: FormatHint;
24
- readonly unit?: string;
25
- /** Where the text will appear, so a formatter can adapt its verbosity. */
26
- readonly slot: 'axis' | 'value' | 'legend' | 'summary';
27
- }
28
-
29
- export interface SeriesRef {
30
- readonly id: string;
31
- readonly label: string;
32
- }
33
-
34
- export interface ChartFormatters {
35
- readonly axisLabel?: (
36
- v: ValueState | Position,
37
- ctx: FormatContext,
38
- ) => string;
39
- readonly value?: (v: ValueState, ctx: FormatContext) => string;
40
- readonly legendLabel?: (series: SeriesRef, ctx: FormatContext) => string;
41
- readonly summary?: (v: ValueState, ctx: FormatContext) => string;
42
- }
43
-
44
- /**
45
- * Placeholders for the three non-value classes. Deliberately not "0" and not
46
- * an empty string, so a reader can tell a gap from a zero at a glance.
47
- */
48
- export const NON_VALUE_TEXT: Readonly<Record<string, string>> = {
49
- unknown: '—',
50
- notApplicable: 'n/a',
51
- withheld: '••',
52
- };
53
-
54
- function trimTrailingZeros(text: string): string {
55
- return text.includes('.') ? text.replace(/\.?0+$/, '') : text;
56
- }
57
-
58
- /**
59
- * Locale-neutral default formatting (FR-057).
60
- *
61
- * Deliberately plain: no thousands separators, no currency symbol lookup, no
62
- * month names. Anything locale-dependent belongs to the consuming app, which
63
- * owns an i18n system the package does not.
64
- */
65
- export function formatValueDefault(
66
- v: ValueState,
67
- ctx: FormatContext = { slot: 'value' },
68
- ): string {
69
- if (!isKnown(v)) {
70
- return NON_VALUE_TEXT[v.kind] ?? NON_VALUE_TEXT.unknown;
71
- }
72
-
73
- const hint = ctx.hint;
74
- const n = v.value;
75
-
76
- switch (hint?.kind) {
77
- case 'percent': {
78
- const decimals = hint.decimals ?? 0;
79
- return `${(n * 100).toFixed(decimals)}%`;
80
- }
81
- case 'currency':
82
- // Code rather than symbol: symbol placement is locale-dependent and
83
- // therefore the app's decision.
84
- return `${n.toFixed(2)} ${hint.currency}`;
85
- case 'duration':
86
- return `${trimTrailingZeros(n.toFixed(2))}${hint.unit}`;
87
- case 'count':
88
- return String(Math.round(n));
89
- case 'custom':
90
- return `${trimTrailingZeros(n.toFixed(2))} ${hint.unit}`;
91
- case 'number':
92
- return hint.decimals === undefined
93
- ? trimTrailingZeros(n.toFixed(2))
94
- : n.toFixed(hint.decimals);
95
- default:
96
- return trimTrailingZeros(n.toFixed(2));
97
- }
98
- }
99
-
100
- export function formatPositionDefault(p: Position): string {
101
- switch (p.kind) {
102
- case 'category':
103
- return p.category;
104
- case 'matrix':
105
- return `${p.row} / ${p.column}`;
106
- case 'instant':
107
- // Epoch milliseconds as-is. Any human-readable date belongs to an app
108
- // formatter, which alone knows the locale and the display timezone
109
- // (FR-016).
110
- return String(p.at);
111
- }
112
- }
113
-
114
- export function formatLegendDefault(series: SeriesRef): string {
115
- return series.label;
116
- }
117
-
118
- /**
119
- * Runs an app formatter, falling back to the package default for that single
120
- * label if it throws (FR-059). One bad formatter must not take down a screen.
121
- *
122
- * The dev-only warning is intentionally value-free: it names the slot, never
123
- * the data.
124
- */
125
- export function safeFormat(
126
- run: () => string,
127
- fallback: () => string,
128
- slot: FormatContext['slot'],
129
- onceKey?: string,
130
- ): string {
131
- try {
132
- return run();
133
- } catch (e) {
134
- if (__DEV__) {
135
- warnFormatterFailureOnce(slot, onceKey, e);
136
- }
137
- return fallback();
138
- }
139
- }
140
-
141
- const warnedFormatters = new Set<string>();
142
-
143
- function warnFormatterFailureOnce(
144
- slot: FormatContext['slot'],
145
- onceKey: string | undefined,
146
- error: unknown,
147
- ): void {
148
- const key = `${slot}:${onceKey ?? 'default'}`;
149
- if (warnedFormatters.has(key)) {
150
- return;
151
- }
152
- warnedFormatters.add(key);
153
- const reason = error instanceof Error ? error.message : 'unknown error';
154
- console.warn(
155
- `[rn-chart] A supplied "${slot}" formatter threw (${reason}). ` +
156
- 'Falling back to the built-in default for that label. ' +
157
- 'This warning appears once per formatter slot and never in production builds.',
158
- );
159
- }
160
-
161
- /** Test-only reset so warning-once behaviour can be asserted repeatedly. */
162
- export function __resetFormatterWarnings(): void {
163
- warnedFormatters.clear();
164
- }
165
-
166
- export function seriesRef<M>(series: Series<M>): SeriesRef {
167
- return { id: series.id, label: series.label };
168
- }
1
+ /**
2
+ * Formatting contract (FR-055 to FR-059).
3
+ *
4
+ * The package never invents or converts a business unit. It accepts a hint,
5
+ * applies a documented locale-neutral default, and uses app-supplied formatter
6
+ * output verbatim whenever one is given.
7
+ */
8
+ import type { Position, Series } from './datasource';
9
+ import { isKnown, type ValueState } from './value';
10
+
11
+ export type FormatHint =
12
+ | { readonly kind: 'number'; readonly decimals?: number }
13
+ | { readonly kind: 'percent'; readonly decimals?: number }
14
+ | { readonly kind: 'currency'; readonly currency: string }
15
+ | {
16
+ readonly kind: 'duration';
17
+ readonly unit: 'ms' | 's' | 'min' | 'h';
18
+ }
19
+ | { readonly kind: 'count' }
20
+ | { readonly kind: 'custom'; readonly unit: string };
21
+
22
+ export interface FormatContext {
23
+ readonly hint?: FormatHint;
24
+ readonly unit?: string;
25
+ /** Where the text will appear, so a formatter can adapt its verbosity. */
26
+ readonly slot: 'axis' | 'value' | 'legend' | 'summary';
27
+ }
28
+
29
+ export interface SeriesRef {
30
+ readonly id: string;
31
+ readonly label: string;
32
+ }
33
+
34
+ export interface ChartFormatters {
35
+ readonly axisLabel?: (v: ValueState | Position, ctx: FormatContext) => string;
36
+ readonly value?: (v: ValueState, ctx: FormatContext) => string;
37
+ readonly legendLabel?: (series: SeriesRef, ctx: FormatContext) => string;
38
+ readonly summary?: (v: ValueState, ctx: FormatContext) => string;
39
+ }
40
+
41
+ /**
42
+ * Placeholders for the three non-value classes. Deliberately not "0" and not
43
+ * an empty string, so a reader can tell a gap from a zero at a glance.
44
+ */
45
+ export const NON_VALUE_TEXT: Readonly<Record<string, string>> = {
46
+ unknown: '—',
47
+ notApplicable: 'n/a',
48
+ withheld: '••',
49
+ };
50
+
51
+ function trimTrailingZeros(text: string): string {
52
+ return text.includes('.') ? text.replace(/\.?0+$/, '') : text;
53
+ }
54
+
55
+ /**
56
+ * Locale-neutral default formatting (FR-057).
57
+ *
58
+ * Deliberately plain: no thousands separators, no currency symbol lookup, no
59
+ * month names. Anything locale-dependent belongs to the consuming app, which
60
+ * owns an i18n system the package does not.
61
+ */
62
+ export function formatValueDefault(v: ValueState, ctx: FormatContext = { slot: 'value' }): string {
63
+ if (!isKnown(v)) {
64
+ return NON_VALUE_TEXT[v.kind] ?? NON_VALUE_TEXT.unknown;
65
+ }
66
+
67
+ const hint = ctx.hint;
68
+ const n = v.value;
69
+
70
+ switch (hint?.kind) {
71
+ case 'percent': {
72
+ const decimals = hint.decimals ?? 0;
73
+ return `${(n * 100).toFixed(decimals)}%`;
74
+ }
75
+ case 'currency':
76
+ // Code rather than symbol: symbol placement is locale-dependent and
77
+ // therefore the app's decision.
78
+ return `${n.toFixed(2)} ${hint.currency}`;
79
+ case 'duration':
80
+ return `${trimTrailingZeros(n.toFixed(2))}${hint.unit}`;
81
+ case 'count':
82
+ return String(Math.round(n));
83
+ case 'custom':
84
+ return `${trimTrailingZeros(n.toFixed(2))} ${hint.unit}`;
85
+ case 'number':
86
+ return hint.decimals === undefined
87
+ ? trimTrailingZeros(n.toFixed(2))
88
+ : n.toFixed(hint.decimals);
89
+ default:
90
+ return trimTrailingZeros(n.toFixed(2));
91
+ }
92
+ }
93
+
94
+ export function formatPositionDefault(p: Position): string {
95
+ switch (p.kind) {
96
+ case 'category':
97
+ return p.category;
98
+ case 'matrix':
99
+ return `${p.row} / ${p.column}`;
100
+ case 'instant':
101
+ // Epoch milliseconds as-is. Any human-readable date belongs to an app
102
+ // formatter, which alone knows the locale and the display timezone
103
+ // (FR-016).
104
+ return String(p.at);
105
+ }
106
+ }
107
+
108
+ export function formatLegendDefault(series: SeriesRef): string {
109
+ return series.label;
110
+ }
111
+
112
+ /**
113
+ * Runs an app formatter, falling back to the package default for that single
114
+ * label if it throws (FR-059). One bad formatter must not take down a screen.
115
+ *
116
+ * The dev-only warning is intentionally value-free: it names the slot, never
117
+ * the data.
118
+ */
119
+ export function safeFormat(
120
+ run: () => string,
121
+ fallback: () => string,
122
+ slot: FormatContext['slot'],
123
+ onceKey?: string,
124
+ ): string {
125
+ try {
126
+ return run();
127
+ } catch (e) {
128
+ if (__DEV__) {
129
+ warnFormatterFailureOnce(slot, onceKey, e);
130
+ }
131
+ return fallback();
132
+ }
133
+ }
134
+
135
+ const warnedFormatters = new Set<string>();
136
+
137
+ function warnFormatterFailureOnce(
138
+ slot: FormatContext['slot'],
139
+ onceKey: string | undefined,
140
+ error: unknown,
141
+ ): void {
142
+ const key = `${slot}:${onceKey ?? 'default'}`;
143
+ if (warnedFormatters.has(key)) {
144
+ return;
145
+ }
146
+ warnedFormatters.add(key);
147
+ const reason = error instanceof Error ? error.message : 'unknown error';
148
+ console.warn(
149
+ `[rn-chart] A supplied "${slot}" formatter threw (${reason}). ` +
150
+ 'Falling back to the built-in default for that label. ' +
151
+ 'This warning appears once per formatter slot and never in production builds.',
152
+ );
153
+ }
154
+
155
+ /** Test-only reset so warning-once behaviour can be asserted repeatedly. */
156
+ export function __resetFormatterWarnings(): void {
157
+ warnedFormatters.clear();
158
+ }
159
+
160
+ export function seriesRef<M>(series: Series<M>): SeriesRef {
161
+ return { id: series.id, label: series.label };
162
+ }
@@ -1,71 +1,70 @@
1
- /**
2
- * Interaction contract (FR-048 to FR-054a).
3
- *
4
- * The package reports what the user touched and does nothing else: no
5
- * navigation, no fetching, no datasource mutation, no business rules.
6
- *
7
- * Selection is **uncontrolled by default and controlled when the app supplies a
8
- * value** — the standard React pattern. The same callbacks fire either way, so
9
- * a consumer can move between the two without rewriting handlers.
10
- */
11
- import type { Position } from './datasource';
12
- import type { ValueState } from './value';
13
-
14
- export interface SelectionRef {
15
- readonly seriesId: string;
16
- /** Absent for a whole-series selection. */
17
- readonly pointId?: string;
18
- }
19
-
20
- export interface PointInteractionEvent<M = unknown> {
21
- readonly type: 'pointSelect' | 'segmentPress';
22
- readonly seriesId: string;
23
- readonly pointId: string;
24
- readonly value: ValueState;
25
- readonly position: Position;
26
- readonly metadata?: M;
27
- }
28
-
29
- export interface SeriesInteractionEvent<M = unknown> {
30
- readonly type: 'seriesSelect' | 'visibilityChange';
31
- readonly seriesId: string;
32
- readonly visible?: boolean;
33
- readonly metadata?: M;
34
- }
35
-
36
- export type ChartInteractionEvent<M = unknown> =
37
- | PointInteractionEvent<M>
38
- | SeriesInteractionEvent<M>;
39
-
40
- export interface InteractionProps<M = unknown> {
41
- /**
42
- * Omit for uncontrolled selection. Supply — including `null` — to take
43
- * ownership; the chart then renders exactly what this says.
44
- */
45
- readonly selection?: SelectionRef | null;
46
- readonly onSelectionChange?: (
47
- next: SelectionRef | null,
48
- event: PointInteractionEvent<M> | null,
49
- ) => void;
50
- /**
51
- * Fires when an app-controlled selection no longer resolves against the
52
- * current datasource, so the app can correct its own state rather than
53
- * having the package silently ignore it.
54
- */
55
- readonly onSelectionUnresolvable?: (ref: SelectionRef) => void;
56
- /** Omit for uncontrolled visibility. Supply to take ownership. */
57
- readonly hiddenSeriesIds?: readonly string[];
58
- readonly onVisibilityChange?: (event: SeriesInteractionEvent<M>) => void;
59
- /** Series toggling stays off unless the app opts in (FR-049). */
60
- readonly seriesToggleEnabled?: boolean;
61
- }
62
-
63
- export function sameSelection(
64
- a: SelectionRef | null | undefined,
65
- b: SelectionRef | null | undefined,
66
- ): boolean {
67
- if (!a || !b) {
68
- return !a && !b;
69
- }
70
- return a.seriesId === b.seriesId && a.pointId === b.pointId;
71
- }
1
+ /**
2
+ * Interaction contract (FR-048 to FR-054a).
3
+ *
4
+ * The package reports what the user touched and does nothing else: no
5
+ * navigation, no fetching, no datasource mutation, no business rules.
6
+ *
7
+ * Selection is **uncontrolled by default and controlled when the app supplies a
8
+ * value** — the standard React pattern. The same callbacks fire either way, so
9
+ * a consumer can move between the two without rewriting handlers.
10
+ */
11
+ import type { Position } from './datasource';
12
+ import type { ValueState } from './value';
13
+
14
+ export interface SelectionRef {
15
+ readonly seriesId: string;
16
+ /** Absent for a whole-series selection. */
17
+ readonly pointId?: string;
18
+ }
19
+
20
+ export interface PointInteractionEvent<M = unknown> {
21
+ readonly type: 'pointSelect' | 'segmentPress';
22
+ readonly seriesId: string;
23
+ readonly pointId: string;
24
+ readonly value: ValueState;
25
+ readonly position: Position;
26
+ readonly metadata?: M;
27
+ }
28
+
29
+ export interface SeriesInteractionEvent<M = unknown> {
30
+ readonly type: 'seriesSelect' | 'visibilityChange';
31
+ readonly seriesId: string;
32
+ readonly visible?: boolean;
33
+ readonly metadata?: M;
34
+ }
35
+
36
+ export type ChartInteractionEvent<M = unknown> =
37
+ PointInteractionEvent<M> | SeriesInteractionEvent<M>;
38
+
39
+ export interface InteractionProps<M = unknown> {
40
+ /**
41
+ * Omit for uncontrolled selection. Supply — including `null` — to take
42
+ * ownership; the chart then renders exactly what this says.
43
+ */
44
+ readonly selection?: SelectionRef | null;
45
+ readonly onSelectionChange?: (
46
+ next: SelectionRef | null,
47
+ event: PointInteractionEvent<M> | null,
48
+ ) => void;
49
+ /**
50
+ * Fires when an app-controlled selection no longer resolves against the
51
+ * current datasource, so the app can correct its own state rather than
52
+ * having the package silently ignore it.
53
+ */
54
+ readonly onSelectionUnresolvable?: (ref: SelectionRef) => void;
55
+ /** Omit for uncontrolled visibility. Supply to take ownership. */
56
+ readonly hiddenSeriesIds?: readonly string[];
57
+ readonly onVisibilityChange?: (event: SeriesInteractionEvent<M>) => void;
58
+ /** Series toggling stays off unless the app opts in (FR-049). */
59
+ readonly seriesToggleEnabled?: boolean;
60
+ }
61
+
62
+ export function sameSelection(
63
+ a: SelectionRef | null | undefined,
64
+ b: SelectionRef | null | undefined,
65
+ ): boolean {
66
+ if (!a || !b) {
67
+ return !a && !b;
68
+ }
69
+ return a.seriesId === b.seriesId && a.pointId === b.pointId;
70
+ }
@@ -1,75 +1,75 @@
1
- /**
2
- * Reference markers: targets, baselines, and thresholds (FR-008, FR-021, FR-022).
3
- *
4
- * The important rule lives here: a marker whose value is not `known`, or whose
5
- * target is zero or negative, produces **no completion proportion**. A chart
6
- * shows the current value alone rather than inventing a percentage.
7
- */
8
- import type { FormatHint } from './format';
9
- import { isKnown, type ValueState } from './value';
10
-
11
- export type MarkerKind = 'target' | 'baseline' | 'threshold';
12
-
13
- export interface ReferenceMarker {
14
- readonly id: string;
15
- readonly kind: MarkerKind;
16
- readonly value: ValueState;
17
- readonly label?: string;
18
- readonly format?: FormatHint;
19
- }
20
-
21
- /**
22
- * A target only supports a proportion when it is known and strictly positive.
23
- * Zero and negative targets make "percent complete" meaningless, so we decline
24
- * to compute one rather than divide and produce a nonsense figure.
25
- */
26
- export function isUsableTarget(marker: ReferenceMarker | undefined): boolean {
27
- if (!marker || marker.kind !== 'target') {
28
- return false;
29
- }
30
- return isKnown(marker.value) && marker.value.value > 0;
31
- }
32
-
33
- export interface ProgressReading {
34
- /** The current value, when known. */
35
- readonly current: ValueState;
36
- /** The target, when one was supplied. */
37
- readonly target?: ReferenceMarker;
38
- /**
39
- * Completion as a ratio of current to target. `undefined` whenever no
40
- * proportion can honestly be computed — this is never defaulted to 0 or 1.
41
- */
42
- readonly proportion?: number;
43
- /** True when the current value exceeds the target: shown, never clamped. */
44
- readonly overshoot: boolean;
45
- }
46
-
47
- export function readProgress(
48
- current: ValueState,
49
- target: ReferenceMarker | undefined,
50
- ): ProgressReading {
51
- if (!isKnown(current) || !isUsableTarget(target) || !target) {
52
- return { current, target, proportion: undefined, overshoot: false };
53
- }
54
-
55
- // isUsableTarget already proved the target is known and positive.
56
- const targetValue = (target.value as { value: number }).value;
57
- const proportion = current.value / targetValue;
58
-
59
- return {
60
- current,
61
- target,
62
- proportion,
63
- overshoot: proportion > 1,
64
- };
65
- }
66
-
67
- export function findTarget(
68
- markers: readonly ReferenceMarker[] | undefined,
69
- id?: string,
70
- ): ReferenceMarker | undefined {
71
- if (!markers) {
72
- return undefined;
73
- }
74
- return markers.find(m => m.kind === 'target' && (!id || m.id === id));
75
- }
1
+ /**
2
+ * Reference markers: targets, baselines, and thresholds (FR-008, FR-021, FR-022).
3
+ *
4
+ * The important rule lives here: a marker whose value is not `known`, or whose
5
+ * target is zero or negative, produces **no completion proportion**. A chart
6
+ * shows the current value alone rather than inventing a percentage.
7
+ */
8
+ import type { FormatHint } from './format';
9
+ import { isKnown, type ValueState } from './value';
10
+
11
+ export type MarkerKind = 'target' | 'baseline' | 'threshold';
12
+
13
+ export interface ReferenceMarker {
14
+ readonly id: string;
15
+ readonly kind: MarkerKind;
16
+ readonly value: ValueState;
17
+ readonly label?: string;
18
+ readonly format?: FormatHint;
19
+ }
20
+
21
+ /**
22
+ * A target only supports a proportion when it is known and strictly positive.
23
+ * Zero and negative targets make "percent complete" meaningless, so we decline
24
+ * to compute one rather than divide and produce a nonsense figure.
25
+ */
26
+ export function isUsableTarget(marker: ReferenceMarker | undefined): boolean {
27
+ if (!marker || marker.kind !== 'target') {
28
+ return false;
29
+ }
30
+ return isKnown(marker.value) && marker.value.value > 0;
31
+ }
32
+
33
+ export interface ProgressReading {
34
+ /** The current value, when known. */
35
+ readonly current: ValueState;
36
+ /** The target, when one was supplied. */
37
+ readonly target?: ReferenceMarker;
38
+ /**
39
+ * Completion as a ratio of current to target. `undefined` whenever no
40
+ * proportion can honestly be computed — this is never defaulted to 0 or 1.
41
+ */
42
+ readonly proportion?: number;
43
+ /** True when the current value exceeds the target: shown, never clamped. */
44
+ readonly overshoot: boolean;
45
+ }
46
+
47
+ export function readProgress(
48
+ current: ValueState,
49
+ target: ReferenceMarker | undefined,
50
+ ): ProgressReading {
51
+ if (!isKnown(current) || !isUsableTarget(target) || !target) {
52
+ return { current, target, proportion: undefined, overshoot: false };
53
+ }
54
+
55
+ // isUsableTarget already proved the target is known and positive.
56
+ const targetValue = (target.value as { value: number }).value;
57
+ const proportion = current.value / targetValue;
58
+
59
+ return {
60
+ current,
61
+ target,
62
+ proportion,
63
+ overshoot: proportion > 1,
64
+ };
65
+ }
66
+
67
+ export function findTarget(
68
+ markers: readonly ReferenceMarker[] | undefined,
69
+ id?: string,
70
+ ): ReferenceMarker | undefined {
71
+ if (!markers) {
72
+ return undefined;
73
+ }
74
+ return markers.find(m => m.kind === 'target' && (!id || m.id === id));
75
+ }