@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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-series geometry: one stroked path per series over an instant domain.
|
|
3
|
+
*
|
|
4
|
+
* Gaps are the interesting part. A non-known value contributes `null` to the
|
|
5
|
+
* point list, `linePath` lifts the pen there, and the line simply does not
|
|
6
|
+
* exist across the gap (FR-012).
|
|
7
|
+
*
|
|
8
|
+
* PURE. No React, no SVG.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChartDatasource, Series } from '../contract/datasource';
|
|
11
|
+
import { isKnown, isWithheld } from '../contract/value';
|
|
12
|
+
import type { HitRegion } from './hitTest';
|
|
13
|
+
import { plotBottom, plotRight } from './layout';
|
|
14
|
+
import { linePath, type MaybePt } from './path';
|
|
15
|
+
import { domainFrom, linearScale, timeScale } from './scale';
|
|
16
|
+
import { niceTickValues, thinTicks, type Tick } from './ticks';
|
|
17
|
+
import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
|
|
18
|
+
|
|
19
|
+
export interface TimeSeriesOptions {
|
|
20
|
+
/** Window supplied by the app. Users cannot draw one (pan/zoom out of scope). */
|
|
21
|
+
readonly range?: { readonly from: number; readonly to: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function visiblePoints<M>(series: Series<M>, range?: TimeSeriesOptions['range']) {
|
|
25
|
+
const points = [...series.points]
|
|
26
|
+
.filter(p => p.position.kind === 'instant')
|
|
27
|
+
// Out-of-order input must still produce a correct axis.
|
|
28
|
+
.sort((a, b) => {
|
|
29
|
+
const at = a.position.kind === 'instant' ? a.position.at : 0;
|
|
30
|
+
const bt = b.position.kind === 'instant' ? b.position.at : 0;
|
|
31
|
+
return at - bt;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!range) {
|
|
35
|
+
return points;
|
|
36
|
+
}
|
|
37
|
+
return points.filter(p => {
|
|
38
|
+
const at = p.position.kind === 'instant' ? p.position.at : 0;
|
|
39
|
+
return at >= range.from && at <= range.to;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildTimeSeriesGeometry<M>(
|
|
44
|
+
datasource: ChartDatasource<M>,
|
|
45
|
+
input: GeometryInput,
|
|
46
|
+
options: TimeSeriesOptions = {},
|
|
47
|
+
): ResolvedGeometry {
|
|
48
|
+
const { plot } = input;
|
|
49
|
+
|
|
50
|
+
const instants: number[] = [];
|
|
51
|
+
const values: number[] = [];
|
|
52
|
+
let excluded = 0;
|
|
53
|
+
|
|
54
|
+
for (const series of datasource.series) {
|
|
55
|
+
for (const point of visiblePoints(series, options.range)) {
|
|
56
|
+
if (point.position.kind === 'instant') {
|
|
57
|
+
instants.push(point.position.at);
|
|
58
|
+
}
|
|
59
|
+
if (isKnown(point.value)) {
|
|
60
|
+
values.push(point.value.value);
|
|
61
|
+
} else {
|
|
62
|
+
excluded += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (instants.length === 0) {
|
|
68
|
+
return {
|
|
69
|
+
plot,
|
|
70
|
+
paths: [],
|
|
71
|
+
markers: [],
|
|
72
|
+
labels: [],
|
|
73
|
+
xTicks: [],
|
|
74
|
+
yTicks: [],
|
|
75
|
+
hitRegions: [],
|
|
76
|
+
incomplete: excluded > 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const x = timeScale(domainFrom(instants), {
|
|
81
|
+
min: plot.x,
|
|
82
|
+
max: plotRight(plot),
|
|
83
|
+
});
|
|
84
|
+
const y = linearScale(domainFrom(values, { padFraction: 0.1 }), {
|
|
85
|
+
min: plotBottom(plot),
|
|
86
|
+
max: plot.y,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const paths: MarkPath[] = [];
|
|
90
|
+
const hitRegions: HitRegion[] = [];
|
|
91
|
+
|
|
92
|
+
for (const series of datasource.series) {
|
|
93
|
+
if (series.hidden) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const pts: MaybePt[] = [];
|
|
98
|
+
|
|
99
|
+
for (const point of visiblePoints(series, options.range)) {
|
|
100
|
+
const at = point.position.kind === 'instant' ? point.position.at : 0;
|
|
101
|
+
const px = x.project(at);
|
|
102
|
+
|
|
103
|
+
if (!isKnown(point.value)) {
|
|
104
|
+
// The gap. No coordinate is produced, so no segment can span it.
|
|
105
|
+
pts.push(null);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const py = y.project(point.value.value);
|
|
110
|
+
pts.push({ x: px, y: py });
|
|
111
|
+
|
|
112
|
+
hitRegions.push({
|
|
113
|
+
seriesId: series.id,
|
|
114
|
+
pointId: point.id,
|
|
115
|
+
x: px,
|
|
116
|
+
y: py,
|
|
117
|
+
// Withheld values are never selectable, so a callback can never
|
|
118
|
+
// carry one (FR-053).
|
|
119
|
+
selectable: !isWithheld(point.value),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const d = linePath(pts);
|
|
124
|
+
if (d.length > 0) {
|
|
125
|
+
paths.push({
|
|
126
|
+
key: `line-${series.id}`,
|
|
127
|
+
seriesId: series.id,
|
|
128
|
+
d,
|
|
129
|
+
color: input.seriesColors[series.id],
|
|
130
|
+
pattern: series.pattern ?? 'solid',
|
|
131
|
+
filled: false,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const xTicks: Tick[] = niceTickValues(x.domain, 4).map(v => ({
|
|
137
|
+
value: v,
|
|
138
|
+
position: x.project(v),
|
|
139
|
+
label: input.formatInstant(v),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const yTicks: Tick[] = niceTickValues(y.domain, 4).map(v => ({
|
|
143
|
+
value: v,
|
|
144
|
+
position: y.project(v),
|
|
145
|
+
label: input.formatValue(v),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
plot,
|
|
150
|
+
paths,
|
|
151
|
+
markers: [],
|
|
152
|
+
labels: [],
|
|
153
|
+
xTicks: thinTicks(xTicks, input.fontSize, plot.width),
|
|
154
|
+
yTicks,
|
|
155
|
+
hitRegions,
|
|
156
|
+
incomplete: excluded > 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single memoized artefact each chart produces.
|
|
3
|
+
*
|
|
4
|
+
* `ResolvedGeometry` is keyed on datasource identity, plot rectangle, theme
|
|
5
|
+
* identity, and formatter identity — and deliberately **not** on selection.
|
|
6
|
+
* Highlighting a point must repaint the highlight, not recompute every scale
|
|
7
|
+
* and path string (research.md R3, FR-073).
|
|
8
|
+
*
|
|
9
|
+
* PURE. No React, no SVG.
|
|
10
|
+
*/
|
|
11
|
+
import type { SeriesPattern } from '../contract/datasource';
|
|
12
|
+
import type { NonValueTexture } from '../contract/theme';
|
|
13
|
+
import type { HitRegion } from './hitTest';
|
|
14
|
+
import type { PlotRect } from './layout';
|
|
15
|
+
import type { Tick } from './ticks';
|
|
16
|
+
|
|
17
|
+
/** One drawable group. Element count scales with these, never with points. */
|
|
18
|
+
export interface MarkPath {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly seriesId: string;
|
|
21
|
+
readonly d: string;
|
|
22
|
+
readonly color: string;
|
|
23
|
+
readonly pattern: SeriesPattern;
|
|
24
|
+
/** Filled shapes (bars, arcs, cells) versus stroked ones (lines). */
|
|
25
|
+
readonly filled: boolean;
|
|
26
|
+
/** Set on marks standing in for a non-value class: texture, not hue. */
|
|
27
|
+
readonly texture?: NonValueTexture;
|
|
28
|
+
readonly opacity?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A target, baseline, or threshold drawn across the plot. */
|
|
32
|
+
export interface MarkerLine {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly d: string;
|
|
35
|
+
readonly label?: string;
|
|
36
|
+
readonly kind: 'target' | 'baseline' | 'threshold';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Text the chart draws itself, such as progress values or segment labels. */
|
|
40
|
+
export interface GeometryLabel {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly text: string;
|
|
43
|
+
readonly x: number;
|
|
44
|
+
readonly y: number;
|
|
45
|
+
readonly anchor: 'start' | 'middle' | 'end';
|
|
46
|
+
readonly muted?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ResolvedGeometry {
|
|
50
|
+
readonly plot: PlotRect;
|
|
51
|
+
readonly paths: readonly MarkPath[];
|
|
52
|
+
readonly markers: readonly MarkerLine[];
|
|
53
|
+
readonly labels: readonly GeometryLabel[];
|
|
54
|
+
readonly xTicks: readonly Tick[];
|
|
55
|
+
readonly yTicks: readonly Tick[];
|
|
56
|
+
readonly hitRegions: readonly HitRegion[];
|
|
57
|
+
/**
|
|
58
|
+
* True when something was excluded from a total or proportion, so the chart
|
|
59
|
+
* can say so rather than presenting a partial figure as whole (FR-013).
|
|
60
|
+
*/
|
|
61
|
+
readonly incomplete: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const EMPTY_GEOMETRY: ResolvedGeometry = {
|
|
65
|
+
plot: { x: 0, y: 0, width: 0, height: 0 },
|
|
66
|
+
paths: [],
|
|
67
|
+
markers: [],
|
|
68
|
+
labels: [],
|
|
69
|
+
xTicks: [],
|
|
70
|
+
yTicks: [],
|
|
71
|
+
hitRegions: [],
|
|
72
|
+
incomplete: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Inputs every family geometry builder needs. */
|
|
76
|
+
export interface GeometryInput {
|
|
77
|
+
readonly plot: PlotRect;
|
|
78
|
+
readonly fontSize: number;
|
|
79
|
+
/** Resolved per series, so geometry never reaches into the theme itself. */
|
|
80
|
+
readonly seriesColors: Readonly<Record<string, string>>;
|
|
81
|
+
readonly nonValueTextures: Readonly<
|
|
82
|
+
Record<'unknown' | 'notApplicable' | 'withheld', NonValueTexture>
|
|
83
|
+
>;
|
|
84
|
+
readonly heatmapRamp: readonly string[];
|
|
85
|
+
readonly formatValue: (value: number) => string;
|
|
86
|
+
readonly formatCategory: (raw: string) => string;
|
|
87
|
+
readonly formatInstant: (at: number) => string;
|
|
88
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-chart — domain-agnostic charting for ChipMobileSdk-family
|
|
3
|
+
* React Native apps.
|
|
4
|
+
*
|
|
5
|
+
* This is the single public entrypoint (LIB-003). Modules under `geometry/`,
|
|
6
|
+
* `render/`, `validation/`, and the internals of `react/` are private; import
|
|
7
|
+
* them only through this file. Keeping them private is what lets the geometry,
|
|
8
|
+
* the layout maths, and the rendering substrate change without a breaking
|
|
9
|
+
* release.
|
|
10
|
+
*
|
|
11
|
+
* The package makes no network request, calls no AI service, reads no
|
|
12
|
+
* database, persists nothing, and logs no data value or series label. It
|
|
13
|
+
* renders numeric data and app-supplied labels — nothing else.
|
|
14
|
+
*
|
|
15
|
+
* There is deliberately **no insight, message, or narrative surface**: apps
|
|
16
|
+
* render their own text around a chart, and a chart imposes no outer margin
|
|
17
|
+
* that would fight that layout.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Value semantics — the foundation of the contract
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export type {
|
|
24
|
+
KnownValue,
|
|
25
|
+
NotApplicableValue,
|
|
26
|
+
TotalResult,
|
|
27
|
+
UnknownValue,
|
|
28
|
+
ValueKind,
|
|
29
|
+
ValueState,
|
|
30
|
+
WithheldValue,
|
|
31
|
+
} from './contract/value';
|
|
32
|
+
export {
|
|
33
|
+
isKnown,
|
|
34
|
+
isWithheld,
|
|
35
|
+
known,
|
|
36
|
+
notApplicable,
|
|
37
|
+
participatesInTotals,
|
|
38
|
+
sumKnown,
|
|
39
|
+
unknown,
|
|
40
|
+
withheld,
|
|
41
|
+
} from './contract/value';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Datasource contract
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
export type {
|
|
47
|
+
CategoryPosition,
|
|
48
|
+
ChartDatasource,
|
|
49
|
+
ChartFamily,
|
|
50
|
+
DataPoint,
|
|
51
|
+
InstantPosition,
|
|
52
|
+
MatrixPosition,
|
|
53
|
+
Position,
|
|
54
|
+
PositionKind,
|
|
55
|
+
Series,
|
|
56
|
+
SeriesPattern,
|
|
57
|
+
} from './contract/datasource';
|
|
58
|
+
export {
|
|
59
|
+
FAMILY_POSITION_KINDS,
|
|
60
|
+
familyAcceptsPositionKind,
|
|
61
|
+
resolveFormat,
|
|
62
|
+
resolveUnit,
|
|
63
|
+
} from './contract/datasource';
|
|
64
|
+
|
|
65
|
+
export type { MarkerKind, ProgressReading, ReferenceMarker } from './contract/marker';
|
|
66
|
+
export { findTarget, isUsableTarget, readProgress } from './contract/marker';
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Formatting
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
export type {
|
|
72
|
+
ChartFormatters,
|
|
73
|
+
FormatContext,
|
|
74
|
+
FormatHint,
|
|
75
|
+
SeriesRef,
|
|
76
|
+
} from './contract/format';
|
|
77
|
+
export {
|
|
78
|
+
NON_VALUE_TEXT,
|
|
79
|
+
formatLegendDefault,
|
|
80
|
+
formatPositionDefault,
|
|
81
|
+
formatValueDefault,
|
|
82
|
+
seriesRef,
|
|
83
|
+
} from './contract/format';
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// State and errors
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
export type {
|
|
89
|
+
ChartStateContent,
|
|
90
|
+
ChartStatus,
|
|
91
|
+
DeclarableChartStatus,
|
|
92
|
+
StatusInputs,
|
|
93
|
+
} from './contract/state';
|
|
94
|
+
export {
|
|
95
|
+
STATUS_PRECEDENCE,
|
|
96
|
+
drawsData,
|
|
97
|
+
resolveStatus,
|
|
98
|
+
suppressesInteraction,
|
|
99
|
+
} from './contract/state';
|
|
100
|
+
|
|
101
|
+
export type {
|
|
102
|
+
ChartError,
|
|
103
|
+
ChartErrorCategory,
|
|
104
|
+
ChartErrorCode,
|
|
105
|
+
ChartFailureHandler,
|
|
106
|
+
ChartFailureReport,
|
|
107
|
+
} from './contract/errors';
|
|
108
|
+
export { isChartError } from './contract/errors';
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Interaction
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
export type {
|
|
114
|
+
ChartInteractionEvent,
|
|
115
|
+
InteractionProps,
|
|
116
|
+
PointInteractionEvent,
|
|
117
|
+
SelectionRef,
|
|
118
|
+
SeriesInteractionEvent,
|
|
119
|
+
} from './contract/interaction';
|
|
120
|
+
export { sameSelection } from './contract/interaction';
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Theme
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
export type {
|
|
126
|
+
ChartTheme,
|
|
127
|
+
LegendEntry,
|
|
128
|
+
NonValueTexture,
|
|
129
|
+
TextStyleTokens,
|
|
130
|
+
} from './contract/theme';
|
|
131
|
+
export { defaultChartTheme, resolveSeriesColor } from './contract/theme';
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Props and accessibility
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
export type {
|
|
137
|
+
BaseChartProps,
|
|
138
|
+
ChartAccessibility,
|
|
139
|
+
ValueFormatter,
|
|
140
|
+
} from './contract/props';
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Dataset budgets
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
export type { BudgetCheck, DatasetBudget } from './validation/budgets';
|
|
146
|
+
export { DATASET_BUDGETS, checkBudget } from './validation/budgets';
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Components
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
export type {
|
|
152
|
+
BarChartProps,
|
|
153
|
+
DistributionChartProps,
|
|
154
|
+
HeatmapChartProps,
|
|
155
|
+
ProgressChartProps,
|
|
156
|
+
TimeSeriesChartProps,
|
|
157
|
+
} from './react/charts';
|
|
158
|
+
export {
|
|
159
|
+
BarChart,
|
|
160
|
+
DistributionChart,
|
|
161
|
+
HeatmapChart,
|
|
162
|
+
ProgressChart,
|
|
163
|
+
TimeSeriesChart,
|
|
164
|
+
} from './react/charts';
|
|
165
|
+
|
|
166
|
+
export { ChartLegend, buildLegendEntries } from './react/ChartLegend';
|
|
167
|
+
export { ChartThemeProvider, useChartTheme } from './react/ChartThemeProvider';
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Accessibility helpers
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
export {
|
|
173
|
+
buildChartSummary,
|
|
174
|
+
buildPointAnnouncement,
|
|
175
|
+
buildTotalAnnouncement,
|
|
176
|
+
} from './a11y/summary';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chart render-failure containment (FR-038a, FR-038b).
|
|
3
|
+
*
|
|
4
|
+
* React 19 still has no hook equivalent of an error boundary —
|
|
5
|
+
* `getDerivedStateFromError` and `componentDidCatch` remain class-only — so
|
|
6
|
+
* this is a class. Forty lines beats adding a dependency for it.
|
|
7
|
+
*
|
|
8
|
+
* Three behaviours are deliberate:
|
|
9
|
+
*
|
|
10
|
+
* - **Reset only on a real change.** The boundary clears its error when the
|
|
11
|
+
* datasource identity or chart key changes, never on an unrelated parent
|
|
12
|
+
* re-render. A deterministic geometry defect therefore cannot pin the CPU in
|
|
13
|
+
* a render-fail-render loop.
|
|
14
|
+
* - **Report once per distinct failure.** De-duplicated by signature, so a
|
|
15
|
+
* chart re-rendered by a sibling's state change does not re-report.
|
|
16
|
+
* - **Redact.** The report carries the chart identity and a catalogue message.
|
|
17
|
+
* A third-party exception's own text is never copied in, because we cannot
|
|
18
|
+
* promise it is free of data (POL-011).
|
|
19
|
+
*/
|
|
20
|
+
import React from 'react';
|
|
21
|
+
|
|
22
|
+
import type { ChartFamily } from '../contract/datasource';
|
|
23
|
+
import {
|
|
24
|
+
chartError,
|
|
25
|
+
failureSignature,
|
|
26
|
+
toFailureReport,
|
|
27
|
+
type ChartError,
|
|
28
|
+
type ChartFailureHandler,
|
|
29
|
+
} from '../contract/errors';
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
readonly family: ChartFamily;
|
|
33
|
+
readonly chartId?: string;
|
|
34
|
+
readonly onFailure?: ChartFailureHandler;
|
|
35
|
+
/** Changing this clears a contained failure. */
|
|
36
|
+
readonly resetKey: unknown;
|
|
37
|
+
readonly renderFallback: (error: ChartError) => React.ReactNode;
|
|
38
|
+
readonly children: React.ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface State {
|
|
42
|
+
readonly error?: ChartError;
|
|
43
|
+
readonly resetKey: unknown;
|
|
44
|
+
readonly reportedSignature?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ChartBoundary extends React.Component<Props, State> {
|
|
48
|
+
constructor(props: Props) {
|
|
49
|
+
super(props);
|
|
50
|
+
this.state = { resetKey: props.resetKey };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static getDerivedStateFromProps(props: Props, state: State): State | null {
|
|
54
|
+
if (props.resetKey !== state.resetKey) {
|
|
55
|
+
// New data: give the chart another chance.
|
|
56
|
+
return { resetKey: props.resetKey, error: undefined };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static getDerivedStateFromError(): Partial<State> {
|
|
62
|
+
// This must set the error, not just acknowledge it. Returning an empty
|
|
63
|
+
// object leaves render() showing the children again, they throw again,
|
|
64
|
+
// and React escalates past the boundary — which is exactly the
|
|
65
|
+
// screen-blanking failure this class exists to prevent.
|
|
66
|
+
//
|
|
67
|
+
// componentDidCatch then refines this into a report carrying the chart
|
|
68
|
+
// identity and family.
|
|
69
|
+
return { error: chartError('render', 'renderException') };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
componentDidCatch(error: unknown, info: { componentStack?: string }): void {
|
|
73
|
+
const report = toFailureReport(
|
|
74
|
+
this.props.family,
|
|
75
|
+
error,
|
|
76
|
+
this.props.chartId,
|
|
77
|
+
info?.componentStack ?? undefined,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const signature = failureSignature(report);
|
|
81
|
+
if (this.state.reportedSignature === signature) {
|
|
82
|
+
this.setState({ error: report.error });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.setState({ error: report.error, reportedSignature: signature });
|
|
87
|
+
this.props.onFailure?.(report);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(): React.ReactNode {
|
|
91
|
+
if (this.state.error) {
|
|
92
|
+
return this.props.renderFallback(this.state.error);
|
|
93
|
+
}
|
|
94
|
+
return this.props.children;
|
|
95
|
+
}
|
|
96
|
+
}
|