@chipmobilesdk/rn-chart 0.1.1 → 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 -74
  2. package/README.md +337 -314
  3. package/package.json +46 -49
  4. package/skills/sdk-rn-chart/SKILL.md +1 -2
  5. package/src/a11y/summary.ts +170 -177
  6. package/src/contract/datasource.ts +126 -134
  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 -139
  14. package/src/contract/value.ts +121 -125
  15. package/src/geometry/bar.ts +271 -256
  16. package/src/geometry/distribution.ts +197 -200
  17. package/src/geometry/heatmap.ts +189 -195
  18. package/src/geometry/hitTest.ts +117 -120
  19. package/src/geometry/layout.ts +162 -121
  20. package/src/geometry/markers.ts +55 -0
  21. package/src/geometry/path.ts +193 -199
  22. package/src/geometry/progress.ts +223 -204
  23. package/src/geometry/scale.ts +140 -146
  24. package/src/geometry/ticks.ts +145 -153
  25. package/src/geometry/timeSeries.ts +168 -158
  26. package/src/geometry/types.ts +94 -88
  27. package/src/index.ts +158 -176
  28. package/src/react/ChartBoundary.tsx +96 -96
  29. package/src/react/ChartFrame.tsx +583 -620
  30. package/src/react/ChartLegend.tsx +211 -177
  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,256 +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 { plotBottom, plotRight } from './layout';
