@chipmobilesdk/rn-chart 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to `@chipmobilesdk/rn-chart` are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
  this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-11-01
9
+
10
+ Three drawing defects on the production path. **No code change is required.**
11
+
12
+ ### Fixed
13
+
14
+ - **Axis labels are no longer clipped.** The frame handed every builder the
15
+ full container, so Y labels (right-aligned left of the plot) were drawn at
16
+ negative x and X labels fell below the bottom of the SVG: on a device a bar
17
+ or time-series chart showed no value scale and cut-off dates. Builders now
18
+ reserve room for their own labels before projecting anything, in one pass.
19
+ - **Distribution legends match their segments.** Segments were coloured by
20
+ point index while the legend listed series, so a one-series donut of four
21
+ segments had four colours and one legend entry; four one-point series all
22
+ drew in the first palette colour. Segment colours now come from one resolver
23
+ shared by the arcs and the legend, which has one entry per segment. Point
24
+ colours also had their own map: they shared the series map, so a point id
25
+ equal to a series id overwrote that series' colour, in every family.
26
+ - **Reference markers are drawn.** Every builder returned no markers, so
27
+ targets, baselines and thresholds reached the accessibility summary only.
28
+ Bar and time-series charts now draw them across the plot (with the domain
29
+ stretched to include them); progress draws its target on each bar.
30
+ - **Progress overshoot stays inside the plot.** It started at the plot's
31
+ right edge and was clipped, so 118% looked like 100%. The track is now scaled
32
+ so the target sits inside the plot and the excess is visible beyond it.
33
+
34
+ - **No phantom axis below zero on all-positive bars.** The domain was padded
35
+ past zero on both sides, so a bar chart with no negative value printed a
36
+ negative tick (`-5.5`) on top of the `0` label once labels became visible.
37
+
38
+ ### Added
39
+
40
+ - `DataPoint.color`, a per-segment colour for distribution charts.
41
+ - `LegendEntry.pointId`, set on distribution entries.
42
+ - `buildLegendEntries(..., family)`: pass `'distribution'` for segment entries.
43
+
44
+ ### Changed
45
+
46
+ - The plot area of bar and time-series charts is smaller by the space its axis
47
+ labels need, and `ResolvedGeometry.plot` reports that inner rectangle.
48
+
8
49
  ## [0.1.0] - Unreleased
9
50
 
10
51
  ### Added
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @chipmobilesdk/rn-chart
2
2
 
3
- Domain-agnostic charts for ChipMobileSdk-family React Native apps.
4
-
5
- > **License:** `UNLICENSED`. Published publicly for use by the owner's
6
- > applications; no open-source license is granted.
7
-
8
- - **Version**: `0.1.0`
9
- - **Entrypoint**: `@chipmobilesdk/rn-chart`
3
+ Domain-agnostic charts for ChipMobileSdk-family React Native apps.
4
+
5
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
6
+ > applications; no open-source license is granted.
7
+
8
+ - **Version**: `0.2.x` (see [CHANGELOG](CHANGELOG.md))
9
+ - **Entrypoint**: `@chipmobilesdk/rn-chart`
10
10
 
11
11
  One datasource contract spans five chart families. The package knows nothing
12
12
  about nutrition, finance, health, or productivity — you hand it aggregated,
@@ -133,6 +133,23 @@ error**, not a rendering guess.
133
133
  Time is epoch milliseconds and carries no timezone. All date wording comes from
134
134
  your formatters.
135
135
 
136
+ ### Reference markers
137
+
138
+ `datasource.markers` holds targets, baselines and thresholds. Those with a known
139
+ value are drawn as dashed lines:
140
+
141
+ - `BarChart` and `TimeSeriesChart` draw each one across the plot, with its
142
+ `label` at the right end. The value axis is stretched to include it, so a
143
+ threshold above every data point is still on the chart.
144
+ - `ProgressChart` draws the target on each row's bar. A value past its target
145
+ is drawn as overshoot beyond that line, inside the plot: the track is scaled
146
+ so the target sits at `1 / min(largest proportion, 1.25)` of its width.
147
+ - `DistributionChart` and `HeatmapChart` ignore markers.
148
+
149
+ Axis labels have room reserved for them: the plot shrinks to fit the widest
150
+ value label on the left and one line of time or category labels below, so no
151
+ label is clipped by the chart's edge.
152
+
136
153
  ## States
137
154
 
138
155
  Seven, and the package derives the two that only the data can determine:
@@ -192,20 +209,20 @@ ownership; the same callbacks fire either way.
192
209
  />
