@chipmobilesdk/rn-chart 0.1.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 +74 -0
- package/README.md +358 -0
- package/package.json +48 -0
- package/src/a11y/summary.ts +177 -0
- package/src/contract/datasource.ts +134 -0
- package/src/contract/errors.ts +133 -0
- package/src/contract/format.ts +168 -0
- package/src/contract/interaction.ts +71 -0
- package/src/contract/marker.ts +75 -0
- package/src/contract/props.ts +60 -0
- package/src/contract/state.ts +94 -0
- package/src/contract/theme.ts +139 -0
- package/src/contract/value.ts +125 -0
- package/src/geometry/bar.ts +256 -0
- package/src/geometry/distribution.ts +200 -0
- package/src/geometry/heatmap.ts +195 -0
- package/src/geometry/hitTest.ts +120 -0
- package/src/geometry/layout.ts +121 -0
- package/src/geometry/path.ts +199 -0
- package/src/geometry/progress.ts +204 -0
- package/src/geometry/scale.ts +146 -0
- package/src/geometry/ticks.ts +153 -0
- package/src/geometry/timeSeries.ts +158 -0
- package/src/geometry/types.ts +88 -0
- package/src/index.ts +176 -0
- package/src/react/ChartBoundary.tsx +96 -0
- package/src/react/ChartFrame.tsx +620 -0
- package/src/react/ChartLegend.tsx +177 -0
- package/src/react/ChartThemeProvider.tsx +48 -0
- package/src/react/charts.tsx +161 -0
- package/src/react/useChartLayout.ts +124 -0
- package/src/react/useChartSelection.ts +170 -0
- package/src/render/Axis.tsx +122 -0
- package/src/render/Marks.tsx +205 -0
- package/src/validation/budgets.ts +110 -0
- package/src/validation/validateDatasource.ts +174 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Axis and grid rendering. Private SVG adapter.
|
|
3
|
+
*
|
|
4
|
+
* Ticks arrive already thinned by `geometry/ticks.ts`, so nothing here decides
|
|
5
|
+
* what fits — this module only draws what the geometry chose.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { G, Line, Text as SvgText } from 'react-native-svg';
|
|
9
|
+
|
|
10
|
+
import type { ChartTheme } from '../contract/theme';
|
|
11
|
+
import type { PlotRect } from '../geometry/layout';
|
|
12
|
+
import { plotBottom, plotRight } from '../geometry/layout';
|
|
13
|
+
import type { Tick } from '../geometry/ticks';
|
|
14
|
+
|
|
15
|
+
export function Grid({
|
|
16
|
+
plot,
|
|
17
|
+
yTicks,
|
|
18
|
+
theme,
|
|
19
|
+
}: {
|
|
20
|
+
readonly plot: PlotRect;
|
|
21
|
+
readonly yTicks: readonly Tick[];
|
|
22
|
+
readonly theme: ChartTheme;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<G>
|
|
26
|
+
{yTicks.map(tick => (
|
|
27
|
+
<Line
|
|
28
|
+
key={`grid-${tick.value}`}
|
|
29
|
+
x1={plot.x}
|
|
30
|
+
y1={tick.position}
|
|
31
|
+
x2={plotRight(plot)}
|
|
32
|
+
y2={tick.position}
|
|
33
|
+
stroke={theme.grid}
|
|
34
|
+
strokeWidth={1}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</G>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function XAxis({
|
|
42
|
+
plot,
|
|
43
|
+
ticks,
|
|
44
|
+
theme,
|
|
45
|
+
fontSize,
|
|
46
|
+
rotateLabels,
|
|
47
|
+
}: {
|
|
48
|
+
readonly plot: PlotRect;
|
|
49
|
+
readonly ticks: readonly Tick[];
|
|
50
|
+
readonly theme: ChartTheme;
|
|
51
|
+
readonly fontSize: number;
|
|
52
|
+
readonly rotateLabels?: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const baseline = plotBottom(plot);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<G>
|
|
58
|
+
<Line
|
|
59
|
+
x1={plot.x}
|
|
60
|
+
y1={baseline}
|
|
61
|
+
x2={plotRight(plot)}
|
|
62
|
+
y2={baseline}
|
|
63
|
+
stroke={theme.axis}
|
|
64
|
+
strokeWidth={1}
|
|
65
|
+
/>
|
|
66
|
+
{ticks.map(tick => (
|
|
67
|
+
<SvgText
|
|
68
|
+
key={`x-${tick.position}-${tick.label}`}
|
|
69
|
+
x={tick.position}
|
|
70
|
+
y={baseline + fontSize + 4}
|
|
71
|
+
fill={theme.typography.axisLabel.color}
|
|
72
|
+
fontSize={fontSize}
|
|
73
|
+
textAnchor={rotateLabels ? 'end' : 'middle'}
|
|
74
|
+
transform={
|
|
75
|
+
rotateLabels
|
|
76
|
+
? `rotate(-45, ${tick.position}, ${baseline + fontSize + 4})`
|
|
77
|
+
: undefined
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
{tick.label}
|
|
81
|
+
</SvgText>
|
|
82
|
+
))}
|
|
83
|
+
</G>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function YAxis({
|
|
88
|
+
plot,
|
|
89
|
+
ticks,
|
|
90
|
+
theme,
|
|
91
|
+
fontSize,
|
|
92
|
+
}: {
|
|
93
|
+
readonly plot: PlotRect;
|
|
94
|
+
readonly ticks: readonly Tick[];
|
|
95
|
+
readonly theme: ChartTheme;
|
|
96
|
+
readonly fontSize: number;
|
|
97
|
+
}) {
|
|
98
|
+
return (
|
|
99
|
+
<G>
|
|
100
|
+
<Line
|
|
101
|
+
x1={plot.x}
|
|
102
|
+
y1={plot.y}
|
|
103
|
+
x2={plot.x}
|
|
104
|
+
y2={plotBottom(plot)}
|
|
105
|
+
stroke={theme.axis}
|
|
106
|
+
strokeWidth={1}
|
|
107
|
+
/>
|
|
108
|
+
{ticks.map(tick => (
|
|
109
|
+
<SvgText
|
|
110
|
+
key={`y-${tick.position}-${tick.label}`}
|
|
111
|
+
x={plot.x - 6}
|
|
112
|
+
y={tick.position + fontSize / 3}
|
|
113
|
+
fill={theme.typography.axisLabel.color}
|
|
114
|
+
fontSize={fontSize}
|
|
115
|
+
textAnchor="end"
|
|
116
|
+
>
|
|
117
|
+
{tick.label}
|
|
118
|
+
</SvgText>
|
|
119
|
+
))}
|
|
120
|
+
</G>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private SVG adapter for resolved geometry.
|
|
3
|
+
*
|
|
4
|
+
* Everything here consumes plain data produced by `geometry/`. Swapping the
|
|
5
|
+
* drawing substrate means rewriting this directory and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import {
|
|
9
|
+
Defs,
|
|
10
|
+
G,
|
|
11
|
+
Path,
|
|
12
|
+
Pattern,
|
|
13
|
+
Rect,
|
|
14
|
+
Text as SvgText,
|
|
15
|
+
} from 'react-native-svg';
|
|
16
|
+
|
|
17
|
+
import type { SeriesPattern } from '../contract/datasource';
|
|
18
|
+
import type { ChartTheme, NonValueTexture } from '../contract/theme';
|
|
19
|
+
import type { GeometryLabel, MarkPath, MarkerLine } from '../geometry/types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Dash patterns give series a second, non-colour identity (FR-064). `undefined`
|
|
23
|
+
* means a solid stroke.
|
|
24
|
+
*/
|
|
25
|
+
export function dashArrayFor(pattern: SeriesPattern): string | undefined {
|
|
26
|
+
switch (pattern) {
|
|
27
|
+
case 'dashed':
|
|
28
|
+
return '6,4';
|
|
29
|
+
case 'dotted':
|
|
30
|
+
return '1,4';
|
|
31
|
+
case 'dashDot':
|
|
32
|
+
return '8,3,1,3';
|
|
33
|
+
default:
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TEXTURE_IDS: Record<NonValueTexture, string> = {
|
|
39
|
+
gap: 'rnchart-texture-gap',
|
|
40
|
+
hatch: 'rnchart-texture-hatch',
|
|
41
|
+
lockedHatch: 'rnchart-texture-locked',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Texture definitions for the three non-value classes.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately shape-based rather than colour-based: a reader in greyscale, or
|
|
48
|
+
* with reduced colour perception, must still tell a gap from a hatch from a
|
|
49
|
+
* locked cell (FR-064, SC-012).
|
|
50
|
+
*/
|
|
51
|
+
export function NonValueTextures({ theme }: { readonly theme: ChartTheme }) {
|
|
52
|
+
return (
|
|
53
|
+
<Defs>
|
|
54
|
+
{/* unknown: sparse dots — visibly "nothing here". */}
|
|
55
|
+
<Pattern
|
|
56
|
+
id={TEXTURE_IDS.gap}
|
|
57
|
+
patternUnits="userSpaceOnUse"
|
|
58
|
+
width="6"
|
|
59
|
+
height="6"
|
|
60
|
+
>
|
|
61
|
+
<Rect width="6" height="6" fill={theme.surface} />
|
|
62
|
+
<Rect x="2" y="2" width="1.5" height="1.5" fill={theme.muted} />
|
|
63
|
+
</Pattern>
|
|
64
|
+
|
|
65
|
+
{/* notApplicable: single diagonal hatch. */}
|
|
66
|
+
<Pattern
|
|
67
|
+
id={TEXTURE_IDS.hatch}
|
|
68
|
+
patternUnits="userSpaceOnUse"
|
|
69
|
+
width="8"
|
|
70
|
+
height="8"
|
|
71
|
+
>
|
|
72
|
+
<Rect width="8" height="8" fill={theme.surface} />
|
|
73
|
+
<Path d="M0 8L8 0" stroke={theme.muted} strokeWidth="1" />
|
|
74
|
+
</Pattern>
|
|
75
|
+
|
|
76
|
+
{/* withheld: cross hatch — denser, reading as deliberately closed off. */}
|
|
77
|
+
<Pattern
|
|
78
|
+
id={TEXTURE_IDS.lockedHatch}
|
|
79
|
+
patternUnits="userSpaceOnUse"
|
|
80
|
+
width="8"
|
|
81
|
+
height="8"
|
|
82
|
+
>
|
|
83
|
+
<Rect width="8" height="8" fill={theme.surface} />
|
|
84
|
+
<Path d="M0 8L8 0M0 0L8 8" stroke={theme.muted} strokeWidth="1" />
|
|
85
|
+
</Pattern>
|
|
86
|
+
</Defs>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fillFor(mark: MarkPath): string {
|
|
91
|
+
if (mark.texture) {
|
|
92
|
+
return `url(#${TEXTURE_IDS[mark.texture]})`;
|
|
93
|
+
}
|
|
94
|
+
return mark.filled ? mark.color : 'none';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function Marks({
|
|
98
|
+
paths,
|
|
99
|
+
selectedKeys,
|
|
100
|
+
}: {
|
|
101
|
+
readonly paths: readonly MarkPath[];
|
|
102
|
+
readonly selectedKeys?: ReadonlySet<string>;
|
|
103
|
+
}) {
|
|
104
|
+
return (
|
|
105
|
+
<G>
|
|
106
|
+
{paths.map(mark => {
|
|
107
|
+
const highlighted = selectedKeys?.has(mark.seriesId);
|
|
108
|
+
return (
|
|
109
|
+
<Path
|
|
110
|
+
key={mark.key}
|
|
111
|
+
d={mark.d}
|
|
112
|
+
fill={fillFor(mark)}
|
|
113
|
+
stroke={mark.filled ? 'none' : mark.color}
|
|
114
|
+
strokeWidth={mark.filled ? 0 : highlighted ? 3 : 2}
|
|
115
|
+
strokeDasharray={
|
|
116
|
+
mark.filled ? undefined : dashArrayFor(mark.pattern)
|
|
117
|
+
}
|
|
118
|
+
strokeLinejoin="round"
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
opacity={mark.opacity ?? 1}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</G>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function MarkerLines({
|
|
129
|
+
markers,
|
|
130
|
+
theme,
|
|
131
|
+
}: {
|
|
132
|
+
readonly markers: readonly MarkerLine[];
|
|
133
|
+
readonly theme: ChartTheme;
|
|
134
|
+
}) {
|
|
135
|
+
return (
|
|
136
|
+
<G>
|
|
137
|
+
{markers.map(marker => (
|
|
138
|
+
<Path
|
|
139
|
+
key={marker.id}
|
|
140
|
+
d={marker.d}
|
|
141
|
+
stroke={theme.onSurface}
|
|
142
|
+
strokeWidth={1}
|
|
143
|
+
strokeDasharray="4,3"
|
|
144
|
+
fill="none"
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
</G>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function GeometryLabels({
|
|
152
|
+
labels,
|
|
153
|
+
theme,
|
|
154
|
+
fontSize,
|
|
155
|
+
}: {
|
|
156
|
+
readonly labels: readonly GeometryLabel[];
|
|
157
|
+
readonly theme: ChartTheme;
|
|
158
|
+
readonly fontSize: number;
|
|
159
|
+
}) {
|
|
160
|
+
return (
|
|
161
|
+
<G>
|
|
162
|
+
{labels
|
|
163
|
+
.filter(label => !label.muted)
|
|
164
|
+
.map(label => (
|
|
165
|
+
<SvgText
|
|
166
|
+
key={label.key}
|
|
167
|
+
x={label.x}
|
|
168
|
+
y={label.y}
|
|
169
|
+
fill={theme.typography.valueLabel.color}
|
|
170
|
+
fontSize={fontSize}
|
|
171
|
+
fontWeight={theme.typography.valueLabel.fontWeight}
|
|
172
|
+
textAnchor={label.anchor}
|
|
173
|
+
>
|
|
174
|
+
{label.text}
|
|
175
|
+
</SvgText>
|
|
176
|
+
))}
|
|
177
|
+
</G>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Marks the currently selected point, when it still resolves. */
|
|
182
|
+
export function SelectionHighlight({
|
|
183
|
+
x,
|
|
184
|
+
y,
|
|
185
|
+
color,
|
|
186
|
+
}: {
|
|
187
|
+
readonly x: number;
|
|
188
|
+
readonly y: number;
|
|
189
|
+
readonly color: string;
|
|
190
|
+
}) {
|
|
191
|
+
return (
|
|
192
|
+
<G>
|
|
193
|
+
<Rect
|
|
194
|
+
x={x - 6}
|
|
195
|
+
y={y - 6}
|
|
196
|
+
width={12}
|
|
197
|
+
height={12}
|
|
198
|
+
rx={6}
|
|
199
|
+
fill={color}
|
|
200
|
+
opacity={0.25}
|
|
201
|
+
/>
|
|
202
|
+
<Rect x={x - 3} y={y - 3} width={6} height={6} rx={3} fill={color} />
|
|
203
|
+
</G>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dataset budgets (FR-074, FR-075, FR-076).
|
|
3
|
+
*
|
|
4
|
+
* Over budget, the chart **still renders**. Degrading silently would break the
|
|
5
|
+
* "never present a reduced picture as complete" rule, and refusing to draw
|
|
6
|
+
* would turn a performance hint into an outage. Instead we warn the developer
|
|
7
|
+
* once, in dev builds only.
|
|
8
|
+
*
|
|
9
|
+
* The `__DEV__` gate matters: it makes the check dead code that Metro strips
|
|
10
|
+
* from release bundles, which is what makes "never in production" verifiable
|
|
11
|
+
* rather than merely intended.
|
|
12
|
+
*
|
|
13
|
+
* The figures were validated against emitted element counts in
|
|
14
|
+
* specs/007-rn-chart-package/research.md R2 — element count scales with series,
|
|
15
|
+
* not with points, so these budgets are about JS work rather than view count.
|
|
16
|
+
*/
|
|
17
|
+
import type { ChartDatasource, ChartFamily } from '../contract/datasource';
|
|
18
|
+
|
|
19
|
+
export interface DatasetBudget {
|
|
20
|
+
readonly maxSeries: number;
|
|
21
|
+
readonly maxPointsPerSeries?: number;
|
|
22
|
+
readonly maxCells?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DATASET_BUDGETS: Readonly<Record<ChartFamily, DatasetBudget>> = {
|
|
26
|
+
timeSeries: { maxSeries: 5, maxPointsPerSeries: 500 },
|
|
27
|
+
bar: { maxSeries: 5, maxPointsPerSeries: 60 },
|
|
28
|
+
distribution: { maxSeries: 1, maxPointsPerSeries: 12 },
|
|
29
|
+
progress: { maxSeries: 1, maxPointsPerSeries: 8 },
|
|
30
|
+
heatmap: { maxSeries: 1, maxCells: 1000 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface BudgetCheck {
|
|
34
|
+
readonly withinBudget: boolean;
|
|
35
|
+
readonly seriesCount: number;
|
|
36
|
+
readonly maxPointsInAnySeries: number;
|
|
37
|
+
readonly totalPoints: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function checkBudget<M>(
|
|
41
|
+
datasource: ChartDatasource<M>,
|
|
42
|
+
family: ChartFamily,
|
|
43
|
+
): BudgetCheck {
|
|
44
|
+
const budget = DATASET_BUDGETS[family];
|
|
45
|
+
const seriesCount = datasource.series.length;
|
|
46
|
+
|
|
47
|
+
let maxPoints = 0;
|
|
48
|
+
let totalPoints = 0;
|
|
49
|
+
for (const series of datasource.series) {
|
|
50
|
+
maxPoints = Math.max(maxPoints, series.points.length);
|
|
51
|
+
totalPoints += series.points.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const overSeries = seriesCount > budget.maxSeries;
|
|
55
|
+
const overPoints =
|
|
56
|
+
budget.maxPointsPerSeries !== undefined &&
|
|
57
|
+
maxPoints > budget.maxPointsPerSeries;
|
|
58
|
+
const overCells =
|
|
59
|
+
budget.maxCells !== undefined && totalPoints > budget.maxCells;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
withinBudget: !overSeries && !overPoints && !overCells,
|
|
63
|
+
seriesCount,
|
|
64
|
+
maxPointsInAnySeries: maxPoints,
|
|
65
|
+
totalPoints,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const warnedCharts = new Set<string>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Warns once per chart instance per mount. De-duplicating matters: a scrolling
|
|
73
|
+
* dashboard would otherwise flood the console on every frame.
|
|
74
|
+
*/
|
|
75
|
+
export function warnIfOverBudget<M>(
|
|
76
|
+
datasource: ChartDatasource<M>,
|
|
77
|
+
family: ChartFamily,
|
|
78
|
+
instanceKey: string,
|
|
79
|
+
): BudgetCheck {
|
|
80
|
+
const check = checkBudget(datasource, family);
|
|
81
|
+
|
|
82
|
+
if (__DEV__ && !check.withinBudget && !warnedCharts.has(instanceKey)) {
|
|
83
|
+
warnedCharts.add(instanceKey);
|
|
84
|
+
const budget = DATASET_BUDGETS[family];
|
|
85
|
+
console.warn(
|
|
86
|
+
`[rn-chart] ${family} chart "${instanceKey}" exceeds its documented dataset budget ` +
|
|
87
|
+
`(${check.seriesCount} series x up to ${check.maxPointsInAnySeries} points; ` +
|
|
88
|
+
`budget is ${budget.maxSeries} series` +
|
|
89
|
+
(budget.maxPointsPerSeries
|
|
90
|
+
? ` x ${budget.maxPointsPerSeries} points`
|
|
91
|
+
: '') +
|
|
92
|
+
(budget.maxCells ? `, ${budget.maxCells} cells` : '') +
|
|
93
|
+
'). The chart still renders and drops nothing — aggregate or downsample ' +
|
|
94
|
+
'upstream if it feels slow. This warning appears once per chart and never ' +
|
|
95
|
+
'in production builds.',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return check;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Called on unmount so a remounted chart can warn again. */
|
|
103
|
+
export function releaseBudgetWarning(instanceKey: string): void {
|
|
104
|
+
warnedCharts.delete(instanceKey);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Test-only reset. */
|
|
108
|
+
export function __resetBudgetWarnings(): void {
|
|
109
|
+
warnedCharts.clear();
|
|
110
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datasource validation (FR-015, FR-017, FR-007a).
|
|
3
|
+
*
|
|
4
|
+
* Rules V1-V8 from specs/007-rn-chart-package/data-model.md §6. Every failure
|
|
5
|
+
* returns a `ChartError` built from the fixed message catalogue, so validation
|
|
6
|
+
* output can never carry a data value or a series label.
|
|
7
|
+
*
|
|
8
|
+
* Note V8: an empty datasource is **not** an error. It resolves to the empty
|
|
9
|
+
* state, which is a first-class outcome rather than a failure.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
familyAcceptsPositionKind,
|
|
13
|
+
type ChartDatasource,
|
|
14
|
+
type ChartFamily,
|
|
15
|
+
} from '../contract/datasource';
|
|
16
|
+
import { validationError, type ChartError } from '../contract/errors';
|
|
17
|
+
import { isKnown } from '../contract/value';
|
|
18
|
+
|
|
19
|
+
export interface ValidationResult {
|
|
20
|
+
readonly error?: ChartError;
|
|
21
|
+
/** No points at all across every series (V8). */
|
|
22
|
+
readonly isEmpty: boolean;
|
|
23
|
+
/** Some points are known and others are not (FR-032). */
|
|
24
|
+
readonly isPartial: boolean;
|
|
25
|
+
/** Distribution segments that do not sum to the whole (V6, FR-024). */
|
|
26
|
+
readonly totalIncomplete: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DISTRIBUTION_SUM_TOLERANCE = 0.001;
|
|
30
|
+
|
|
31
|
+
export function validateDatasource<M>(
|
|
32
|
+
datasource: ChartDatasource<M>,
|
|
33
|
+
family: ChartFamily,
|
|
34
|
+
chartId?: string,
|
|
35
|
+
): ValidationResult {
|
|
36
|
+
const base = { isEmpty: true, isPartial: false, totalIncomplete: false };
|
|
37
|
+
|
|
38
|
+
// V4 — the receiving family must accept the declared position kind. Checked
|
|
39
|
+
// first because a mismatch here makes everything downstream meaningless.
|
|
40
|
+
if (!familyAcceptsPositionKind(family, datasource.positionKind)) {
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
isEmpty: false,
|
|
44
|
+
error: validationError('familyPositionKindUnsupported', {
|
|
45
|
+
chartId,
|
|
46
|
+
family,
|
|
47
|
+
detail: { actualKind: datasource.positionKind },
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// V1 — series ids unique.
|
|
53
|
+
const seenSeriesIds = new Set<string>();
|
|
54
|
+
for (const series of datasource.series) {
|
|
55
|
+
if (seenSeriesIds.has(series.id)) {
|
|
56
|
+
return {
|
|
57
|
+
...base,
|
|
58
|
+
isEmpty: false,
|
|
59
|
+
error: validationError('duplicateSeriesId', {
|
|
60
|
+
chartId,
|
|
61
|
+
family,
|
|
62
|
+
detail: { occurrences: seenSeriesIds.size },
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
seenSeriesIds.add(series.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let totalPoints = 0;
|
|
70
|
+
let knownPoints = 0;
|
|
71
|
+
const matrixRows = new Set<string>();
|
|
72
|
+
const matrixColumns = new Set<string>();
|
|
73
|
+
|
|
74
|
+
for (const series of datasource.series) {
|
|
75
|
+
// V2 — point ids unique within their series.
|
|
76
|
+
const seenPointIds = new Set<string>();
|
|
77
|
+
|
|
78
|
+
for (const point of series.points) {
|
|
79
|
+
if (seenPointIds.has(point.id)) {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
isEmpty: false,
|
|
83
|
+
error: validationError('duplicatePointId', {
|
|
84
|
+
chartId,
|
|
85
|
+
family,
|
|
86
|
+
detail: { occurrences: seenPointIds.size },
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
seenPointIds.add(point.id);
|
|
91
|
+
|
|
92
|
+
// V3 — every point matches the declared position kind.
|
|
93
|
+
if (point.position.kind !== datasource.positionKind) {
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
isEmpty: false,
|
|
97
|
+
error: validationError('positionKindMismatch', {
|
|
98
|
+
chartId,
|
|
99
|
+
family,
|
|
100
|
+
detail: {
|
|
101
|
+
expectedKind: datasource.positionKind,
|
|
102
|
+
actualKind: point.position.kind,
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (point.position.kind === 'matrix') {
|
|
109
|
+
matrixRows.add(point.position.row);
|
|
110
|
+
matrixColumns.add(point.position.column);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
totalPoints += 1;
|
|
114
|
+
if (isKnown(point.value)) {
|
|
115
|
+
knownPoints += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// V5 — a matrix must be rectangular: every row/column pair accounted for.
|
|
121
|
+
if (datasource.positionKind === 'matrix' && totalPoints > 0) {
|
|
122
|
+
const expected = matrixRows.size * matrixColumns.size;
|
|
123
|
+
if (totalPoints !== expected) {
|
|
124
|
+
return {
|
|
125
|
+
...base,
|
|
126
|
+
isEmpty: false,
|
|
127
|
+
error: validationError('inconsistentMatrix', {
|
|
128
|
+
chartId,
|
|
129
|
+
family,
|
|
130
|
+
detail: { occurrences: Math.abs(expected - totalPoints) },
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// V6 — distribution segments that do not sum to the whole are flagged, never
|
|
137
|
+
// rescaled. This is a flag rather than a rejection: the chart still renders
|
|
138
|
+
// and marks the total incomplete.
|
|
139
|
+
let totalIncomplete = knownPoints < totalPoints;
|
|
140
|
+
if (family === 'distribution' && totalPoints > 0) {
|
|
141
|
+
totalIncomplete = totalIncomplete || !distributionSumsToWhole(datasource);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
isEmpty: totalPoints === 0,
|
|
146
|
+
isPartial: totalPoints > 0 && knownPoints > 0 && knownPoints < totalPoints,
|
|
147
|
+
totalIncomplete,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Distribution values may be supplied either as proportions summing to 1 or as
|
|
153
|
+
* absolute values that the chart normalises for display. Only the proportion
|
|
154
|
+
* case can be checked for wholeness, and only when every point is known.
|
|
155
|
+
*/
|
|
156
|
+
function distributionSumsToWhole<M>(datasource: ChartDatasource<M>): boolean {
|
|
157
|
+
const values: number[] = [];
|
|
158
|
+
for (const series of datasource.series) {
|
|
159
|
+
for (const point of series.points) {
|
|
160
|
+
if (!isKnown(point.value)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
values.push(point.value.value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
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.
|
|
171
|
+
return sum > 0;
|
|
172
|
+
}
|
|
173
|
+
return Math.abs(sum - 1) <= DISTRIBUTION_SUM_TOLERANCE;
|
|
174
|
+
}
|