@chipmobilesdk/rn-chart 0.2.0 → 0.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +164 -115
  2. package/README.md +391 -381
  3. package/package.json +49 -49
  4. package/skills/sdk-rn-chart/SKILL.md +68 -69
  5. package/src/a11y/summary.ts +190 -177
  6. package/src/contract/datasource.ts +126 -136
  7. package/src/contract/errors.ts +129 -133
  8. package/src/contract/format.ts +168 -168
  9. package/src/contract/interaction.ts +70 -71
  10. package/src/contract/marker.ts +75 -75
  11. package/src/contract/props.ts +64 -60
  12. package/src/contract/state.ts +88 -94
  13. package/src/contract/strings.ts +48 -0
  14. package/src/contract/theme.ts +169 -177
  15. package/src/contract/value.ts +121 -125
  16. package/src/geometry/bar.ts +271 -273
  17. package/src/geometry/distribution.ts +197 -203
  18. package/src/geometry/heatmap.ts +189 -195
  19. package/src/geometry/hitTest.ts +117 -120
  20. package/src/geometry/layout.ts +162 -173
  21. package/src/geometry/markers.ts +55 -57
  22. package/src/geometry/path.ts +193 -199
  23. package/src/geometry/progress.ts +223 -230
  24. package/src/geometry/scale.ts +140 -146
  25. package/src/geometry/ticks.ts +145 -153
  26. package/src/geometry/timeSeries.ts +168 -168
  27. package/src/geometry/types.ts +94 -94
  28. package/src/index.ts +162 -176
  29. package/src/react/ChartBoundary.tsx +96 -96
  30. package/src/react/ChartFrame.tsx +596 -622
  31. package/src/react/ChartLegend.tsx +216 -215
  32. package/src/react/ChartThemeProvider.tsx +44 -48
  33. package/src/react/charts.tsx +154 -161
  34. package/src/react/useChartLayout.ts +124 -124
  35. package/src/react/useChartSelection.ts +152 -170
  36. package/src/render/Axis.tsx +120 -122
  37. package/src/render/Marks.tsx +173 -205
  38. package/src/validation/budgets.ts +113 -110
  39. package/src/validation/validateDatasource.ts +174 -174