193
210
  ```
194
211
 
195
- Every payload carries the point id, series id, value, position, and whatever
196
- metadata you attached. The package never navigates, fetches, or mutates.
197
-
198
- Legends can also toggle series visibility. The state is uncontrolled unless
199
- you supply `hiddenSeriesIds`:
200
-
201
- ```tsx
202
- <BarChart
203
- datasource={ds}
204
- seriesToggleEnabled
205
- hiddenSeriesIds={hiddenSeriesIds}
206
- onVisibilityChange={event => updateHiddenSeries(event.seriesId)}
207
- />
208
- ```
212
+ Every payload carries the point id, series id, value, position, and whatever
213
+ metadata you attached. The package never navigates, fetches, or mutates.
214
+
215
+ Legends can also toggle series visibility. The state is uncontrolled unless
216
+ you supply `hiddenSeriesIds`:
217
+
218
+ ```tsx
219
+ <BarChart
220
+ datasource={ds}
221
+ seriesToggleEnabled
222
+ hiddenSeriesIds={hiddenSeriesIds}
223
+ onVisibilityChange={event => updateHiddenSeries(event.seriesId)}
224
+ />
225
+ ```
209
226
 
210
227
  Pan, pinch-zoom, and brush-to-select are out of scope. `range` on
211
228
  `TimeSeriesChart` is a window **you** supply, not one the user draws.
@@ -235,6 +252,12 @@ correctly in light and dark.
235
252
  Meaning never rides on colour alone: series carry dash patterns as well as
236
253
  colours, and non-value classes use textures.
237
254
 
255
+ `DistributionChart` colours **segments**, not series. Palette entries are taken
256
+ in order across every point, so one series of four points and four series of
257
+ one point both give four colours. A point's own `color` overrides its segment;
258
+ a series with a single point is that segment, so its series `color` applies.
259
+ The legend has one entry per segment, in the same colour as its arc.
260
+
238
261
  ## Formatting
239
262
 
240
263
  ```tsx
@@ -304,32 +327,32 @@ If you render your own narrative text near a chart — particularly text from an
304
327
  AI service or from users — moderation and content obligations are entirely
305
328
  yours. The package ships no free-text surface at all.
306
329
 
307
- ## Public API
330
+ ## Public API
308
331
 
309
332
  `src/index.ts` is the only entrypoint. Geometry, scales, layout maths, path
310
333
  builders, the chart shell, and the SVG adapter are private so they can change
311
334
  without a breaking release.
312
335
 
313
- **Stable**: value semantics, `Position`, the datasource types, the five chart
314
- components and their base props, `ChartStatus`, the error types, interaction
315
- events.
316
-
317
- The single entrypoint exports these public groups:
318
-
319
- - Components: `BarChart`, `DistributionChart`, `HeatmapChart`,
320
- `ProgressChart`, `TimeSeriesChart`, `ChartLegend`, `ChartThemeProvider`,
321
- `useChartTheme`, `buildLegendEntries`, and their props
322
- - Values and datasource: `known`, `unknown`, `notApplicable`, `withheld`,
323
- `isKnown`, `isWithheld`, `sumKnown`, position/series/datasource types,
324
- marker helpers, and format resolution helpers
325
- - State and validation: `resolveStatus`, `drawsData`, `suppressesInteraction`,
326
- `STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
327
- and `isChartError`
328
- - Interaction: selection and series interaction types plus `sameSelection`
329
- - Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
330
- formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
331
- - Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
332
- `buildTotalAnnouncement`
336
+ **Stable**: value semantics, `Position`, the datasource types, the five chart
337
+ components and their base props, `ChartStatus`, the error types, interaction
338
+ events.
339
+
340
+ The single entrypoint exports these public groups:
341
+
342
+ - Components: `BarChart`, `DistributionChart`, `HeatmapChart`,
343
+ `ProgressChart`, `TimeSeriesChart`, `ChartLegend`, `ChartThemeProvider`,
344
+ `useChartTheme`, `buildLegendEntries`, and their props
345
+ - Values and datasource: `known`, `unknown`, `notApplicable`, `withheld`,
346
+ `isKnown`, `isWithheld`, `sumKnown`, position/series/datasource types,
347
+ marker helpers, and format resolution helpers
348
+ - State and validation: `resolveStatus`, `drawsData`, `suppressesInteraction`,
349
+ `STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
350
+ and `isChartError`
351
+ - Interaction: selection and series interaction types plus `sameSelection`
352
+ - Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
353
+ formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
354
+ - Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
355
+ `buildTotalAnnouncement`
333
356
 
334
357
  **Experimental** — may change in a minor: `ChartTheme` token set, `LegendEntry`,
335
358
  family-specific props (`shape`, `grouping`, `buckets`), and the
@@ -352,7 +375,7 @@ npm run lint
352
375
  npm test
353
376
  npm run typecheck:chart
354
377
  npm run pack:chart
