@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.
- package/CHANGELOG.md +126 -115
- package/README.md +381 -381
- package/package.json +46 -49
- package/skills/sdk-rn-chart/SKILL.md +68 -69
- package/src/a11y/summary.ts +170 -177
- package/src/contract/datasource.ts +126 -136
- package/src/contract/errors.ts +129 -133
- package/src/contract/format.ts +162 -168
- package/src/contract/interaction.ts +70 -71
- package/src/contract/marker.ts +75 -75
- package/src/contract/props.ts +57 -60
- package/src/contract/state.ts +88 -94
- package/src/contract/theme.ts +169 -177
- package/src/contract/value.ts +121 -125
- package/src/geometry/bar.ts +271 -273
- package/src/geometry/distribution.ts +197 -203
- package/src/geometry/heatmap.ts +189 -195
- package/src/geometry/hitTest.ts +117 -120
- package/src/geometry/layout.ts +162 -173
- package/src/geometry/markers.ts +55 -57
- package/src/geometry/path.ts +193 -199
- package/src/geometry/progress.ts +223 -230
- package/src/geometry/scale.ts +140 -146
- package/src/geometry/ticks.ts +145 -153
- package/src/geometry/timeSeries.ts +168 -168
- package/src/geometry/types.ts +94 -94
- package/src/index.ts +158 -176
- package/src/react/ChartBoundary.tsx +96 -96
- package/src/react/ChartFrame.tsx +583 -622
- package/src/react/ChartLegend.tsx +211 -215
- package/src/react/ChartThemeProvider.tsx +44 -48
- package/src/react/charts.tsx +154 -161
- package/src/react/useChartLayout.ts +124 -124
- package/src/react/useChartSelection.ts +152 -170
- package/src/render/Axis.tsx +120 -122
- package/src/render/Marks.tsx +173 -205
- package/src/validation/budgets.ts +103 -110
- package/src/validation/validateDatasource.ts +174 -174
package/src/contract/format.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
) => string;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
case '
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return `${n.toFixed(2)} ${hint.
|
|
85
|
-
case '
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
readonly
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
readonly
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|
package/src/contract/marker.ts
CHANGED
|
@@ -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
|
+
}
|