@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,230 +1,223 @@
1
- /**
2
- * Progress geometry: current value against an optional target.
3
- *
4
- * The rule that matters (FR-021, FR-022): with no usable target there is **no
5
- * proportion**. The chart shows the current value and nothing else. And a value
6
- * beyond its target renders as overshoot rather than a clamped full bar that
7
- * hides the excess.
8
- *
9
- * PURE. No React, no SVG.
10
- */
11
- import type { ChartDatasource } from '../contract/datasource';
12
- import { findTarget, readProgress } from '../contract/marker';
13
- import { isKnown, isWithheld } from '../contract/value';
14
- import type { HitRegion } from './hitTest';
15
- import { plotBottom, plotRight } from './layout';
16
- import { rectSubpath } from './path';
17
- import type {
18
- GeometryInput,
19
- GeometryLabel,
20
- MarkerLine,
21
- MarkPath,
22
- ResolvedGeometry,
23
- } from './types';
24
-
25
- export interface ProgressOptions {
26
- readonly orientation?: 'horizontal' | 'vertical';
27
- readonly trackRadius?: number;
28
- }
29
-
30
- /** How far past the target an overshoot bar is allowed to extend visually. */
31
- const OVERSHOOT_DISPLAY_CAP = 1.25;
32
-
33
- export function buildProgressGeometry<M>(
34
- datasource: ChartDatasource<M>,
35
- input: GeometryInput,
36
- options: ProgressOptions = {},
37
- ): ResolvedGeometry {
38
- const { plot } = input;
39
- const metrics = datasource.series.flatMap(series =>
40
- series.points.map(point => ({ series, point })),
41
- );
42
-
43
- if (metrics.length === 0 || plot.width <= 0) {
44
- return {
45
- plot,
46
- paths: [],
47
- markers: [],
48
- labels: [],
49
- xTicks: [],
50
- yTicks: [],
51
- hitRegions: [],
52
- incomplete: false,
53
- };
54
- }
55
-
56
- const readings = metrics.map(({ point }) =>
57
- readProgress(point.value, findTarget(datasource.markers, point.id) ?? findTarget(datasource.markers)),
58
- );
59
-
60
- // The track spans 0..displayMax of the target, shared by every row so their
61
- // targets line up. With an overshoot the target sits inside the plot and the
62
- // excess is drawn to its right — previously the target was the plot's right
63
- // edge, so overshoot started there and was clipped by the SVG (CHT-03).
64
- const displayMax = readings.reduce(
65
- (max, r) =>
66
- r.proportion === undefined ? max : Math.max(max, Math.min(r.proportion, OVERSHOOT_DISPLAY_CAP)),
67
- 1,
68
- );
69
- const unit = plot.width / displayMax;
70
- const targetX = plot.x + unit;
71
-
72
- const rowHeight = plot.height / metrics.length;
73
- const barHeight = Math.min(rowHeight * 0.45, 24);
74
- const radius = options.trackRadius ?? barHeight / 2;
75
-
76
- const paths: MarkPath[] = [];
77
- const labels: GeometryLabel[] = [];
78
- const hitRegions: HitRegion[] = [];
79
- const markers: MarkerLine[] = [];
80
- let incomplete = false;
81
-
82
- metrics.forEach(({ series, point }, index) => {
83
- const rowY = plot.y + index * rowHeight + (rowHeight - barHeight) / 2;
84
- const reading = readings[index];
85
-
86
- // Track: the full width, always drawn, so an empty metric still reads as
87
- // a metric rather than as blank space.
88
- paths.push({
89
- key: `track-${series.id}-${point.id}`,
90
- seriesId: series.id,
91
- d: rectSubpath({
92
- x: plot.x,
93
- y: rowY,
94
- width: plot.width,
95
- height: barHeight,
96
- radius,
97
- }),
98
- color: input.seriesColors[series.id],
99
- pattern: 'solid',
100
- filled: true,
101
- opacity: 0.15,
102
- });
103
-
104
- if (!isKnown(point.value)) {
105
- incomplete = true;
106
- // No fill at all, and a texture rather than a zero-length bar — a gap
107
- // must not look like "none of the target achieved".
108
- paths.push({
109
- key: `nonvalue-${series.id}-${point.id}`,
110
- seriesId: series.id,
111
- d: rectSubpath({
112
- x: plot.x,
113
- y: rowY,
114
- width: plot.width,
115
- height: barHeight,
116
- radius,
117
- }),
118
- color: input.seriesColors[series.id],
119
- pattern: 'solid',
120
- filled: true,
121
- texture: input.nonValueTextures[point.value.kind],
122
- opacity: 0.5,
123
- });
124
- } else {
125
- const proportion = reading.proportion;
126
- // With no usable target there is no proportion to draw, so the fill
127
- // shows the value's presence rather than a fabricated percentage.
128
- const fillFraction =
129
- proportion === undefined
130
- ? 1
131
- : Math.min(proportion, OVERSHOOT_DISPLAY_CAP);
132
-
133
- paths.push({
134
- key: `fill-${series.id}-${point.id}`,
135
- seriesId: series.id,
136
- d: rectSubpath({
137
- x: plot.x,
138
- y: rowY,
139
- width: Math.max(0, unit * Math.min(fillFraction, 1)),
140
- height: barHeight,
141
- radius,
142
- }),
143
- color: input.seriesColors[series.id],
144
- pattern: 'solid',
145
- filled: true,
146
- });
147
-
148
- // Overshoot is drawn beyond the target line rather than clamped away,
149
- // so exceeding a goal is visible instead of indistinguishable from
150
- // exactly meeting it.
151
- if (reading.overshoot && proportion !== undefined) {
152
- const overshootWidth = unit * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
153
- paths.push({
154
- key: `overshoot-${series.id}-${point.id}`,
155
- seriesId: series.id,
156
- d: rectSubpath({
157
- x: targetX,
158
- y: rowY + barHeight * 0.2,
159
- width: Math.max(0, overshootWidth),
160
- height: barHeight * 0.6,
161
- radius: radius / 2,
162
- }),
163
- color: input.seriesColors[series.id],
164
- pattern: 'dashed',
165
- filled: true,
166
- opacity: 0.7,
167
- });
168
- }
169
- }
170
-
171
- // The target itself, across this row's bar (FR-022, FR-072).
172
- if (reading.proportion !== undefined && reading.target) {
173
- markers.push({
174
- id: `target-${series.id}-${point.id}`,
175
- d: `M${targetX},${Math.max(plot.y, rowY - 3)}V${Math.min(plotBottom(plot), rowY + barHeight + 3)}`,
176
- label: reading.target.label,
177
- kind: 'target',
178
- });
179
- }
180
-
181
- const valueText = isKnown(point.value)
182
- ? input.formatValue(point.value.value)
183
- : '';
184
- // Only append a percentage when one is real.
185
- const proportionText =
186
- reading.proportion === undefined
187
- ? ''
188
- : ` (${Math.round(reading.proportion * 100)}%)`;
189
-
190
- labels.push({
191
- key: `label-${series.id}-${point.id}`,
192
- text: input.formatCategory(
193
- point.position.kind === 'category' ? point.position.category : series.label,
194
- ),
195
- x: plot.x,
196
- y: rowY - 4,
197
- anchor: 'start',
198
- });
199
-
200
- if (valueText) {
201
- labels.push({
202
- key: `value-${series.id}-${point.id}`,
203
- text: `${valueText}${proportionText}`,
204
- x: plotRight(plot),
205
- y: rowY - 4,
206
- anchor: 'end',
207
- });
208
- }
209
-
210
- hitRegions.push({
211
- seriesId: series.id,
212
- pointId: point.id,
213
- x: plot.x + plot.width / 2,
214
- y: rowY + barHeight / 2,
215
- bounds: { x: plot.x, y: rowY, width: plot.width, height: barHeight },
216
- selectable: !isWithheld(point.value),
217
- });
218
- });
219
-
220
- return {
221
- plot,
222
- paths,
223
- markers,
224
- labels,
225
- xTicks: [],
226
- yTicks: [],
227
- hitRegions,
228
- incomplete,
229
- };
230
- }
1
+ /**
2
+ * Progress geometry: current value against an optional target.
3
+ *
4
+ * The rule that matters (FR-021, FR-022): with no usable target there is **no
5
+ * proportion**. The chart shows the current value and nothing else. And a value
6
+ * beyond its target renders as overshoot rather than a clamped full bar that
7
+ * hides the excess.
8
+ *
9
+ * PURE. No React, no SVG.
10
+ */
11
+ import type { ChartDatasource } from '../contract/datasource';
12
+ import { findTarget, readProgress } from '../contract/marker';
13
+ import { isKnown, isWithheld } from '../contract/value';
14
+ import type { HitRegion } from './hitTest';
15
+ import { plotBottom, plotRight } from './layout';
16
+ import { rectSubpath } from './path';
17
+ import type { GeometryInput, GeometryLabel, MarkerLine, MarkPath, ResolvedGeometry } from './types';
18
+
19
+ export interface ProgressOptions {
20
+ readonly orientation?: 'horizontal' | 'vertical';
21
+ readonly trackRadius?: number;
22
+ }
23
+
24
+ /** How far past the target an overshoot bar is allowed to extend visually. */
25
+ const OVERSHOOT_DISPLAY_CAP = 1.25;
26
+
27
+ export function buildProgressGeometry<M>(
28
+ datasource: ChartDatasource<M>,
29
+ input: GeometryInput,
30
+ options: ProgressOptions = {},
31
+ ): ResolvedGeometry {
32
+ const { plot } = input;
33
+ const metrics = datasource.series.flatMap(series =>
34
+ series.points.map(point => ({ series, point })),
35
+ );
36
+
37
+ if (metrics.length === 0 || plot.width <= 0) {
38
+ return {
39
+ plot,
40
+ paths: [],
41
+ markers: [],
42
+ labels: [],
43
+ xTicks: [],
44
+ yTicks: [],
45
+ hitRegions: [],
46
+ incomplete: false,
47
+ };
48
+ }
49
+
50
+ const readings = metrics.map(({ point }) =>
51
+ readProgress(
52
+ point.value,
53
+ findTarget(datasource.markers, point.id) ?? findTarget(datasource.markers),
54
+ ),
55
+ );
56
+
57
+ // The track spans 0..displayMax of the target, shared by every row so their
58
+ // targets line up. With an overshoot the target sits inside the plot and the
59
+ // excess is drawn to its right — previously the target was the plot's right
60
+ // edge, so overshoot started there and was clipped by the SVG (CHT-03).
61
+ const displayMax = readings.reduce(
62
+ (max, r) =>
63
+ r.proportion === undefined
64
+ ? max
65
+ : Math.max(max, Math.min(r.proportion, OVERSHOOT_DISPLAY_CAP)),
66
+ 1,
67
+ );
68
+ const unit = plot.width / displayMax;
69
+ const targetX = plot.x + unit;
70
+
71
+ const rowHeight = plot.height / metrics.length;
72
+ const barHeight = Math.min(rowHeight * 0.45, 24);
73
+ const radius = options.trackRadius ?? barHeight / 2;
74
+
75
+ const paths: MarkPath[] = [];
76
+ const labels: GeometryLabel[] = [];
77
+ const hitRegions: HitRegion[] = [];
78
+ const markers: MarkerLine[] = [];
79
+ let incomplete = false;
80
+
81
+ metrics.forEach(({ series, point }, index) => {
82
+ const rowY = plot.y + index * rowHeight + (rowHeight - barHeight) / 2;
83
+ const reading = readings[index];
84
+
85
+ // Track: the full width, always drawn, so an empty metric still reads as
86
+ // a metric rather than as blank space.
87
+ paths.push({
88
+ key: `track-${series.id}-${point.id}`,
89
+ seriesId: series.id,
90
+ d: rectSubpath({
91
+ x: plot.x,
92
+ y: rowY,
93
+ width: plot.width,
94
+ height: barHeight,
95
+ radius,
96
+ }),
97
+ color: input.seriesColors[series.id],
98
+ pattern: 'solid',
99
+ filled: true,
100
+ opacity: 0.15,
101
+ });
102
+
103
+ if (!isKnown(point.value)) {
104
+ incomplete = true;
105
+ // No fill at all, and a texture rather than a zero-length bar — a gap
106
+ // must not look like "none of the target achieved".
107
+ paths.push({
108
+ key: `nonvalue-${series.id}-${point.id}`,
109
+ seriesId: series.id,
110
+ d: rectSubpath({
111
+ x: plot.x,
112
+ y: rowY,
113
+ width: plot.width,
114
+ height: barHeight,
115
+ radius,
116
+ }),
117
+ color: input.seriesColors[series.id],
118
+ pattern: 'solid',
119
+ filled: true,
120
+ texture: input.nonValueTextures[point.value.kind],
121
+ opacity: 0.5,
122
+ });
123
+ } else {
124
+ const proportion = reading.proportion;
125
+ // With no usable target there is no proportion to draw, so the fill
126
+ // shows the value's presence rather than a fabricated percentage.
127
+ const fillFraction =
128
+ proportion === undefined ? 1 : Math.min(proportion, OVERSHOOT_DISPLAY_CAP);
129
+
130
+ paths.push({
131
+ key: `fill-${series.id}-${point.id}`,
132
+ seriesId: series.id,
133
+ d: rectSubpath({
134
+ x: plot.x,
135
+ y: rowY,
136
+ width: Math.max(0, unit * Math.min(fillFraction, 1)),
137
+ height: barHeight,
138
+ radius,
139
+ }),
140
+ color: input.seriesColors[series.id],
141
+ pattern: 'solid',
142
+ filled: true,
143
+ });
144
+
145
+ // Overshoot is drawn beyond the target line rather than clamped away,
146
+ // so exceeding a goal is visible instead of indistinguishable from
147
+ // exactly meeting it.
148
+ if (reading.overshoot && proportion !== undefined) {
149
+ const overshootWidth = unit * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
150
+ paths.push({
151
+ key: `overshoot-${series.id}-${point.id}`,
152
+ seriesId: series.id,
153
+ d: rectSubpath({
154
+ x: targetX,
155
+ y: rowY + barHeight * 0.2,
156
+ width: Math.max(0, overshootWidth),
157
+ height: barHeight * 0.6,
158
+ radius: radius / 2,
159
+ }),
160
+ color: input.seriesColors[series.id],
161
+ pattern: 'dashed',
162
+ filled: true,
163
+ opacity: 0.7,
164
+ });
165
+ }
166
+ }
167
+
168
+ // The target itself, across this row's bar (FR-022, FR-072).
169
+ if (reading.proportion !== undefined && reading.target) {
170
+ markers.push({
171
+ id: `target-${series.id}-${point.id}`,
172
+ d: `M${targetX},${Math.max(plot.y, rowY - 3)}V${Math.min(plotBottom(plot), rowY + barHeight + 3)}`,
173
+ label: reading.target.label,
174
+ kind: 'target',
175
+ });
176
+ }
177
+
178
+ const valueText = isKnown(point.value) ? input.formatValue(point.value.value) : '';
179
+ // Only append a percentage when one is real.
180
+ const proportionText =
181
+ reading.proportion === undefined ? '' : ` (${Math.round(reading.proportion * 100)}%)`;
182
+
183
+ labels.push({
184
+ key: `label-${series.id}-${point.id}`,
185
+ text: input.formatCategory(
186
+ point.position.kind === 'category' ? point.position.category : series.label,
187
+ ),
188
+ x: plot.x,
189
+ y: rowY - 4,
190
+ anchor: 'start',
191
+ });
192
+
193
+ if (valueText) {
194
+ labels.push({
195
+ key: `value-${series.id}-${point.id}`,
196
+ text: `${valueText}${proportionText}`,
197
+ x: plotRight(plot),
198
+ y: rowY - 4,
199
+ anchor: 'end',
200
+ });
201
+ }
202
+
203
+ hitRegions.push({
204
+ seriesId: series.id,
205
+ pointId: point.id,
206
+ x: plot.x + plot.width / 2,
207
+ y: rowY + barHeight / 2,
208
+ bounds: { x: plot.x, y: rowY, width: plot.width, height: barHeight },
209
+ selectable: !isWithheld(point.value),
210
+ });
211
+ });
212
+
213
+ return {
214
+ plot,
215
+ paths,
216
+ markers,
217
+ labels,
218
+ xTicks: [],
219
+ yTicks: [],
220
+ hitRegions,
221
+ incomplete,
222
+ };
223
+ }