@chipmobilesdk/rn-chart 0.3.0 → 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.
@@ -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
- * Two properties matter and both are load-bearing:
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: (ref: SelectionRef | null, event: PointInteractionEvent<M> | null) => void;
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 (!selection || resolved || args.interactionSuppressed || !args.geometryReady) {
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 = key;
100
+ reportedRef.current = selectionKey;
79
101
 
80
- if (isControlled) {
81
- args.onSelectionUnresolvable?.(selection);
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
- }, [selection, resolved, isControlled, args, args.geometryReady]);
110
+ }, [unresolved, selectionKey]);
86
111
 
87
112
  const select = useCallback(
88
- (ref: SelectionRef | null, event: PointInteractionEvent<M> | null) => {
89
- if (args.interactionSuppressed) {
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
- if (!isControlled) {
93
- setInternal(prev => (sameSelection(prev, ref) ? null : ref));
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?.(ref, event);
125
+ current.args.onSelectionChange?.(next, next ? event : null);
96
126
  },
97
- [args, isControlled],
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 hidden = new Set(hiddenList);
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 toggle = useCallback(
127
- (seriesId: string, metadata?: unknown) => {
128
- if (!args.enabled) {
129
- return;
130
- }
164
+ const latest = useRef({ args, hidden, isControlled });
165
+ latest.current = { args, hidden, isControlled };
131
166
 
132
- const currentlyHidden = new Set(isControlled ? (args.controlled ?? []) : internal);
133
- const willBeVisible = currentlyHidden.has(seriesId);
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
- if (!isControlled) {
136
- setInternal(prev =>
137
- prev.includes(seriesId) ? prev.filter(id => id !== seriesId) : [...prev, seriesId],
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
- args.onVisibilityChange?.({
142
- type: 'visibilityChange',
143
- seriesId,
144
- visible: willBeVisible,
145
- metadata: metadata as M,
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
+ }
@@ -43,26 +43,29 @@ export function XAxis({
43
43
  ticks,
44
44
  theme,
45
45
  fontSize,
46
- rotateLabels,
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
- readonly rotateLabels?: boolean;
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
- <Line
59
- x1={plot.x}
60
- y1={baseline}
61
- x2={plotRight(plot)}
62
- y2={baseline}
63
- stroke={theme.axis}
64
- strokeWidth={1}
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={rotateLabels ? 'end' : 'middle'}
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
- <Line
99
- x1={plot.x}
100
- y1={plot.y}
101
- x2={plot.x}
102
- y2={plotBottom(plot)}
103
- stroke={theme.axis}
104
- strokeWidth={1}
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}`}
@@ -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 : highlighted ? 3 : 2}
85
+ strokeWidth={mark.filled ? 0 : 2}
93
86
  strokeDasharray={mark.filled ? undefined : dashArrayFor(mark.pattern)}
94
87
  strokeLinejoin="round"
95
88
  strokeLinecap="round"
@@ -76,10 +76,14 @@ export function warnIfOverBudget<M>(
76
76
  datasource: ChartDatasource<M>,
77
77
  family: ChartFamily,
78
78
  instanceKey: string,
79
- ): BudgetCheck {
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
+ }
80
84
  const check = checkBudget(datasource, family);
81
85
 
82
- if (__DEV__ && !check.withinBudget && !warnedCharts.has(instanceKey)) {
86
+ if (!check.withinBudget) {
83
87
  warnedCharts.add(instanceKey);
84
88
  channel.emit('over_budget', 'warn', {
85
89
  family,
@@ -98,8 +102,6 @@ export function warnIfOverBudget<M>(
98
102
  'in production builds.',
99
103
  );
100
104
  }
101
-
102
- return check;
103
105
  }
104
106
 
105
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
- * 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.
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>(datasource: ChartDatasource<M>): boolean {
157
- const values: number[] = [];
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
- values.push(point.value.value);
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;