355
- npm run validate:chart # all of the above
356
- ```
357
-
358
- The demo integration lives in `src/screens/ChartDemoScreen.tsx`.
378
+ npm run validate:chart # all of the above
379
+ ```
380
+
381
+ The demo integration lives in `src/screens/ChartDemoScreen.tsx`.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-chart",
3
- "version": "0.1.0",
4
- "private": false,
5
- "license": "UNLICENSED",
6
- "main": "src/index.ts",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
8
  "exports": {
9
9
  ".": {
@@ -20,6 +20,7 @@
20
20
  "src/react",
21
21
  "src/render",
22
22
  "src/validation",
23
+ "skills",
23
24
  "README.md",
24
25
  "CHANGELOG.md"
25
26
  ],
@@ -41,8 +42,8 @@
41
42
  "engines": {
42
43
  "node": ">=22.11.0"
43
44
  },
44
- "publishConfig": {
45
- "access": "public",
46
- "registry": "https://registry.npmjs.org/"
47
- }
48
- }
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ }
49
+ }
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: sdk-rn-chart
3
+ description: "Charts via @chipmobilesdk/rn-chart: datasource contract, series theming, legends, selection, dataset budgets, chart accessibility. Use when rendering a chart, wiring chart data, theming series, or hitting a dataset size limit."
4
+ metadata:
5
+ package: "@chipmobilesdk/rn-chart"
6
+ packageVersion: "0.1.0"
7
+ user-invocable: true
8
+ disable-model-invocation: false
9
+ ---
10
+
11
+ # @chipmobilesdk/rn-chart
12
+
13
+ Chart rendering with an explicit datasource contract, its own theme provider,
14
+ accessibility support and enforced dataset budgets.
15
+
16
+ ## Use this when
17
+
18
+ - Rendering any chart or adding a series
19
+ - Shaping data to the datasource contract
20
+ - Theming series colors or building a legend
21
+ - Handling selection / interaction on a chart
22
+ - A chart refuses to render a large dataset
23
+
24
+ ## Check what it deliberately does not do
25
+
26
+ The README opens with `## What it does not do`. Read that first — it prevents building
27
+ a feature this package intentionally excludes.
28
+
29
+ ## Datasets are budgeted
30
+
31
+ `DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
32
+ low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
33
+ the budget. See `## Dataset budgets`.
34
+
35
+ ## Chart theming is separate from app theming
36
+
37
+ Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provider from
38
+ `sdk-rn-theme`. Series colors come from `resolveSeriesColor`, never from hardcoded values.
39
+
40
+ ## Public API (v0.2.0)
41
+
42
+ Runtime:
43
+
44
+ - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
45
+ - `ChartLegend`, `buildLegendEntries`
46
+ - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
47
+ - `DATASET_BUDGETS`, `checkBudget`
48
+ - `isChartError`
49
+
50
+ Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
51
+
52
+ Use `sameSelection` for selection equality — comparing selection objects by reference
53
+ causes redundant re-renders.
54
+
55
+ ## Where the detail lives
56
+
57
+ Read one section of `node_modules/@chipmobilesdk/rn-chart/README.md` (~360 lines):
58
+
59
+ | Need | Section |
60
+ |---|---|
61
+ | Scope check | `## What it does not do` |
62
+ | First integration | `## Install`, `## Quick start` |
63
+ | Shaping data | `## The datasource contract`, `## Value semantics` |
64
+ | Loading/empty/error rendering | `## States` |
65
+ | Touch and selection | `## Interaction` |
66
+ | Colors and legends | `## Theming` |
67
+ | Axis/label formatting | `## Formatting` |
68
+ | Screen reader support | `## Accessibility` |
69
+ | Size limits | `## Dataset budgets` |
@@ -61,6 +61,8 @@ export interface DataPoint<M = unknown> {
61
61
  readonly unit?: string;
62
62
  /** Overrides the series format hint. */
63
63
  readonly format?: FormatHint;
64
+ /** Colour of this point's segment in a distribution chart. Ignored elsewhere. */
65
+ readonly color?: string;
64
66
  /** Opaque to the package: never read, never logged, returned unchanged. */
65
67
  readonly metadata?: M;
66
68
  }
