@chipmobilesdk/rn-chart 0.2.1 → 0.4.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/CHANGELOG.md +56 -0
- package/README.md +54 -2
- package/package.json +5 -2
- package/skills/sdk-rn-chart/SKILL.md +49 -14
- package/src/a11y/summary.ts +85 -46
- package/src/contract/distribution.ts +44 -0
- package/src/contract/errors.ts +6 -0
- package/src/contract/format.ts +6 -0
- package/src/contract/interaction.ts +2 -2
- package/src/contract/props.ts +7 -0
- package/src/contract/strings.ts +50 -0
- package/src/geometry/bar.ts +27 -16
- package/src/geometry/distribution.ts +11 -6
- package/src/geometry/heatmap.ts +34 -17
- package/src/geometry/hitTest.ts +35 -0
- package/src/geometry/progress.ts +0 -1
- package/src/geometry/ticks.ts +0 -13
- package/src/geometry/timeSeries.ts +7 -2
- package/src/geometry/types.ts +11 -0
- package/src/index.ts +8 -1
- package/src/react/ChartBoundary.tsx +2 -2
- package/src/react/ChartFrame.tsx +128 -362
- package/src/react/ChartLegend.tsx +65 -3
- package/src/react/ChartPlot.tsx +57 -0
- package/src/react/ChartStateOverlay.tsx +94 -0
- package/src/react/charts.tsx +27 -12
- package/src/react/pointEvent.ts +40 -0
- package/src/react/useChartA11y.ts +113 -0
- package/src/react/useChartFormatters.ts +105 -0
- package/src/react/useChartLayout.ts +5 -2
- package/src/react/useChartSelection.ts +77 -40
- package/src/react/useChartStatus.ts +85 -0
- package/src/render/Axis.tsx +26 -22
- package/src/render/Marks.tsx +2 -9
- package/src/validation/budgets.ts +16 -4
- package/src/validation/validateDatasource.ts +38 -14
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
* Selection ownership: uncontrolled by default, controlled when the app
|
|
3
3
|
* supplies a value (FR-054a).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Three properties matter and all are load-bearing:
|
|
6
6
|
*
|
|
7
7
|
* - The selection is **resolved against the current datasource on every
|
|
8
8
|
* render** rather than stored resolved. A reference to a point that no
|
|
9
9
|
* longer exists therefore draws nothing, in either mode (FR-054).
|
|
10
10
|
* - Selection state lives outside the geometry memo, so highlighting repaints
|
|
11
11
|
* the highlight without recomputing scales or path strings (FR-073).
|
|
12
|
+
* - `select` and `toggle` keep one identity for the life of the chart. They
|
|
13
|
+
* read the app's callbacks through a ref, so an inline callback does not
|
|
14
|
+
* make every render look like a change (CHT-08).
|
|
12
15
|
*/
|
|
13
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
17
|
|
|
15
18
|
import type {
|
|
16
19
|
PointInteractionEvent,
|
|
@@ -42,11 +45,24 @@ export interface UseChartSelectionArgs<M> {
|
|
|
42
45
|
readonly geometryReady: boolean;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
export interface SelectOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Selecting what is already selected clears it — the touch gesture. False
|
|
51
|
+
* for screen-reader stepping, where landing on the same point again (a chart
|
|
52
|
+
* with one point) must not switch the selection off and on (CHT-06).
|
|
53
|
+
*/
|
|
54
|
+
readonly toggle?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
export interface ChartSelectionApi<M> {
|
|
46
58
|
/** What to draw. Never a reference the datasource cannot resolve. */
|
|
47
59
|
readonly resolved: HitRegion | undefined;
|
|
48
60
|
readonly selection: SelectionRef | null;
|
|
49
|
-
readonly select: (
|
|
61
|
+
readonly select: (
|
|
62
|
+
ref: SelectionRef | null,
|
|
63
|
+
event: PointInteractionEvent<M> | null,
|
|
64
|
+
options?: SelectOptions,
|
|
65
|
+
) => void;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
export function useChartSelection<M>(args: UseChartSelectionArgs<M>): ChartSelectionApi<M> {
|
|
@@ -62,39 +78,53 @@ export function useChartSelection<M>(args: UseChartSelectionArgs<M>): ChartSelec
|
|
|
62
78
|
? findRegion(args.hitRegions, selection.seriesId, selection.pointId)
|
|
63
79
|
: undefined;
|
|
64
80
|
|
|
81
|
+
const latest = useRef({ args, selection, isControlled });
|
|
82
|
+
latest.current = { args, selection, isControlled };
|
|
83
|
+
|
|
65
84
|
// Tell a controlling app when its selection stopped resolving, rather than
|
|
66
|
-
// silently ignoring it. Uncontrolled selections are cleared instead.
|
|
85
|
+
// silently ignoring it. Uncontrolled selections are cleared instead. Keyed
|
|
86
|
+
// on what the selection is, not on object identity, so an app passing
|
|
87
|
+
// `selection={{…}}` inline does not re-run this every render.
|
|
88
|
+
const selectionKey = selection ? `${selection.seriesId}:${selection.pointId ?? ''}` : null;
|
|
89
|
+
const unresolved =
|
|
90
|
+
selectionKey !== null && !resolved && !args.interactionSuppressed && args.geometryReady;
|
|
67
91
|
const reportedRef = useRef<string | null>(null);
|
|
68
92
|
useEffect(() => {
|
|
69
|
-
if (!
|
|
93
|
+
if (!unresolved) {
|
|
70
94
|
reportedRef.current = null;
|
|
71
95
|
return;
|
|
72
96
|
}
|
|
73
|
-
|
|
74
|
-
const key = `${selection.seriesId}:${selection.pointId ?? ''}`;
|
|
75
|
-
if (reportedRef.current === key) {
|
|
97
|
+
if (reportedRef.current === selectionKey) {
|
|
76
98
|
return;
|
|
77
99
|
}
|
|
78
|
-
reportedRef.current =
|
|
100
|
+
reportedRef.current = selectionKey;
|
|
79
101
|
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
const current = latest.current;
|
|
103
|
+
if (current.isControlled) {
|
|
104
|
+
if (current.selection) {
|
|
105
|
+
current.args.onSelectionUnresolvable?.(current.selection);
|
|
106
|
+
}
|
|
82
107
|
} else {
|
|
83
108
|
setInternal(null);
|
|
84
109
|
}
|
|
85
|
-
}, [
|
|
110
|
+
}, [unresolved, selectionKey]);
|
|
86
111
|
|
|
87
112
|
const select = useCallback(
|
|
88
|
-
(ref: SelectionRef | null, event: PointInteractionEvent<M> | null) => {
|
|
89
|
-
|
|
113
|
+
(ref: SelectionRef | null, event: PointInteractionEvent<M> | null, options?: SelectOptions) => {
|
|
114
|
+
const current = latest.current;
|
|
115
|
+
if (current.args.interactionSuppressed) {
|
|
90
116
|
return;
|
|
91
117
|
}
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
// Decided once, then used for the state and for the report: the app
|
|
119
|
+
// used to be told a point was selected at the very moment the chart
|
|
120
|
+
// dropped its highlight (CHT-06).
|
|
121
|
+
const next = options?.toggle !== false && sameSelection(current.selection, ref) ? null : ref;
|
|
122
|
+
if (!current.isControlled) {
|
|
123
|
+
setInternal(next);
|
|
94
124
|
}
|
|
95
|
-
args.onSelectionChange?.(
|
|
125
|
+
current.args.onSelectionChange?.(next, next ? event : null);
|
|
96
126
|
},
|
|
97
|
-
[
|
|
127
|
+
[],
|
|
98
128
|
);
|
|
99
129
|
|
|
100
130
|
return { resolved, selection, select };
|
|
@@ -111,42 +141,49 @@ export interface UseSeriesVisibilityArgs<M> {
|
|
|
111
141
|
}
|
|
112
142
|
|
|
113
143
|
export interface SeriesVisibilityApi {
|
|
144
|
+
/** The same Set until the hidden ids change, so memos may depend on it. */
|
|
114
145
|
readonly hidden: ReadonlySet<string>;
|
|
115
146
|
readonly toggle: (seriesId: string, metadata?: unknown) => void;
|
|
116
147
|
readonly enabled: boolean;
|
|
117
148
|
}
|
|
118
149
|
|
|
150
|
+
/** Cannot occur in a series id that came from JSON or a database key. */
|
|
151
|
+
const SEPARATOR = '\u0000';
|
|
152
|
+
|
|
119
153
|
export function useSeriesVisibility<M>(args: UseSeriesVisibilityArgs<M>): SeriesVisibilityApi {
|
|
120
154
|
const isControlled = args.controlled !== undefined;
|
|
121
155
|
const [internal, setInternal] = useState<readonly string[]>(args.initiallyHidden);
|
|
122
156
|
|
|
123
157
|
const hiddenList = isControlled ? (args.controlled ?? []) : internal;
|
|
124
|
-
const
|
|
158
|
+
const hiddenKey = [...hiddenList].sort((a, b) => a.localeCompare(b)).join(SEPARATOR);
|
|
159
|
+
const hidden = useMemo(
|
|
160
|
+
() => new Set(hiddenKey === '' ? [] : hiddenKey.split(SEPARATOR)),
|
|
161
|
+
[hiddenKey],
|
|
162
|
+
);
|
|
125
163
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
if (!args.enabled) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
164
|
+
const latest = useRef({ args, hidden, isControlled });
|
|
165
|
+
latest.current = { args, hidden, isControlled };
|
|
131
166
|
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
const toggle = useCallback((seriesId: string, metadata?: unknown) => {
|
|
168
|
+
const current = latest.current;
|
|
169
|
+
if (!current.args.enabled) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const willBeVisible = current.hidden.has(seriesId);
|
|
134
173
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
if (!current.isControlled) {
|
|
175
|
+
setInternal(prev =>
|
|
176
|
+
prev.includes(seriesId) ? prev.filter(id => id !== seriesId) : [...prev, seriesId],
|
|
177
|
+
);
|
|
178
|
+
}
|
|
140
179
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
[args, internal, isControlled],
|
|
149
|
-
);
|
|
180
|
+
current.args.onVisibilityChange?.({
|
|
181
|
+
type: 'visibilityChange',
|
|
182
|
+
seriesId,
|
|
183
|
+
visible: willBeVisible,
|
|
184
|
+
metadata: metadata as M,
|
|
185
|
+
});
|
|
186
|
+
}, []);
|
|
150
187
|
|
|
151
188
|
return { hidden, toggle, enabled: args.enabled };
|
|
152
189
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What state the chart is in: validation, the app's declared status, and the
|
|
3
|
+
* one report a validation failure gets.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
8
|
+
import type { DistributionValues } from '../contract/distribution';
|
|
9
|
+
import { chartError, type ChartError, type ChartFailureHandler } from '../contract/errors';
|
|
10
|
+
import { resolveStatus, type ChartStatus, type DeclarableChartStatus } from '../contract/state';
|
|
11
|
+
import { releaseBudgetWarning, warnIfOverBudget } from '../validation/budgets';
|
|
12
|
+
import { validateDatasource, type ValidationResult } from '../validation/validateDatasource';
|
|
13
|
+
|
|
14
|
+
export interface UseChartStatusArgs<M> {
|
|
15
|
+
readonly datasource: ChartDatasource<M>;
|
|
16
|
+
readonly family: ChartFamily;
|
|
17
|
+
readonly chartId?: string;
|
|
18
|
+
/** `chartId`, or a generated id: what a once-per-chart warning is keyed on. */
|
|
19
|
+
readonly instanceKey: string;
|
|
20
|
+
readonly declared?: DeclarableChartStatus;
|
|
21
|
+
readonly onFailure?: ChartFailureHandler;
|
|
22
|
+
readonly distributionValues?: DistributionValues;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ChartStatusResult {
|
|
26
|
+
readonly validation: ValidationResult;
|
|
27
|
+
readonly error: ChartError | undefined;
|
|
28
|
+
readonly status: ChartStatus;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useChartStatus<M>(args: UseChartStatusArgs<M>): ChartStatusResult {
|
|
32
|
+
const { datasource, family, chartId, instanceKey, distributionValues } = args;
|
|
33
|
+
|
|
34
|
+
const validation = useMemo(
|
|
35
|
+
() => validateDatasource(datasource, family, chartId, distributionValues),
|
|
36
|
+
[datasource, family, chartId, distributionValues],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Warn once per chart instance, dev only, and release the key on unmount so
|
|
40
|
+
// a remount warns again. An effect, not a memo: a warning is a side effect,
|
|
41
|
+
// and React may run a memo's function more than once or discard its result.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
warnIfOverBudget(datasource, family, instanceKey);
|
|
44
|
+
}, [datasource, family, instanceKey]);
|
|
45
|
+
useEffect(() => () => releaseBudgetWarning(instanceKey), [instanceKey]);
|
|
46
|
+
|
|
47
|
+
const declaredError =
|
|
48
|
+
args.declared === 'error'
|
|
49
|
+
? chartError('declared', 'declaredByApp', { chartId, family })
|
|
50
|
+
: undefined;
|
|
51
|
+
const error = validation.error ?? declaredError;
|
|
52
|
+
|
|
53
|
+
const status = resolveStatus({
|
|
54
|
+
declared: args.declared,
|
|
55
|
+
isEmpty: validation.isEmpty,
|
|
56
|
+
isPartial: validation.isPartial,
|
|
57
|
+
error,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A validation failure is reported through the same channel as a contained
|
|
61
|
+
// render exception, so consumers have one place to watch. `onFailure` is
|
|
62
|
+
// read through a ref: an inline callback would otherwise re-fire the report
|
|
63
|
+
// on every render.
|
|
64
|
+
const reportedRef = useRef<string | undefined>(undefined);
|
|
65
|
+
const onFailureRef = useRef(args.onFailure);
|
|
66
|
+
onFailureRef.current = args.onFailure;
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!validation.error) {
|
|
70
|
+
reportedRef.current = undefined;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (reportedRef.current === validation.error.code) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
reportedRef.current = validation.error.code;
|
|
77
|
+
onFailureRef.current?.({
|
|
78
|
+
chartId: validation.error.chartId,
|
|
79
|
+
family: validation.error.family ?? family,
|
|
80
|
+
error: validation.error,
|
|
81
|
+
});
|
|
82
|
+
}, [validation.error, family]);
|
|
83
|
+
|
|
84
|
+
return { validation, error, status };
|
|
85
|
+
}
|
package/src/render/Axis.tsx
CHANGED
|
@@ -43,26 +43,29 @@ export function XAxis({
|
|
|
43
43
|
ticks,
|
|
44
44
|
theme,
|
|
45
45
|
fontSize,
|
|
46
|
-
|
|
46
|
+
line = true,
|
|
47
47
|
}: {
|
|
48
48
|
readonly plot: PlotRect;
|
|
49
49
|
readonly ticks: readonly Tick[];
|
|
50
50
|
readonly theme: ChartTheme;
|
|
51
51
|
readonly fontSize: number;
|
|
52
|
-
|
|
52
|
+
/** False for a heatmap: its columns are named, but there is no axis to draw. */
|
|
53
|
+
readonly line?: boolean;
|
|
53
54
|
}) {
|
|
54
55
|
const baseline = plotBottom(plot);
|
|
55
56
|
|
|
56
57
|
return (
|
|
57
58
|
<G>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
{line ? (
|
|
60
|
+
<Line
|
|
61
|
+
x1={plot.x}
|
|
62
|
+
y1={baseline}
|
|
63
|
+
x2={plotRight(plot)}
|
|
64
|
+
y2={baseline}
|
|
65
|
+
stroke={theme.axis}
|
|
66
|
+
strokeWidth={1}
|
|
67
|
+
/>
|
|
68
|
+
) : null}
|
|
66
69
|
{ticks.map(tick => (
|
|
67
70
|
<SvgText
|
|
68
71
|
key={`x-${tick.position}-${tick.label}`}
|
|
@@ -70,10 +73,7 @@ export function XAxis({
|
|
|
70
73
|
y={baseline + fontSize + 4}
|
|
71
74
|
fill={theme.typography.axisLabel.color}
|
|
72
75
|
fontSize={fontSize}
|
|
73
|
-
textAnchor=
|
|
74
|
-
transform={
|
|
75
|
-
rotateLabels ? `rotate(-45, ${tick.position}, ${baseline + fontSize + 4})` : undefined
|
|
76
|
-
}
|
|
76
|
+
textAnchor="middle"
|
|
77
77
|
>
|
|
78
78
|
{tick.label}
|
|
79
79
|
</SvgText>
|
|
@@ -87,22 +87,26 @@ export function YAxis({
|
|
|
87
87
|
ticks,
|
|
88
88
|
theme,
|
|
89
89
|
fontSize,
|
|
90
|
+
line = true,
|
|
90
91
|
}: {
|
|
91
92
|
readonly plot: PlotRect;
|
|
92
93
|
readonly ticks: readonly Tick[];
|
|
93
94
|
readonly theme: ChartTheme;
|
|
94
95
|
readonly fontSize: number;
|
|
96
|
+
readonly line?: boolean;
|
|
95
97
|
}) {
|
|
96
98
|
return (
|
|
97
99
|
<G>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
{line ? (
|
|
101
|
+
<Line
|
|
102
|
+
x1={plot.x}
|
|
103
|
+
y1={plot.y}
|
|
104
|
+
x2={plot.x}
|
|
105
|
+
y2={plotBottom(plot)}
|
|
106
|
+
stroke={theme.axis}
|
|
107
|
+
strokeWidth={1}
|
|
108
|
+
/>
|
|
109
|
+
) : null}
|
|
106
110
|
{ticks.map(tick => (
|
|
107
111
|
<SvgText
|
|
108
112
|
key={`y-${tick.position}-${tick.label}`}
|
package/src/render/Marks.tsx
CHANGED
|
@@ -72,24 +72,17 @@ function fillFor(mark: MarkPath): string {
|
|
|
72
72
|
return mark.filled ? mark.color : 'none';
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function Marks({
|
|
76
|
-
paths,
|
|
77
|
-
selectedKeys,
|
|
78
|
-
}: {
|
|
79
|
-
readonly paths: readonly MarkPath[];
|
|
80
|
-
readonly selectedKeys?: ReadonlySet<string>;
|
|
81
|
-
}) {
|
|
75
|
+
export function Marks({ paths }: { readonly paths: readonly MarkPath[] }) {
|
|
82
76
|
return (
|
|
83
77
|
<G>
|
|
84
78
|
{paths.map(mark => {
|
|
85
|
-
const highlighted = selectedKeys?.has(mark.seriesId);
|
|
86
79
|
return (
|
|
87
80
|
<Path
|
|
88
81
|
key={mark.key}
|
|
89
82
|
d={mark.d}
|
|
90
83
|
fill={fillFor(mark)}
|
|
91
84
|
stroke={mark.filled ? 'none' : mark.color}
|
|
92
|
-
strokeWidth={mark.filled ? 0 :
|
|
85
|
+
strokeWidth={mark.filled ? 0 : 2}
|
|
93
86
|
strokeDasharray={mark.filled ? undefined : dashArrayFor(mark.pattern)}
|
|
94
87
|
strokeLinejoin="round"
|
|
95
88
|
strokeLinecap="round"
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* specs/007-rn-chart-package/research.md R2 — element count scales with series,
|
|
15
15
|
* not with points, so these budgets are about JS work rather than view count.
|
|
16
16
|
*/
|
|
17
|
+
import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
17
19
|
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
18
20
|
|
|
19
21
|
export interface DatasetBudget {
|
|
@@ -63,6 +65,9 @@ export function checkBudget<M>(datasource: ChartDatasource<M>, family: ChartFami
|
|
|
63
65
|
|
|
64
66
|
const warnedCharts = new Set<string>();
|
|
65
67
|
|
|
68
|
+
/** Chart diagnostics also reach the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
69
|
+
const channel = createDiagnosticChannel<'over_budget'>('rn-chart');
|
|
70
|
+
|
|
66
71
|
/**
|
|
67
72
|
* Warns once per chart instance per mount. De-duplicating matters: a scrolling
|
|
68
73
|
* dashboard would otherwise flood the console on every frame.
|
|
@@ -71,11 +76,20 @@ export function warnIfOverBudget<M>(
|
|
|
71
76
|
datasource: ChartDatasource<M>,
|
|
72
77
|
family: ChartFamily,
|
|
73
78
|
instanceKey: string,
|
|
74
|
-
):
|
|
79
|
+
): void {
|
|
80
|
+
// Counting the dataset is dev work too: a release build skips it entirely.
|
|
81
|
+
if (!__DEV__ || warnedCharts.has(instanceKey)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
75
84
|
const check = checkBudget(datasource, family);
|
|
76
85
|
|
|
77
|
-
if (
|
|
86
|
+
if (!check.withinBudget) {
|
|
78
87
|
warnedCharts.add(instanceKey);
|
|
88
|
+
channel.emit('over_budget', 'warn', {
|
|
89
|
+
family,
|
|
90
|
+
series: check.seriesCount,
|
|
91
|
+
maxPoints: check.maxPointsInAnySeries,
|
|
92
|
+
});
|
|
79
93
|
const budget = DATASET_BUDGETS[family];
|
|
80
94
|
console.warn(
|
|
81
95
|
`[rn-chart] ${family} chart "${instanceKey}" exceeds its documented dataset budget ` +
|
|
@@ -88,8 +102,6 @@ export function warnIfOverBudget<M>(
|
|
|
88
102
|
'in production builds.',
|
|
89
103
|
);
|
|
90
104
|
}
|
|
91
|
-
|
|
92
|
-
return check;
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
/** Called on unmount so a remounted chart can warn again. */
|
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
type ChartDatasource,
|
|
14
14
|
type ChartFamily,
|
|
15
15
|
} from '../contract/datasource';
|
|
16
|
+
import {
|
|
17
|
+
DISTRIBUTION_SUM_TOLERANCE,
|
|
18
|
+
resolveDistributionValues,
|
|
19
|
+
type DistributionValues,
|
|
20
|
+
} from '../contract/distribution';
|
|
16
21
|
import { validationError, type ChartError } from '../contract/errors';
|
|
17
22
|
import { isKnown } from '../contract/value';
|
|
18
23
|
|
|
@@ -26,12 +31,12 @@ export interface ValidationResult {
|
|
|
26
31
|
readonly totalIncomplete: boolean;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
const DISTRIBUTION_SUM_TOLERANCE = 0.001;
|
|
30
|
-
|
|
31
34
|
export function validateDatasource<M>(
|
|
32
35
|
datasource: ChartDatasource<M>,
|
|
33
36
|
family: ChartFamily,
|
|
34
37
|
chartId?: string,
|
|
38
|
+
/** What a distribution's values mean, when the app says; inferred otherwise. */
|
|
39
|
+
distributionValues?: DistributionValues,
|
|
35
40
|
): ValidationResult {
|
|
36
41
|
const base = { isEmpty: true, isPartial: false, totalIncomplete: false };
|
|
37
42
|
|
|
@@ -110,6 +115,27 @@ export function validateDatasource<M>(
|
|
|
110
115
|
matrixColumns.add(point.position.column);
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
// V9 — a known value is a finite number, and so is an instant. NaN or
|
|
119
|
+
// Infinity used to be drawn at the origin without a word (CHT-10).
|
|
120
|
+
const finite =
|
|
121
|
+
(!isKnown(point.value) || Number.isFinite(point.value.value)) &&
|
|
122
|
+
(point.position.kind !== 'instant' || Number.isFinite(point.position.at));
|
|
123
|
+
if (!finite) {
|
|
124
|
+
return {
|
|
125
|
+
...base,
|
|
126
|
+
isEmpty: false,
|
|
127
|
+
error: validationError('nonFiniteValue', { chartId, family }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// V10 — a share of a whole cannot be negative.
|
|
131
|
+
if (family === 'distribution' && isKnown(point.value) && point.value.value < 0) {
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
isEmpty: false,
|
|
135
|
+
error: validationError('negativeDistributionValue', { chartId, family }),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
totalPoints += 1;
|
|
114
140
|
if (isKnown(point.value)) {
|
|
115
141
|
knownPoints += 1;
|
|
@@ -138,7 +164,7 @@ export function validateDatasource<M>(
|
|
|
138
164
|
// and marks the total incomplete.
|
|
139
165
|
let totalIncomplete = knownPoints < totalPoints;
|
|
140
166
|
if (family === 'distribution' && totalPoints > 0) {
|
|
141
|
-
totalIncomplete = totalIncomplete || !distributionSumsToWhole(datasource);
|
|
167
|
+
totalIncomplete = totalIncomplete || !distributionSumsToWhole(datasource, distributionValues);
|
|
142
168
|
}
|
|
143
169
|
|
|
144
170
|
return {
|
|
@@ -149,25 +175,23 @@ export function validateDatasource<M>(
|
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* case can be checked for wholeness, and only when every point is known.
|
|
178
|
+
* Only proportions can be checked for wholeness, and only when every point is
|
|
179
|
+
* known. Absolute values are a whole by definition once they sum to something.
|
|
155
180
|
*/
|
|
156
|
-
function distributionSumsToWhole<M>(
|
|
157
|
-
|
|
181
|
+
function distributionSumsToWhole<M>(
|
|
182
|
+
datasource: ChartDatasource<M>,
|
|
183
|
+
declared?: DistributionValues,
|
|
184
|
+
): boolean {
|
|
185
|
+
let sum = 0;
|
|
158
186
|
for (const series of datasource.series) {
|
|
159
187
|
for (const point of series.points) {
|
|
160
188
|
if (!isKnown(point.value)) {
|
|
161
189
|
return false;
|
|
162
190
|
}
|
|
163
|
-
|
|
191
|
+
sum += point.value.value;
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
|
-
|
|
167
|
-
const sum = values.reduce((acc, n) => acc + n, 0);
|
|
168
|
-
const looksLikeProportions = values.every(v => v >= 0 && v <= 1);
|
|
169
|
-
if (!looksLikeProportions) {
|
|
170
|
-
// Absolute values: any positive sum is a whole by definition.
|
|
194
|
+
if (resolveDistributionValues(datasource, declared) === 'absolute') {
|
|
171
195
|
return sum > 0;
|
|
172
196
|
}
|
|
173
197
|
return Math.abs(sum - 1) <= DISTRIBUTION_SUM_TOLERANCE;
|