@@ -1,273 +1,271 @@
1
- /**
2
- * Bar geometry: one batched path per series, grouped or stacked.
3
- *
4
- * Bars measure length from zero, so the domain always includes it — otherwise
5
- * a bar's length means nothing. Negative values place the baseline correctly
6
- * rather than clamping to the plot floor.
7
- *
8
- * PURE. No React, no SVG.
9
- */
10
- import type { ChartDatasource, Position, 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 { rectBatchPath, type RectSpec } from './path';
16
- import { bandScale, domainFrom, linearScale, widenDegenerateDomain } from './scale';
17
- import { thinTicks, type Tick } from './ticks';
18
- import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
19
-
20
- export type BarGrouping = 'grouped' | 'stacked';
21
-
22
- export interface BarOptions {
23
- readonly grouping?: BarGrouping;
24
- readonly radius?: number;
25
- }
26
-
27
- /**
28
- * Bars accept both category and instant positions — that dual acceptance is
29
- * what makes swapping a time-series chart for a bar chart free. Both collapse
30
- * to a string key for the band scale.
31
- */
32
- function categoryKey(position: Position): string {
33
- switch (position.kind) {
34
- case 'category':
35
- return position.category;
36
- case 'instant':
37
- return String(position.at);
38
- default:
39
- return '';
40
- }
41
- }
42
-
43
- function countExcluded<M>(datasource: ChartDatasource<M>): number {
44
- let excluded = 0;
45
- for (const series of datasource.series) {
46
- for (const point of series.points) {
47
- if (!isKnown(point.value)) {
48
- excluded += 1;
49
- }
50
- }
51
- }
52
- return excluded;
53
- }
54
-
55
- function groupedDomainValues<M>(datasource: ChartDatasource<M>): number[] {
56
- const values: number[] = [];
57
- for (const series of datasource.series) {
58
- for (const point of series.points) {
59
- if (isKnown(point.value)) {
60
- values.push(point.value.value);
61
- }
62
- }
63
- }
64
- return values;
65
- }
66
-
67
- /**
68
- * A stacked axis must fit the stack totals, not the individual values.
69
- * Positive and negative parts accumulate separately so a stack straddling zero
70
- * still fits.
71
- */
72
- function stackedDomainValues<M>(
73
- categories: readonly string[],
74
- visible: readonly Series<M>[],
75
- ): number[] {
76
- const values: number[] = [];
77
-
78
- for (const category of categories) {
79
- let positive = 0;
80
- let negative = 0;
81
-
82
- for (const series of visible) {
83
- const point = series.points.find(
84
- p => categoryKey(p.position) === category,
85
- );
86
- if (point && isKnown(point.value)) {
87
- if (point.value.value >= 0) {
88
- positive += point.value.value;
89
- } else {
90
- negative += point.value.value;
91
- }
92
- }
93
- }
94
- values.push(positive, negative);
95
- }
96
-
97
- return values;
98
- }
99
-
100
- function groupedBarX(
101
- bandStart: number,
102
- bandWidth: number,
103
- seriesIndex: number,
104
- seriesCount: number,
105
- ): { barX: number; barWidth: number } {
106
- const barWidth = bandWidth / Math.max(seriesCount, 1);
107
- return { barX: bandStart + seriesIndex * barWidth, barWidth };
108
- }
109
-
110
- /** Grows from the zero baseline, upward or downward as the sign requires. */
111
- function groupedBarY(
112
- value: number,
113
- zeroY: number,
114
- project: (v: number) => number,
115
- ): { top: number; height: number } {
116
- const valueY = project(value);
117
- return { top: Math.min(zeroY, valueY), height: Math.abs(valueY - zeroY) };
118
- }
119
-
120
- /** Grows from wherever the running stack for this sign currently ends. */
121
- function stackedBarY(
122
- value: number,
123
- category: string,
124
- stack: Map<string, number>,
125
- project: (v: number) => number,
126
- ): { top: number; height: number } {
127
- const base = stack.get(category) ?? 0;
128
- const from = project(base);
129
- const to = project(base + value);
130
- stack.set(category, base + value);
131
- return { top: Math.min(from, to), height: Math.abs(to - from) };
132
- }
133
-
134
- export function buildBarGeometry<M>(
135
- datasource: ChartDatasource<M>,
136
- input: GeometryInput,
137
- options: BarOptions = {},
138
- ): ResolvedGeometry {
139
- const grouping: BarGrouping = options.grouping ?? 'grouped';
140
-
141
- // Preserve first-seen order rather than sorting: the app chose this order.
142
- const categories: string[] = [];
143
- const seen = new Set<string>();
144
- for (const series of datasource.series) {
145
- for (const point of series.points) {
146
- const key = categoryKey(point.position);
147
- if (key && !seen.has(key)) {
148
- seen.add(key);
149
- categories.push(key);
150
- }
151
- }
152
- }
153
-
154
- const visible = datasource.series.filter(s => !s.hidden);
155
- const excluded = countExcluded(datasource);
156
- const values = [
157
- ...(grouping === 'stacked'
158
- ? stackedDomainValues(categories, visible)
159
- : groupedDomainValues(datasource)),
160
- // A threshold above every bar must still be on the chart.
161
- ...markerDomainValues(datasource.markers),
162
- ];
163
-
164
- // Labels depend only on the domain, so they are known before any pixel is,
165
- // and the plot can make room for them first.
166
- // Bars grow from zero, so padding past zero on a side no bar reaches only
167
- // adds a meaningless tick (-5.5 under all-positive data) crowding the 0.
168
- const padded = domainFrom(values, { includeZero: true, padFraction: 0.05 });
169
- const domain = widenDegenerateDomain({
170
- min: values.some(v => v < 0) ? padded.min : Math.max(padded.min, 0),
171
- max: values.some(v => v > 0) ? padded.max : Math.min(padded.max, 0),
172
- });
173
- const yTickValues = [domain.min, 0, domain.max].filter((v, i, arr) => arr.indexOf(v) === i);
174
- const xLabel = (c: string) =>
175
- datasource.positionKind === 'instant'
176
- ? input.formatInstant(Number(c))
177
- : input.formatCategory(c);
178
- const plot = insetForAxes(input.plot, {
179
- xLabels: categories.map(xLabel),
180
- yLabels: yTickValues.map(input.formatValue),
181
- fontSize: input.fontSize,
182
- });
183
-
184
- const xBand = bandScale(categories, { min: plot.x, max: plotRight(plot) }, 0.25);
185
- const y = linearScale(domain, { min: plotBottom(plot), max: plot.y });
186
- const zeroY = y.project(0);
187
-
188
- const paths: MarkPath[] = [];
189
- const hitRegions: HitRegion[] = [];
190
-
191
- // Running stack offsets, tracked separately per sign so negative bars grow
192
- // downward from zero while positive bars grow upward.
193
- const stackUp = new Map<string, number>();
194
- const stackDown = new Map<string, number>();
195
-
196
- visible.forEach((series, seriesIndex) => {
197
- const rects: RectSpec[] = [];
198
-
199
- for (const point of series.points) {
200
- const category = categoryKey(point.position);
201
- const bandStart = xBand.project(category);
202
- if (bandStart === undefined || !isKnown(point.value)) {
203
- continue;
204
- }
205
-
206
- const value = point.value.value;
207
-
208
- const { barX, barWidth } =
209
- grouping === 'grouped'
210
- ? groupedBarX(bandStart, xBand.bandWidth, seriesIndex, visible.length)
211
- : { barX: bandStart, barWidth: xBand.bandWidth };
212
-
213
- const stack = value >= 0 ? stackUp : stackDown;
214
- const { top, height } =
215
- grouping === 'stacked'
216
- ? stackedBarY(value, category, stack, y.project)
217
- : groupedBarY(value, zeroY, y.project);
218
-
219
- rects.push({
220
- x: barX,
221
- y: top,
222
- width: barWidth,
223
- height,
224
- radius: options.radius,
225
- });
226
-
227
- hitRegions.push({
228
- seriesId: series.id,
229
- pointId: point.id,
230
- x: barX + barWidth / 2,
231
- y: top + height / 2,
232
- bounds: { x: barX, y: top, width: barWidth, height },
233
- selectable: !isWithheld(point.value),
234
- });
235
- }
236
-
237
- if (rects.length > 0) {
238
- paths.push({
239
- key: `bars-${series.id}`,
240
- seriesId: series.id,
241
- d: rectBatchPath(rects),
242
- color: input.seriesColors[series.id],
243
- pattern: series.pattern ?? 'solid',
244
- filled: true,
245
- });
246
- }
247
- });
248
-
249
- const xTicks: Tick[] = categories.map(c => ({
250
- value: 0,
251
- position: xBand.centerOf(c) ?? plot.x,
252
- label: xLabel(c),
253
- }));
254
-
255
- const yTicks: Tick[] = yTickValues.map(v => ({
256
- value: v,
257
- position: y.project(v),
258
- label: input.formatValue(v),
259
- }));
260
-
261
- const markers = horizontalMarkers(datasource.markers, plot, y.project);
262
-
263
- return {
264
- plot,
265
- paths,
266
- markers: markers.lines,
267
- labels: markers.labels,
268
- xTicks: thinTicks(xTicks, input.fontSize, plot.width),
269
- yTicks,
270
- hitRegions,
271
- incomplete: excluded > 0,
272
- };
273
- }
1
+ /**
2
+ * Bar geometry: one batched path per series, grouped or stacked.
3
+ *
4
+ * Bars measure length from zero, so the domain always includes it — otherwise
5
+ * a bar's length means nothing. Negative values place the baseline correctly
6
+ * rather than clamping to the plot floor.
7
+ *
8
+ * PURE. No React, no SVG.
9
+ */
10
+ import type { ChartDatasource, Position, 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 { rectBatchPath, type RectSpec } from './path';
16
+ import { bandScale, domainFrom, linearScale, widenDegenerateDomain } from './scale';
17
+ import { thinTicks, type Tick } from './ticks';
18
+ import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
19
+
20
+ export type BarGrouping = 'grouped' | 'stacked';
21
+
22
+ export interface BarOptions {
23
+ readonly grouping?: BarGrouping;
24
+ readonly radius?: number;
25
+ }
26
+
27
+ /**
28
+ * Bars accept both category and instant positions — that dual acceptance is
29
+ * what makes swapping a time-series chart for a bar chart free. Both collapse
30
+ * to a string key for the band scale.
31
+ */
32
+ function categoryKey(position: Position): string {
33
+ switch (position.kind) {
34
+ case 'category':
35
+ return position.category;
36
+ case 'instant':
37
+ return String(position.at);
38
+ default:
39
+ return '';
40
+ }
41
+ }
42
+
43
+ function countExcluded<M>(datasource: ChartDatasource<M>): number {
44
+ let excluded = 0;
45
+ for (const series of datasource.series) {
46
+ for (const point of series.points) {
47
+ if (!isKnown(point.value)) {
48
+ excluded += 1;
49
+ }
50
+ }
51
+ }
52
+ return excluded;
53
+ }
54
+
55
+ function groupedDomainValues<M>(datasource: ChartDatasource<M>): number[] {
56
+ const values: number[] = [];
57
+ for (const series of datasource.series) {
58
+ for (const point of series.points) {
59
+ if (isKnown(point.value)) {
60
+ values.push(point.value.value);
61
+ }
62
+ }
63
+ }
64
+ return values;
65
+ }
66
+
67
+ /**
68
+ * A stacked axis must fit the stack totals, not the individual values.
69
+ * Positive and negative parts accumulate separately so a stack straddling zero
70
+ * still fits.
71
+ */
72
+ function stackedDomainValues<M>(
73
+ categories: readonly string[],
74
+ visible: readonly Series<M>[],
75
+ ): number[] {
76
+ const values: number[] = [];
77
+
78
+ for (const category of categories) {
79
+ let positive = 0;
80
+ let negative = 0;
81
+
82
+ for (const series of visible) {
83
+ const point = series.points.find(p => categoryKey(p.position) === category);
84
+ if (point && isKnown(point.value)) {
85
+ if (point.value.value >= 0) {
86
+ positive += point.value.value;
87
+ } else {
88
+ negative += point.value.value;
89
+ }
90
+ }
91
+ }
92
+ values.push(positive, negative);
93
+ }
94
+
95
+ return values;
96
+ }
97
+
98
+ function groupedBarX(
99
+ bandStart: number,
100
+ bandWidth: number,
101
+ seriesIndex: number,
102
+ seriesCount: number,
103
+ ): { barX: number; barWidth: number } {
104
+ const barWidth = bandWidth / Math.max(seriesCount, 1);
105
+ return { barX: bandStart + seriesIndex * barWidth, barWidth };
106
+ }
107
+
108
+ /** Grows from the zero baseline, upward or downward as the sign requires. */
109
+ function groupedBarY(
110
+ value: number,
111
+ zeroY: number,
112
+ project: (v: number) => number,
113
+ ): { top: number; height: number } {
114
+ const valueY = project(value);
115
+ return { top: Math.min(zeroY, valueY), height: Math.abs(valueY - zeroY) };
116
+ }
117
+
118
+ /** Grows from wherever the running stack for this sign currently ends. */
119
+ function stackedBarY(
120
+ value: number,
121
+ category: string,
122
+ stack: Map<string, number>,
123
+ project: (v: number) => number,
124
+ ): { top: number; height: number } {
125
+ const base = stack.get(category) ?? 0;
126
+ const from = project(base);
127
+ const to = project(base + value);
128
+ stack.set(category, base + value);
129
+ return { top: Math.min(from, to), height: Math.abs(to - from) };
130
+ }
131
+
132
+ export function buildBarGeometry<M>(
133
+ datasource: ChartDatasource<M>,
134
+ input: GeometryInput,
135
+ options: BarOptions = {},
136
+ ): ResolvedGeometry {
137
+ const grouping: BarGrouping = options.grouping ?? 'grouped';
138
+
139
+ // Preserve first-seen order rather than sorting: the app chose this order.
140
+ const categories: string[] = [];
141
+ const seen = new Set<string>();
142
+ for (const series of datasource.series) {
143
+ for (const point of series.points) {
144
+ const key = categoryKey(point.position);
145
+ if (key && !seen.has(key)) {
146
+ seen.add(key);
147
+ categories.push(key);
148
+ }
149
+ }
150
+ }
151
+
152
+ const visible = datasource.series.filter(s => !s.hidden);
153
+ const excluded = countExcluded(datasource);
154
+ const values = [
155
+ ...(grouping === 'stacked'
156
+ ? stackedDomainValues(categories, visible)
157
+ : groupedDomainValues(datasource)),
158
+ // A threshold above every bar must still be on the chart.
159
+ ...markerDomainValues(datasource.markers),
160
+ ];
161
+
162
+ // Labels depend only on the domain, so they are known before any pixel is,
163
+ // and the plot can make room for them first.
164
+ // Bars grow from zero, so padding past zero on a side no bar reaches only
165
+ // adds a meaningless tick (-5.5 under all-positive data) crowding the 0.
166
+ const padded = domainFrom(values, { includeZero: true, padFraction: 0.05 });
167
+ const domain = widenDegenerateDomain({
168
+ min: values.some(v => v < 0) ? padded.min : Math.max(padded.min, 0),
169
+ max: values.some(v => v > 0) ? padded.max : Math.min(padded.max, 0),
170
+ });
171
+ const yTickValues = [domain.min, 0, domain.max].filter((v, i, arr) => arr.indexOf(v) === i);
172
+ const xLabel = (c: string) =>
173
+ datasource.positionKind === 'instant'
174
+ ? input.formatInstant(Number(c))
175
+ : input.formatCategory(c);
176
+ const plot = insetForAxes(input.plot, {
177
+ xLabels: categories.map(xLabel),
178
+ yLabels: yTickValues.map(input.formatValue),
179
+ fontSize: input.fontSize,
180
+ });
181
+
182
+ const xBand = bandScale(categories, { min: plot.x, max: plotRight(plot) }, 0.25);
183
+ const y = linearScale(domain, { min: plotBottom(plot), max: plot.y });
184
+ const zeroY = y.project(0);
185
+
186
+ const paths: MarkPath[] = [];
187
+ const hitRegions: HitRegion[] = [];
188
+
189
+ // Running stack offsets, tracked separately per sign so negative bars grow
190
+ // downward from zero while positive bars grow upward.
191
+ const stackUp = new Map<string, number>();
192
+ const stackDown = new Map<string, number>();
193
+
194
+ visible.forEach((series, seriesIndex) => {
195
+ const rects: RectSpec[] = [];
196
+
197
+ for (const point of series.points) {
198
+ const category = categoryKey(point.position);
199
+ const bandStart = xBand.project(category);
200
+ if (bandStart === undefined || !isKnown(point.value)) {
201
+ continue;
202
+ }
203
+
204
+ const value = point.value.value;
205
+
206
+ const { barX, barWidth } =
207
+ grouping === 'grouped'
208
+ ? groupedBarX(bandStart, xBand.bandWidth, seriesIndex, visible.length)
209
+ : { barX: bandStart, barWidth: xBand.bandWidth };
210
+
211
+ const stack = value >= 0 ? stackUp : stackDown;
212
+ const { top, height } =
213
+ grouping === 'stacked'
214
+ ? stackedBarY(value, category, stack, y.project)
215
+ : groupedBarY(value, zeroY, y.project);
216
+
217
+ rects.push({
218
+ x: barX,
219
+ y: top,
220
+ width: barWidth,
221
+ height,
222
+ radius: options.radius,
223
+ });
224
+
225
+ hitRegions.push({
226
+ seriesId: series.id,
227
+ pointId: point.id,
228
+ x: barX + barWidth / 2,
229
+ y: top + height / 2,
230
+ bounds: { x: barX, y: top, width: barWidth, height },
231
+ selectable: !isWithheld(point.value),
232
+ });
233
+ }
234
+
235
+ if (rects.length > 0) {
236
+ paths.push({
237
+ key: `bars-${series.id}`,
238
+ seriesId: series.id,
239
+ d: rectBatchPath(rects),
240
+ color: input.seriesColors[series.id],
241
+ pattern: series.pattern ?? 'solid',
242
+ filled: true,
243
+ });
244
+ }
245
+ });
246
+
247
+ const xTicks: Tick[] = categories.map(c => ({
248
+ value: 0,
249
+ position: xBand.centerOf(c) ?? plot.x,
250
+ label: xLabel(c),
251
+ }));
252
+
253
+ const yTicks: Tick[] = yTickValues.map(v => ({
254
+ value: v,
255
+ position: y.project(v),
256
+ label: input.formatValue(v),
257
+ }));
258
+
259
+ const markers = horizontalMarkers(datasource.markers, plot, y.project);
260
+
261
+ return {
262
+ plot,
263
+ paths,
264
+ markers: markers.lines,
265
+ labels: markers.labels,
266
+ xTicks: thinTicks(xTicks, input.fontSize, plot.width),
267
+ yTicks,
268
+ hitRegions,
269
+ incomplete: excluded > 0,
270
+ };
271
+ }