@@ -53,6 +53,8 @@ export interface ChartTheme {
53
53
 
54
54
  export interface LegendEntry {
55
55
  readonly seriesId: string;
56
+ /** Set on distribution entries, which describe one segment rather than a series. */
57
+ readonly pointId?: string;
56
58
  readonly label: string;
57
59
  readonly color: string;
58
60
  readonly pattern: 'solid' | 'dashed' | 'dotted' | 'dashDot';
@@ -137,3 +139,39 @@ export function resolveSeriesColor(
137
139
  ): string {
138
140
  return override ?? theme.palette[index % theme.palette.length];
139
141
  }
142
+
143
+ /** Key of one distribution segment: point ids are only unique within a series. */
144
+ export function segmentKey(seriesId: string, pointId: string): string {
145
+ return `${seriesId}/${pointId}`;
146
+ }
147
+
148
+ /**
149
+ * Colours for distribution segments, keyed by `segmentKey`. The single source
150
+ * for both the arcs and the legend, so the two cannot disagree (CHT-02).
151
+ *
152
+ * Segments take palette entries in order across the whole datasource, so one
153
+ * series of four points and four series of one point both give four colours.
154
+ * A point's own `color` wins; a series with a single point is that segment, so
155
+ * the series `color` applies to it.
156
+ */
157
+ export function resolveSegmentColors(
158
+ datasource: {
159
+ readonly series: readonly {
160
+ readonly id: string;
161
+ readonly color?: string;
162
+ readonly points: readonly { readonly id: string; readonly color?: string }[];
163
+ }[];
164
+ },
165
+ theme: ChartTheme,
166
+ ): Record<string, string> {
167
+ const colors: Record<string, string> = {};
168
+ let index = 0;
169
+ for (const series of datasource.series) {
170
+ for (const point of series.points) {
171
+ const override = point.color ?? (series.points.length === 1 ? series.color : undefined);
172
+ colors[segmentKey(series.id, point.id)] = resolveSeriesColor(theme, index, override);
173
+ index += 1;
174
+ }
175
+ }
176
+ return colors;
177
+ }
@@ -10,9 +10,10 @@
10
10
  import type { ChartDatasource, Position, Series } from '../contract/datasource';
11
11
  import { isKnown, isWithheld } from '../contract/value';
12
12
  import type { HitRegion } from './hitTest';
13
- import { plotBottom, plotRight } from './layout';
13
+ import { insetForAxes, plotBottom, plotRight } from './layout';
14
+ import { horizontalMarkers, markerDomainValues } from './markers';
14
15
  import { rectBatchPath, type RectSpec } from './path';
15
- import { bandScale, domainFrom, linearScale } from './scale';
16
+ import { bandScale, domainFrom, linearScale, widenDegenerateDomain } from './scale';
16
17
  import { thinTicks, type Tick } from './ticks';
17
18
  import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
18
19
 
@@ -135,7 +136,6 @@ export function buildBarGeometry<M>(
135
136
  input: GeometryInput,
136
137
  options: BarOptions = {},
137
138
  ): ResolvedGeometry {
138
- const { plot } = input;
139
139
  const grouping: BarGrouping = options.grouping ?? 'grouped';
140
140
 
141
141
  // Preserve first-seen order rather than sorting: the app chose this order.
@@ -153,16 +153,36 @@ export function buildBarGeometry<M>(
153
153
 
154
154
  const visible = datasource.series.filter(s => !s.hidden);
155
155
  const excluded = countExcluded(datasource);
156
- const values =
157
- grouping === 'stacked'
156
+ const values = [
157
+ ...(grouping === 'stacked'
158
158
  ? stackedDomainValues(categories, visible)
159
- : groupedDomainValues(datasource);
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
+ });
160
183
 
161
184
  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
- );
185
+ const y = linearScale(domain, { min: plotBottom(plot), max: plot.y });
166
186
  const zeroY = y.project(0);
167
187
 
168
188
  const paths: MarkPath[] = [];
@@ -229,25 +249,22 @@ export function buildBarGeometry<M>(
229
249
  const xTicks: Tick[] = categories.map(c => ({
230
250
  value: 0,
231
251
  position: xBand.centerOf(c) ?? plot.x,
232
- label:
233
- datasource.positionKind === 'instant'
234
- ? input.formatInstant(Number(c))
235
- : input.formatCategory(c),
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),
236
259
  }));
237
260
 
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
- }));
261
+ const markers = horizontalMarkers(datasource.markers, plot, y.project);
245
262
 
246
263
  return {
247
264
  plot,
248
265
  paths,
249
- markers: [],
250
- labels: [],
266
+ markers: markers.lines,
267
+ labels: markers.labels,
251
268
  xTicks: thinTicks(xTicks, input.fontSize, plot.width),
252
269
  yTicks,
253
270
  hitRegions,
@@ -9,6 +9,7 @@
9
9
  * PURE. No React, no SVG.
10
10
  */
11
11
  import type { ChartDatasource } from '../contract/datasource';
12
+ import { segmentKey } from '../contract/theme';
12
13
  import { isKnown, isWithheld, sumKnown } from '../contract/value';
13
14
  import type { HitRegion } from './hitTest';
14
15
  import { arcPath, rectSubpath } from './path';
@@ -83,6 +84,8 @@ export function buildDistributionGeometry<M>(
83
84
  const paths: MarkPath[] = [];
84
85
  const labels: GeometryLabel[] = [];
85
86
  const hitRegions: HitRegion[] = [];
87
+ const colorOf = (seriesId: string, pointId: string) =>
88
+ input.segmentColors?.[segmentKey(seriesId, pointId)] ?? input.seriesColors[seriesId];
86
89
 
87
90
  if (shape === 'segmentedBar') {
88
91
  const barHeight = Math.min(plot.height, 28);
@@ -100,7 +103,7 @@ export function buildDistributionGeometry<M>(
100
103
  key: `seg-${series.id}-${point.id}`,
101
104
  seriesId: series.id,
102
105
  d: rectSubpath({ x: cursor, y: barY, width, height: barHeight }),
103
- color: input.seriesColors[point.id] ?? input.seriesColors[series.id],
106
+ color: colorOf(series.id, point.id),
104
107
  pattern: 'solid',
105
108
  filled: true,
106
109
  });
@@ -159,7 +162,7 @@ export function buildDistributionGeometry<M>(
159
162
  startAngle: start,
160
163
  endAngle: end,
161
164
  }),
162
- color: input.seriesColors[point.id] ?? input.seriesColors[series.id],
165
+ color: colorOf(series.id, point.id),
163
166
  pattern: 'solid',
164
167
  filled: true,
165
168
  });
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * PURE. No React, no SVG.
9
9
  */
