@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +126 -115
  2. package/README.md +381 -381
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -136
  7. package/src/contract/errors.ts +129 -133
  8. package/src/contract/format.ts +162 -168
  9. package/src/contract/interaction.ts +70 -71
  10. package/src/contract/marker.ts +75 -75
  11. package/src/contract/props.ts +57 -60
  12. package/src/contract/state.ts +88 -94
  13. package/src/contract/theme.ts +169 -177
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -273
  16. package/src/geometry/distribution.ts +197 -203
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -173
  20. package/src/geometry/markers.ts +55 -57
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -230
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -168
  26. package/src/geometry/types.ts +94 -94
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -622
  30. package/src/react/ChartLegend.tsx +211 -215
  31. package/src/react/ChartThemeProvider.tsx +44 -48
  32. package/src/react/charts.tsx +154 -161
  33. package/src/react/useChartLayout.ts +124 -124
  34. package/src/react/useChartSelection.ts +152 -170
  35. package/src/render/Axis.tsx +120 -122
  36. package/src/render/Marks.tsx +173 -205
  37. package/src/validation/budgets.ts +103 -110
  38. package/src/validation/validateDatasource.ts +174 -174
@@ -1,168 +1,168 @@
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 { insetForAxes, plotBottom, plotRight } from './layout';
14
- import { horizontalMarkers, markerDomainValues } from './markers';
15
- import { linePath, type MaybePt } from './path';
16
- import { domainFrom, linearScale, timeScale, widenDegenerateDomain } from './scale';
17
- import { niceTickValues, thinTicks, type Tick } from './ticks';
18
- import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
19
-
20
- export interface TimeSeriesOptions {
21
- /** Window supplied by the app. Users cannot draw one (pan/zoom out of scope). */
22
- readonly range?: { readonly from: number; readonly to: number };
23
- }
24
-
25
- function visiblePoints<M>(series: Series<M>, range?: TimeSeriesOptions['range']) {
26
- const points = [...series.points]
27
- .filter(p => p.position.kind === 'instant')
28
- // Out-of-order input must still produce a correct axis.
29
- .sort((a, b) => {
30
- const at = a.position.kind === 'instant' ? a.position.at : 0;
31
- const bt = b.position.kind === 'instant' ? b.position.at : 0;
32
- return at - bt;
33
- });
34
-
35
- if (!range) {
36
- return points;
37
- }
38
- return points.filter(p => {
39
- const at = p.position.kind === 'instant' ? p.position.at : 0;
40
- return at >= range.from && at <= range.to;
41
- });
42
- }
43
-
44
- export function buildTimeSeriesGeometry<M>(
45
- datasource: ChartDatasource<M>,
46
- input: GeometryInput,
47
- options: TimeSeriesOptions = {},
48
- ): ResolvedGeometry {
49
- const instants: number[] = [];
50
- const values: number[] = [];
51
- let excluded = 0;
52
-
53
- for (const series of datasource.series) {
54
- for (const point of visiblePoints(series, options.range)) {
55
- if (point.position.kind === 'instant') {
56
- instants.push(point.position.at);
57
- }
58
- if (isKnown(point.value)) {
59
- values.push(point.value.value);
60
- } else {
61
- excluded += 1;
62
- }
63
- }
64
- }
65
-
66
- if (instants.length === 0) {
67
- return {
68
- plot: input.plot,
69
- paths: [],
70
- markers: [],
71
- labels: [],
72
- xTicks: [],
73
- yTicks: [],
74
- hitRegions: [],
75
- incomplete: excluded > 0,
76
- };
77
- }
78
-
79
- // Tick labels depend only on the domains, so the plot can make room for
80
- // them before anything is projected.
81
- // Widened here, as the scales would, so the ticks label the axes actually drawn.
82
- const xDomain = widenDegenerateDomain(domainFrom(instants));
83
- const yDomain = widenDegenerateDomain(
84
- domainFrom([...values, ...markerDomainValues(datasource.markers)], { padFraction: 0.1 }),
85
- );
86
- const xTickValues = niceTickValues(xDomain, 4);
87
- const yTickValues = niceTickValues(yDomain, 4);
88
- const plot = insetForAxes(input.plot, {
89
- xLabels: xTickValues.map(input.formatInstant),
90
- yLabels: yTickValues.map(input.formatValue),
91
- fontSize: input.fontSize,
92
- });
93
-
94
- const x = timeScale(xDomain, { min: plot.x, max: plotRight(plot) });
95
- const y = linearScale(yDomain, { min: plotBottom(plot), max: plot.y });
96
-
97
- const paths: MarkPath[] = [];
98
- const hitRegions: HitRegion[] = [];
99
-
100
- for (const series of datasource.series) {
101
- if (series.hidden) {
102
- continue;
103
- }
104
-
105
- const pts: MaybePt[] = [];
106
-
107
- for (const point of visiblePoints(series, options.range)) {
108
- const at = point.position.kind === 'instant' ? point.position.at : 0;
109
- const px = x.project(at);
110
-
111
- if (!isKnown(point.value)) {
112
- // The gap. No coordinate is produced, so no segment can span it.
113
- pts.push(null);
114
- continue;
115
- }
116
-
117
- const py = y.project(point.value.value);
118
- pts.push({ x: px, y: py });
119
-
120
- hitRegions.push({
121
- seriesId: series.id,
122
- pointId: point.id,
123
- x: px,
124
- y: py,
125
- // Withheld values are never selectable, so a callback can never
126
- // carry one (FR-053).
127
- selectable: !isWithheld(point.value),
128
- });
129
- }
130
-
131
- const d = linePath(pts);
132
- if (d.length > 0) {
133
- paths.push({
134
- key: `line-${series.id}`,
135
- seriesId: series.id,
136
- d,
137
- color: input.seriesColors[series.id],
138
- pattern: series.pattern ?? 'solid',
139
- filled: false,
140
- });
141
- }
142
- }
143
-
144
- const xTicks: Tick[] = xTickValues.map(v => ({
145
- value: v,
146
- position: x.project(v),
147
- label: input.formatInstant(v),
148
- }));
149
-
150
- const yTicks: Tick[] = yTickValues.map(v => ({
151
- value: v,
152
- position: y.project(v),
153
- label: input.formatValue(v),
154
- }));
155
-
156
- const markers = horizontalMarkers(datasource.markers, plot, y.project);
157
-
158
- return {
159
- plot,
160
- paths,
161
- markers: markers.lines,
162
- labels: markers.labels,
163
- xTicks: thinTicks(xTicks, input.fontSize, plot.width),
164
- yTicks,
165
- hitRegions,
166
- incomplete: excluded > 0,
167
- };
168
- }
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 { insetForAxes, plotBottom, plotRight } from './layout';
14
+ import { horizontalMarkers, markerDomainValues } from './markers';
15
+ import { linePath, type MaybePt } from './path';
16
+ import { domainFrom, linearScale, timeScale, widenDegenerateDomain } from './scale';
17
+ import { niceTickValues, thinTicks, type Tick } from './ticks';
18
+ import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
19
+
20
+ export interface TimeSeriesOptions {
21
+ /** Window supplied by the app. Users cannot draw one (pan/zoom out of scope). */
22
+ readonly range?: { readonly from: number; readonly to: number };
23
+ }
24
+
25
+ function visiblePoints<M>(series: Series<M>, range?: TimeSeriesOptions['range']) {
26
+ const points = [...series.points]
27
+ .filter(p => p.position.kind === 'instant')
28
+ // Out-of-order input must still produce a correct axis.
29
+ .sort((a, b) => {
30
+ const at = a.position.kind === 'instant' ? a.position.at : 0;
31
+ const bt = b.position.kind === 'instant' ? b.position.at : 0;
32
+ return at - bt;
33
+ });
34
+
35
+ if (!range) {
36
+ return points;
37
+ }
38
+ return points.filter(p => {
39
+ const at = p.position.kind === 'instant' ? p.position.at : 0;
40
+ return at >= range.from && at <= range.to;
41
+ });
42
+ }
43
+
44
+ export function buildTimeSeriesGeometry<M>(
45
+ datasource: ChartDatasource<M>,
46
+ input: GeometryInput,
47
+ options: TimeSeriesOptions = {},
48
+ ): ResolvedGeometry {
49
+ const instants: number[] = [];
50
+ const values: number[] = [];
51
+ let excluded = 0;
52
+
53
+ for (const series of datasource.series) {
54
+ for (const point of visiblePoints(series, options.range)) {
55
+ if (point.position.kind === 'instant') {
56
+ instants.push(point.position.at);
57
+ }
58
+ if (isKnown(point.value)) {
59
+ values.push(point.value.value);
60
+ } else {
61
+ excluded += 1;
62
+ }
63
+ }
64
+ }
65
+
66
+ if (instants.length === 0) {
67
+ return {
68
+ plot: input.plot,
69
+ paths: [],
70
+ markers: [],
71
+ labels: [],
72
+ xTicks: [],
73
+ yTicks: [],
74
+ hitRegions: [],
75
+ incomplete: excluded > 0,
76
+ };
77
+ }
78
+
79
+ // Tick labels depend only on the domains, so the plot can make room for
80
+ // them before anything is projected.
81
+ // Widened here, as the scales would, so the ticks label the axes actually drawn.
82
+ const xDomain = widenDegenerateDomain(domainFrom(instants));
83
+ const yDomain = widenDegenerateDomain(
84
+ domainFrom([...values, ...markerDomainValues(datasource.markers)], { padFraction: 0.1 }),
85
+ );
86
+ const xTickValues = niceTickValues(xDomain, 4);
87
+ const yTickValues = niceTickValues(yDomain, 4);
88
+ const plot = insetForAxes(input.plot, {
89
+ xLabels: xTickValues.map(input.formatInstant),
90
+ yLabels: yTickValues.map(input.formatValue),
91
+ fontSize: input.fontSize,
92
+ });
93
+
94
+ const x = timeScale(xDomain, { min: plot.x, max: plotRight(plot) });
95
+ const y = linearScale(yDomain, { min: plotBottom(plot), max: plot.y });
96
+
97
+ const paths: MarkPath[] = [];
98
+ const hitRegions: HitRegion[] = [];
99
+
100
+ for (const series of datasource.series) {
101
+ if (series.hidden) {
102
+ continue;
103
+ }
104
+
105
+ const pts: MaybePt[] = [];
106
+
107
+ for (const point of visiblePoints(series, options.range)) {
108
+ const at = point.position.kind === 'instant' ? point.position.at : 0;
109
+ const px = x.project(at);
110
+
111
+ if (!isKnown(point.value)) {
112
+ // The gap. No coordinate is produced, so no segment can span it.
113
+ pts.push(null);
114
+ continue;
115
+ }
116
+
117
+ const py = y.project(point.value.value);
118
+ pts.push({ x: px, y: py });
119
+
120
+ hitRegions.push({
121
+ seriesId: series.id,
122
+ pointId: point.id,
123
+ x: px,
124
+ y: py,
125
+ // Withheld values are never selectable, so a callback can never
126
+ // carry one (FR-053).
127
+ selectable: !isWithheld(point.value),
128
+ });
129
+ }
130
+
131
+ const d = linePath(pts);
132
+ if (d.length > 0) {
133
+ paths.push({
134
+ key: `line-${series.id}`,
135
+ seriesId: series.id,
136
+ d,
137
+ color: input.seriesColors[series.id],
138
+ pattern: series.pattern ?? 'solid',
139
+ filled: false,
140
+ });
141
+ }
142
+ }
143
+
144
+ const xTicks: Tick[] = xTickValues.map(v => ({
145
+ value: v,
146
+ position: x.project(v),
147
+ label: input.formatInstant(v),
148
+ }));
149
+
150
+ const yTicks: Tick[] = yTickValues.map(v => ({
151
+ value: v,
152
+ position: y.project(v),
153
+ label: input.formatValue(v),
154
+ }));
155
+
156
+ const markers = horizontalMarkers(datasource.markers, plot, y.project);
157
+
158
+ return {
159
+ plot,
160
+ paths,
161
+ markers: markers.lines,
162
+ labels: markers.labels,
163
+ xTicks: thinTicks(xTicks, input.fontSize, plot.width),
164
+ yTicks,
165
+ hitRegions,
166
+ incomplete: excluded > 0,
167
+ };
168
+ }
@@ -1,94 +1,94 @@
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
- /**
82
- * Distribution segment colours keyed by `segmentKey(seriesId, pointId)`.
83
- * Only resolved for the distribution family. Point colours used to share
84
- * `seriesColors`, so a point id equal to a series id overwrote that series.
85
- */
86
- readonly segmentColors?: Readonly<Record<string, string>>;
87
- readonly nonValueTextures: Readonly<
88
- Record<'unknown' | 'notApplicable' | 'withheld', NonValueTexture>
89
- >;
90
- readonly heatmapRamp: readonly string[];
91
- readonly formatValue: (value: number) => string;
92
- readonly formatCategory: (raw: string) => string;
93
- readonly formatInstant: (at: number) => string;
94
- }
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
+ /**
82
+ * Distribution segment colours keyed by `segmentKey(seriesId, pointId)`.
83
+ * Only resolved for the distribution family. Point colours used to share
84
+ * `seriesColors`, so a point id equal to a series id overwrote that series.
85
+ */
86
+ readonly segmentColors?: Readonly<Record<string, string>>;
87
+ readonly nonValueTextures: Readonly<
88
+ Record<'unknown' | 'notApplicable' | 'withheld', NonValueTexture>
89
+ >;
90
+ readonly heatmapRamp: readonly string[];
91
+ readonly formatValue: (value: number) => string;
92
+ readonly formatCategory: (raw: string) => string;
93
+ readonly formatInstant: (at: number) => string;
94
+ }