14
- import { rectBatchPath, type RectSpec } from './path';
15
- import { bandScale, domainFrom, linearScale } from './scale';
16
- import { thinTicks, type Tick } from './ticks';
17
- import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
18
-
19
- export type BarGrouping = 'grouped' | 'stacked';
20
-
21
- export interface BarOptions {
22
- readonly grouping?: BarGrouping;
23
- readonly radius?: number;
24
- }
25
-
26
- /**
27
- * Bars accept both category and instant positions — that dual acceptance is
28
- * what makes swapping a time-series chart for a bar chart free. Both collapse
29
- * to a string key for the band scale.
30
- */
31
- function categoryKey(position: Position): string {
32
- switch (position.kind) {
33
- case 'category':
34
- return position.category;
35
- case 'instant':
36
- return String(position.at);
37
- default:
38
- return '';
39
- }
40
- }
41
-
42
- function countExcluded<M>(datasource: ChartDatasource<M>): number {
43
- let excluded = 0;
44
- for (const series of datasource.series) {
45
- for (const point of series.points) {
46
- if (!isKnown(point.value)) {
47
- excluded += 1;
48
- }
49
- }
50
- }
51
- return excluded;
52
- }
53
-
54
- function groupedDomainValues<M>(datasource: ChartDatasource<M>): number[] {
55
- const values: number[] = [];
56
- for (const series of datasource.series) {
57
- for (const point of series.points) {
58
- if (isKnown(point.value)) {
59
- values.push(point.value.value);
60
- }
61
- }
62
- }
63
- return values;
64
- }
65
-
66
- /**
67
- * A stacked axis must fit the stack totals, not the individual values.
68
- * Positive and negative parts accumulate separately so a stack straddling zero
69
- * still fits.
70
- */
71
- function stackedDomainValues<M>(
72
- categories: readonly string[],
73
- visible: readonly Series<M>[],
74
- ): number[] {
75
- const values: number[] = [];
76
-
77
- for (const category of categories) {
78
- let positive = 0;
79
- let negative = 0;
80
-
81
- for (const series of visible) {
82
- const point = series.points.find(
83
- p => categoryKey(p.position) === category,
84
- );
85
- if (point && isKnown(point.value)) {
86
- if (point.value.value >= 0) {
87
- positive += point.value.value;
88
- } else {
89
- negative += point.value.value;
90
- }
91
- }
92
- }
93
- values.push(positive, negative);
94
- }
95
-
96
- return values;
97
- }
98
-
99
- function groupedBarX(
100
- bandStart: number,
101
- bandWidth: number,
102
- seriesIndex: number,
103
- seriesCount: number,
104
- ): { barX: number; barWidth: number } {
105
- const barWidth = bandWidth / Math.max(seriesCount, 1);
106
- return { barX: bandStart + seriesIndex * barWidth, barWidth };
107
- }
108
-
109
- /** Grows from the zero baseline, upward or downward as the sign requires. */
110
- function groupedBarY(
111
- value: number,
112
- zeroY: number,
113
- project: (v: number) => number,
114
- ): { top: number; height: number } {
115
- const valueY = project(value);
116
- return { top: Math.min(zeroY, valueY), height: Math.abs(valueY - zeroY) };
117
- }
118
-
119
- /** Grows from wherever the running stack for this sign currently ends. */
120
- function stackedBarY(
121
- value: number,
122
- category: string,
123
- stack: Map<string, number>,
124
- project: (v: number) => number,
125
- ): { top: number; height: number } {
126
- const base = stack.get(category) ?? 0;
127
- const from = project(base);
128
- const to = project(base + value);
129
- stack.set(category, base + value);
130
- return { top: Math.min(from, to), height: Math.abs(to - from) };
131
- }
132
-
133
- export function buildBarGeometry<M>(
134
- datasource: ChartDatasource<M>,
135
- input: GeometryInput,
136
- options: BarOptions = {},
137
- ): ResolvedGeometry {
138
- const { plot } = input;
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
-
161
- const xBand = bandScale(categories, { min: plot.x, max: plotRight(plot) }, 0.25);
162
- const y = linearScale(
163
- domainFrom(values, { includeZero: true, padFraction: 0.05 }),
164
- { min: plotBottom(plot), max: plot.y },
165
- );
166
- const zeroY = y.project(0);
167
-
168
- const paths: MarkPath[] = [];
169
- const hitRegions: HitRegion[] = [];
170
-
171
- // Running stack offsets, tracked separately per sign so negative bars grow
172
- // downward from zero while positive bars grow upward.
173
- const stackUp = new Map<string, number>();
174
- const stackDown = new Map<string, number>();
175
-
176
- visible.forEach((series, seriesIndex) => {
177
- const rects: RectSpec[] = [];
178
-
179
- for (const point of series.points) {
180
- const category = categoryKey(point.position);
181
- const bandStart = xBand.project(category);
182
- if (bandStart === undefined || !isKnown(point.value)) {
183
- continue;
184
- }
185
-
186
- const value = point.value.value;
187
-
188
- const { barX, barWidth } =
189
- grouping === 'grouped'
190
- ? groupedBarX(bandStart, xBand.bandWidth, seriesIndex, visible.length)
191
- : { barX: bandStart, barWidth: xBand.bandWidth };
192
-
193
- const stack = value >= 0 ? stackUp : stackDown;
194
- const { top, height } =
195
- grouping === 'stacked'
196
- ? stackedBarY(value, category, stack, y.project)
197
- : groupedBarY(value, zeroY, y.project);
198
-
199
- rects.push({
200
- x: barX,
201
- y: top,
202
- width: barWidth,
203
- height,
204
- radius: options.radius,
205
- });
206
-
207
- hitRegions.push({
208
- seriesId: series.id,
209
- pointId: point.id,
210
- x: barX + barWidth / 2,
211
- y: top + height / 2,
212
- bounds: { x: barX, y: top, width: barWidth, height },
213
- selectable: !isWithheld(point.value),
214
- });
215
- }
216
-
217
- if (rects.length > 0) {
218
- paths.push({
219
- key: `bars-${series.id}`,
220
- seriesId: series.id,
221
- d: rectBatchPath(rects),
222
- color: input.seriesColors[series.id],
223
- pattern: series.pattern ?? 'solid',
224
- filled: true,
225
- });
226
- }
227
- });
228
-
229
- const xTicks: Tick[] = categories.map(c => ({
230
- value: 0,
231
- position: xBand.centerOf(c) ?? plot.x,
232
- label:
233
- datasource.positionKind === 'instant'
234
- ? input.formatInstant(Number(c))
235
- : input.formatCategory(c),
236
- }));
237
-
238
- const yTicks: Tick[] = [y.domain.min, 0, y.domain.max]
239
- .filter((v, i, arr) => arr.indexOf(v) === i)
240
- .map(v => ({
241
- value: v,
242
- position: y.project(v),
243
- label: input.formatValue(v),
244
- }));
245
-
246
- return {
247
- plot,
248
- paths,
249
- markers: [],
250
- labels: [],
251
- xTicks: thinTicks(xTicks, input.fontSize, plot.width),
252
- yTicks,
253
- hitRegions,
254
- incomplete: excluded > 0,
255
- };
256
- }
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
+ }