10
- import { reserveAxisSpace } from './ticks';
10
+ import { estimateTextWidth, reserveAxisSpace } from './ticks';
11
11
 
12
12
  export interface PlotRect {
13
13
  readonly x: number;
@@ -103,6 +103,58 @@ export function resolvePlotRect(options: LayoutOptions): PlotRect {
103
103
  };
104
104
  }
105
105
 
106
+ export interface AxisLabels {
107
+ readonly xLabels: readonly string[];
108
+ readonly yLabels: readonly string[];
109
+ readonly fontSize: number;
110
+ }
111
+
112
+ /**
113
+ * Shrinks a plot so its own axis labels fit inside the container.
114
+ *
115
+ * Builders call this once they know their tick labels (which depend only on
116
+ * the data domain) and before projecting anything, so the reservation costs no
117
+ * second pass. Without it the frame handed every builder the full container:
118
+ * Y labels, drawn right-aligned left of the plot, landed at negative x, and X
119
+ * labels below the baseline fell past the bottom of the SVG (CHT-01).
120
+ *
121
+ * - left: the widest Y label plus a gutter, and at least half the widest X
122
+ * label, because the first X label is centred on the plot's left edge;
123
+ * - right: half the widest X label, for the last one;
124
+ * - top: the part of the topmost Y label that rises above its tick;
125
+ * - bottom: one line of X labels plus a gutter.
126
+ *
127
+ * Each side is capped so labels can never squeeze the plot out of existence.
128
+ */
129
+ export function insetForAxes(outer: PlotRect, labels: AxisLabels): PlotRect {
130
+ const { fontSize } = labels;
131
+ let widestX = 0;
132
+ for (const label of labels.xLabels) {
133
+ widestX = Math.max(widestX, estimateTextWidth(label, fontSize));
134
+ }
135
+ const hasY = labels.yLabels.length > 0;
136
+ const hasX = labels.xLabels.length > 0;
137
+
138
+ const left = Math.min(
139
+ Math.max(hasY ? reserveAxisSpace(labels.yLabels, fontSize, 'vertical') : 0, widestX / 2),
140
+ outer.width * 0.4,
141
+ );
142
+ const right = Math.min(widestX / 2, outer.width * 0.25);
143
+ // Y labels sit on their tick at `position + fontSize / 3`, so two thirds of
144
+ // the glyph box rise above it.
145
+ const top = hasY ? Math.min(Math.ceil((fontSize * 2) / 3), outer.height * 0.2) : 0;
146
+ const bottom = hasX
147
+ ? Math.min(reserveAxisSpace(labels.xLabels, fontSize, 'horizontal'), outer.height * 0.4)
148
+ : 0;
149
+
150
+ return {
151
+ x: outer.x + left,
152
+ y: outer.y + top,
153
+ width: Math.max(0, outer.width - left - right),
154
+ height: Math.max(0, outer.height - top - bottom),
155
+ };
156
+ }
157
+
106
158
  export function plotBottom(plot: PlotRect): number {
107
159
  return plot.y + plot.height;
108
160
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reference markers on a value axis: thresholds, baselines and targets drawn
3
+ * as horizontal lines across the plot (FR-008, FR-072).
4
+ *
5
+ * Every builder used to return `markers: []`, so a threshold the app supplied
6
+ * reached the accessibility summary and nowhere else (CHT-03).
7
+ *
8
+ * PURE. No React, no SVG.
9
+ */
10
+ import type { ReferenceMarker } from '../contract/marker';
11
+ import { isKnown } from '../contract/value';
12
+ import { plotRight, type PlotRect } from './layout';
13
+ import type { GeometryLabel, MarkerLine } from './types';
14
+
15
+ type PlacedMarker = ReferenceMarker & { readonly value: { kind: 'known'; value: number } };
16
+
17
+ /** Markers that can be placed on a value axis: those with a known value. */
18
+ export function placeableMarkers(
19
+ markers: readonly ReferenceMarker[] | undefined,
20
+ ): PlacedMarker[] {
21
+ return (markers ?? []).filter((m): m is PlacedMarker => isKnown(m.value));
22
+ }
23
+
24
+ /** Values the domain must include so every marker lands inside the plot. */
25
+ export function markerDomainValues(markers: readonly ReferenceMarker[] | undefined): number[] {
26
+ return placeableMarkers(markers).map(m => m.value.value);
27
+ }
28
+
29
+ /** One line per marker across the plot, plus its label when it has one. */
30
+ export function horizontalMarkers(
31
+ markers: readonly ReferenceMarker[] | undefined,
32
+ plot: PlotRect,
33
+ project: (value: number) => number,
34
+ ): { lines: MarkerLine[]; labels: GeometryLabel[] } {
35
+ const lines: MarkerLine[] = [];
36
+ const labels: GeometryLabel[] = [];
37
+ for (const marker of placeableMarkers(markers)) {
38
+ const y = project(marker.value.value);
39
+ lines.push({
40
+ id: `marker-${marker.id}`,
41
+ d: `M${plot.x},${y}H${plotRight(plot)}`,
42
+ label: marker.label,
43
+ kind: marker.kind,
44
+ });
45
+ if (marker.label) {
46
+ labels.push({
47
+ key: `marker-label-${marker.id}`,
48
+ text: marker.label,
49
+ x: plotRight(plot),
50
+ // Above the line, unless that would leave the plot.
51
+ y: y - 4 >= plot.y + 8 ? y - 4 : y + 12,
52
+ anchor: 'end',
53
+ });
54
+ }
55
+ }
56
+ return { lines, labels };
57
+ }
@@ -12,11 +12,12 @@ import type { ChartDatasource } from '../contract/datasource';
12
12
  import { findTarget, readProgress } from '../contract/marker';
13
13
  import { isKnown, isWithheld } from '../contract/value';
14
14
  import type { HitRegion } from './hitTest';
15
- import { plotRight } from './layout';
15
+ import { plotBottom, plotRight } from './layout';
16
16
  import { rectSubpath } from './path';
17
17
  import type {
18
18
  GeometryInput,
19
19
  GeometryLabel,
20
+ MarkerLine,
20
21
  MarkPath,
21
22
  ResolvedGeometry,
22
23
  } from './types';
@@ -52,6 +53,22 @@ export function buildProgressGeometry<M>(
52
53
  };
53
54
  }
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
+
55
72
  const rowHeight = plot.height / metrics.length;
56
73
  const barHeight = Math.min(rowHeight * 0.45, 24);
57
74
  const radius = options.trackRadius ?? barHeight / 2;
@@ -59,12 +76,12 @@ export function buildProgressGeometry<M>(
59
76
  const paths: MarkPath[] = [];
60
77
  const labels: GeometryLabel[] = [];
61
78
  const hitRegions: HitRegion[] = [];
79
+ const markers: MarkerLine[] = [];
62
80
  let incomplete = false;
63
81
 
64
82
  metrics.forEach(({ series, point }, index) => {
65
83
  const rowY = plot.y + index * rowHeight + (rowHeight - barHeight) / 2;
66
- const target = findTarget(datasource.markers, point.id) ?? findTarget(datasource.markers);
67
- const reading = readProgress(point.value, target);
84
+ const reading = readings[index];
68
85
 
69
86
  // Track: the full width, always drawn, so an empty metric still reads as
70
87
  // a metric rather than as blank space.
@@ -119,7 +136,7 @@ export function buildProgressGeometry<M>(
119
136
  d: rectSubpath({
120
137
  x: plot.x,
121
138
  y: rowY,
122
- width: Math.max(0, plot.width * Math.min(fillFraction, 1)),
139
+ width: Math.max(0, unit * Math.min(fillFraction, 1)),
123
140
  height: barHeight,
124
141
  radius,
125
142
  }),
@@ -132,13 +149,12 @@ export function buildProgressGeometry<M>(
132
149
  // so exceeding a goal is visible instead of indistinguishable from
133
150
  // exactly meeting it.
134
151
  if (reading.overshoot && proportion !== undefined) {
135
- const overshootWidth =
136
- plot.width * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
152
+ const overshootWidth = unit * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
137
153
  paths.push({
138
154
  key: `overshoot-${series.id}-${point.id}`,
139
155
  seriesId: series.id,
140
156
  d: rectSubpath({
141
- x: plot.x + plot.width,
157
+ x: targetX,
142
158
  y: rowY + barHeight * 0.2,
143
159
  width: Math.max(0, overshootWidth),
144
160
  height: barHeight * 0.6,
@@ -152,6 +168,16 @@ export function buildProgressGeometry<M>(
152
168
  }
153
169
  }
154
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
+
155
181
  const valueText = isKnown(point.value)
156
182
  ? input.formatValue(point.value.value)
157
183
  : '';
@@ -194,7 +220,7 @@ export function buildProgressGeometry<M>(
194
220
  return {
195
221
  plot,
196
222
  paths,
197
- markers: [],
223
+ markers,
198
224
  labels,
199
225
  xTicks: [],
200
226
  yTicks: [],
@@ -22,7 +22,7 @@ export interface LinearScale {
22
22
  * usable axis. Without this the projection divides by zero and every point
23
23
  * lands on the same pixel — or on NaN.
24
24
  */
25
- function widenDegenerateDomain(domain: Range): Range {
25
+ export function widenDegenerateDomain(domain: Range): Range {
26
26
  if (domain.max > domain.min) {
27
27
  return domain;
28
28
  }
@@ -10,9 +10,10 @@
10
10
  import type { ChartDatasource, Series } from '../contract/datasource';
11
11
  import { isKnown, isWithheld } from '../contract/value';
12
12
  import type { HitRegion } from './hitTest';
13
- import { plotBottom, plotRight } from './layout';
13
+ import { insetForAxes, plotBottom, plotRight } from './layout';
14
+ import { horizontalMarkers, markerDomainValues } from './markers';
14
15
  import { linePath, type MaybePt } from './path';
15
- import { domainFrom, linearScale, timeScale } from './scale';
16
+ import { domainFrom, linearScale, timeScale, widenDegenerateDomain } from './scale';
16
17
  import { niceTickValues, thinTicks, type Tick } from './ticks';
17
18
  import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
18
19
 
@@ -45,8 +46,6 @@ export function buildTimeSeriesGeometry<M>(
45
46
  input: GeometryInput,
46
47
  options: TimeSeriesOptions = {},
47
48
  ): ResolvedGeometry {
48
- const { plot } = input;
49
-
50
49
  const instants: number[] = [];
51
50
  const values: number[] = [];
52
51
  let excluded = 0;
@@ -66,7 +65,7 @@ export function buildTimeSeriesGeometry<M>(
66
65
 
67
66
  if (instants.length === 0) {
68
67
  return {
69
- plot,
68
+ plot: input.plot,
70
69
  paths: [],
71
70
  markers: [],
72
71
  labels: [],
@@ -77,15 +76,24 @@ export function buildTimeSeriesGeometry<M>(
77
76
  };
78
77
  }
79
78
 
80
- const x = timeScale(domainFrom(instants), {
81
- min: plot.x,
82
- max: plotRight(plot),
83
- });
84
- const y = linearScale(domainFrom(values, { padFraction: 0.1 }), {
85
- min: plotBottom(plot),
86
- max: plot.y,
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,
87
92
  });
88
93
 
94
+ const x = timeScale(xDomain, { min: plot.x, max: plotRight(plot) });
95
+ const y = linearScale(yDomain, { min: plotBottom(plot), max: plot.y });
96
+
89
97
  const paths: MarkPath[] = [];
90
98
  const hitRegions: HitRegion[] = [];
91
99
 
@@ -133,23 +141,25 @@ export function buildTimeSeriesGeometry<M>(
133
141
  }
134
142
  }
135
143
 
136
- const xTicks: Tick[] = niceTickValues(x.domain, 4).map(v => ({
144
+ const xTicks: Tick[] = xTickValues.map(v => ({
137
145
  value: v,
138
146
  position: x.project(v),
139
147
  label: input.formatInstant(v),
140
148
  }));
141
149
 
142
- const yTicks: Tick[] = niceTickValues(y.domain, 4).map(v => ({
150
+ const yTicks: Tick[] = yTickValues.map(v => ({
143
151
  value: v,
144
152
  position: y.project(v),
145
153
  label: input.formatValue(v),
146
154
  }));
147
155
 
156
+ const markers = horizontalMarkers(datasource.markers, plot, y.project);
157
+
148
158
  return {
149
159
  plot,
150
160
  paths,
151
- markers: [],
152
- labels: [],
161
+ markers: markers.lines,
162
+ labels: markers.labels,
153
163
  xTicks: thinTicks(xTicks, input.fontSize, plot.width),
154
164
  yTicks,
155
165
  hitRegions,
@@ -78,6 +78,12 @@ export interface GeometryInput {
78
78
  readonly fontSize: number;
79
79
  /** Resolved per series, so geometry never reaches into the theme itself. */
80
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>>;
81
87
  readonly nonValueTextures: Readonly<
82
88
  Record<'unknown' | 'notApplicable' | 'withheld', NonValueTexture>
83
89
  >;
@@ -167,6 +167,7 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
167
167
  theme,
168
168
  layout,
169
169
  geometryFormatters,
170
+ family,
170
171
  );
171
172
 
172
173
  const showData = drawsData(status);
@@ -362,9 +363,10 @@ export function ChartFrame<M>(props: ChartFrameProps<M>) {
362
363
  theme,
363
364
  visibility.hidden,
364
365
  visibility.enabled,
366
+ family,
365
367
  )
366
368
  : [],
367
- [legendEnabled, props.datasource, theme, visibility.hidden, visibility.enabled],
369
+ [legendEnabled, props.datasource, theme, visibility.hidden, visibility.enabled, family],
368
370
  );
369
371
 
370
372
  const legendPosition =
@@ -9,18 +9,52 @@
9
9
  import React from 'react';
10
10
  import { Pressable, StyleSheet, Text, View } from 'react-native';
11
11
 
12
- import type { ChartDatasource } from '../contract/datasource';
13
- import { resolveSeriesColor, type ChartTheme, type LegendEntry } from '../contract/theme';
12
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
13
+ import {
14
+ resolveSegmentColors,
15
+ resolveSeriesColor,
16
+ segmentKey,
17
+ type ChartTheme,
18
+ type LegendEntry,
19
+ } from '../contract/theme';
14
20
 
15
21
  /** Minimum tap target, per platform accessibility guidance (UX-003). */
16
22
  const MIN_TAP_TARGET = 44;
17
23
 
24
+ /**
25
+ * Legend model. A distribution chart gets one entry per segment, in the colour
26
+ * its arc is drawn in; it used to get one per series, so a one-series donut of
27
+ * four segments showed a single entry (CHT-02).
28
+ */
18
29
  export function buildLegendEntries<M>(
19
30
  datasource: ChartDatasource<M>,
20
31
  theme: ChartTheme,
21
32
  hidden: ReadonlySet<string>,
22
33
  toggleable: boolean,
34
+ family?: ChartFamily,
23
35
  ): LegendEntry[] {
36
+ if (family === 'distribution') {
37
+ const colors = resolveSegmentColors(datasource, theme);
38
+ return datasource.series.flatMap(series =>
39
+ series.points.map(point => {
40
+ const single = series.points.length === 1;
41
+ let label = series.label;
42
+ if (!single) {
43
+ label = point.position.kind === 'category' ? point.position.category : point.id;
44
+ }
45
+ return {
46
+ seriesId: series.id,
47
+ pointId: point.id,
48
+ label,
49
+ color: colors[segmentKey(series.id, point.id)],
50
+ pattern: 'solid' as const,
51
+ visible: !hidden.has(series.id),
52
+ // Visibility is per series, so only a one-segment series toggles.
53
+ toggleable: toggleable && single,
54
+ };
55
+ }),
56
+ );
57
+ }
24
58
  return datasource.series.map((series, index) => ({
25
59
  seriesId: series.id,
26
60
  label: series.label,
@@ -31,6 +65,10 @@ export function buildLegendEntries<M>(
31
65
  }));
32
66
  }
33
67
 
68
+ function keyOf(entry: LegendEntry): string {
69
+ return entry.pointId === undefined ? entry.seriesId : segmentKey(entry.seriesId, entry.pointId);
70
+ }
71
+
34
72
  function segmentCountFor(pattern: LegendEntry['pattern']): number {
35
73
  switch (pattern) {
36
74
  case 'dotted':
@@ -113,7 +151,7 @@ export function ChartLegend({
113
151
 
114
152
  if (!entry.toggleable || !onToggle) {
115
153
  return (
116
- <View key={entry.seriesId} style={styles.entry} accessibilityRole="text">
154
+ <View key={keyOf(entry)} style={styles.entry} accessibilityRole="text">
117
155
  {content}
118
156
  </View>
119
157
  );
@@ -121,7 +159,7 @@ export function ChartLegend({
121
159
 
122
160
  return (
123
161
  <Pressable
124
- key={entry.seriesId}
162
+ key={keyOf(entry)}
125
163
  onPress={() => onToggle(entry.seriesId)}
126
164
  accessibilityRole="switch"
127
165
  accessibilityState={{ checked: entry.visible }}
@@ -13,8 +13,8 @@
13
13
  import { useCallback, useMemo, useState } from 'react';
14
14
  import { PixelRatio, type LayoutChangeEvent } from 'react-native';
15
15
 
16
- import type { ChartDatasource } from '../contract/datasource';
17
- import { resolveSeriesColor, type ChartTheme } from '../contract/theme';
16
+ import type { ChartDatasource, ChartFamily } from '../contract/datasource';
17
+ import { resolveSegmentColors, resolveSeriesColor, type ChartTheme } from '../contract/theme';
18
18
  import {
19
19
  EMPTY_PLOT,
20
20
  isMeasurable,
@@ -98,27 +98,27 @@ export function useGeometryInput<M>(
98
98
  readonly formatCategory: (raw: string) => string;
99
99
  readonly formatInstant: (at: number) => string;
100
100
  },
101
+ family?: ChartFamily,
101
102
  ): GeometryInput {
102
103
  return useMemo(() => {
103
104
  const seriesColors: Record<string, string> = {};
104
105
  datasource.series.forEach((series, index) => {
105
106
  seriesColors[series.id] = resolveSeriesColor(theme, index, series.color);
106
- // Distribution segments are coloured per point rather than per series,
107
- // so give each point a palette entry too.
108
- series.points.forEach((point, pointIndex) => {
109
- seriesColors[point.id] = resolveSeriesColor(theme, pointIndex);
110
- });
111
107
  });
112
108
 
113
109
  return {
114
110
  plot: layout.plot,
115
111
  fontSize: layout.fontSize,
116
112
  seriesColors,
113
+ // Per point only where segments exist; resolving one per point for
114
+ // every family cost a colour per data point at the time-series budget.
115
+ segmentColors:
116
+ family === 'distribution' ? resolveSegmentColors(datasource, theme) : undefined,
117
117
  nonValueTextures: theme.nonValue,
118
118
  heatmapRamp: theme.heatmapRamp,
119
119
  formatValue: formatters.formatValue,
120
120
  formatCategory: formatters.formatCategory,
121
121
  formatInstant: formatters.formatInstant,
122
122
  };
123
- }, [datasource, theme, layout.plot, layout.fontSize, formatters]);
123
+ }, [datasource, theme, layout.plot, layout.fontSize, formatters, family]);
124
124
  }