@chipmobilesdk/rn-chart 0.3.0 → 0.4.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
@@ -1,5 +1,23 @@
1
1
  # @chipmobilesdk/rn-chart
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Props and formatters that were declared but never read now have an effect. `formatters.legendLabel` labels legend entries, `formatters.summary` words the values a screen reader hears, and `theme.radius` rounds bars and heatmap cells. `accessibility.summaryFormatter` already worked; `ProgressChart.orientation`, which did nothing, is gone, and the `segmentPress` and `seriesSelect` event kinds, which were never emitted, are gone from the interaction types. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ Tapping the selected point again clears the selection and now reports `(null, null)` instead of the point the chart just stopped highlighting. Screen-reader stepping moves the selection rather than toggling it, so a chart with one point stays on that point. A screen reader hears a position in the app's own words through `formatters.axisLabel` rather than a raw epoch number, and the summary names the highest and lowest points by when or where they are — a single time series used to report one value and lose its lowest.
10
+
11
+ Values are checked rather than guessed at. `NaN` or `Infinity` in a known value or an instant is a `nonFiniteValue` validation error instead of a mark drawn at the origin, and a negative distribution segment is a `negativeDistributionValue` one. `DistributionChart` takes `valuesAre` to say whether its numbers are proportions or absolute amounts; inference no longer reads two counts of 1 as two full circles. A donut or pie segment is hit by the angle and radius under the finger, so the edge of a large segment selects that segment.
12
+
13
+ `HeatmapChart` draws the names of its rows and columns, and a key to its colours — the lowest and highest value with the ramp between them, as one accessible element. `ColorScaleLegend` and `ColorScale` are exported for apps that lay it out themselves. Bar charts label their value axis in round steps and put instant categories in time order, as the time-series chart does.
14
+
15
+ ### Patch Changes
16
+
17
+ - Charts keep their memoization under the way apps actually write props. `formatters` and `TimeSeriesChart`'s `range` are compared by what they carry rather than by object identity, so writing either inline no longer rebuilds every scale and path string on each parent render. The hidden-series set, the selection callbacks and the legend toggle keep one identity, the drawn geometry is memoized apart from the selection so highlighting a point no longer reconciles the whole SVG tree, and the dataset budget is measured only in development builds. Text size is read reactively, so changing the system font size re-lays-out the chart immediately. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
18
+
19
+ Internally, the chart shell is split into `useChartStatus`, `useChartFormatters`, `useChartA11y`, `ChartPlot` and `ChartStateOverlay` instead of one 600-line component; the budget warning runs in an effect rather than during render; and the bar, time-series and heatmap builders each make one pass over their points where they made several.
20
+
3
21
  ## 0.3.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -150,6 +150,11 @@ Axis labels have room reserved for them: the plot shrinks to fit the widest
150
150
  value label on the left and one line of time or category labels below, so no
151
151
  label is clipped by the chart's edge.
152
152
 
153
+ Every known value, and every instant, must be a **finite number**. `NaN` or
154
+ `Infinity` is a validation error (`nonFiniteValue`) rather than a mark drawn at
155
+ the origin: data that arrived broken is reported as broken. Use `unknown()` for
156
+ a value you do not have.
157
+
153
158
  ## States
154
159
 
155
160
  Seven, and the package derives the two that only the data can determine:
@@ -212,6 +217,12 @@ ownership; the same callbacks fire either way.
212
217
  Every payload carries the point id, series id, value, position, and whatever
213
218
  metadata you attached. The package never navigates, fetches, or mutates.
214
219
 
220
+ Tapping the selected point again clears the selection, and the callback is told
221
+ so: `(null, null)`. Empty space clears it the same way. Screen-reader stepping
222
+ moves the selection rather than toggling it, so a chart with one point stays on
223
+ that point. A donut or pie is hit by the segment the finger is in — its angle
224
+ and radius — not by distance to its label.
225
+
215
226
  Legends can also toggle series visibility. The state is uncontrolled unless
216
227
  you supply `hiddenSeriesIds`:
217
228
 
@@ -252,6 +263,18 @@ correctly in light and dark.
252
263
  Meaning never rides on colour alone: series carry dash patterns as well as
253
264
  colours, and non-value classes use textures.
254
265
 
266
+ `DistributionChart` reads its numbers either as **proportions** (each in 0..1,
267
+ summing to 1) or as **absolute** amounts it normalises by their own total. It
268
+ infers which from the data, and a short sum of proportions is drawn short and
269
+ reported incomplete rather than stretched to fill the circle. Two counts of `1`
270
+ are ambiguous, so say what you mean when counts can be small:
271
+
272
+ ```tsx
273
+ <DistributionChart datasource={ds} valuesAre="absolute" />
274
+ ```
275
+
276
+ A negative segment is a validation error: a share of a whole cannot be negative.
277
+
255
278
  `DistributionChart` colours **segments**, not series. Palette entries are taken
256
279
  in order across every point, so one series of four points and four series of
257
280
  one point both give four colours. A point's own `color` overrides its segment;
@@ -275,6 +298,18 @@ default applies — the package never infers or converts a business unit. A
275
298
  formatter that throws degrades to the default **for that one label** and does
276
299
  not crash the screen.
277
300
 
301
+ Four slots: `value` (drawn labels), `axisLabel` (axis ticks, and how a position
302
+ is spoken to a screen reader), `legendLabel` (legend entries) and `summary` (the
303
+ values inside the accessible summary; falls back to `value`).
304
+
305
+ The `formatters` object itself may be written inline — the chart depends on the
306
+ functions it carries, not on the object. The **functions** must be stable:
307
+ define them at module scope or hold them in `useCallback`/`useMemo`, as with
308
+ `datasource`. A formatter rebuilt every render rebuilds every scale and path
309
+ string, which is the one thing `FR-073` exists to prevent. The same goes for
310
+ `range` on `TimeSeriesChart`, which is compared by its `from` and `to`, so
311
+ `range={{ from, to }}` inline is free.
312
+
278
313
  ## Accessibility
279
314
 
280
315
  Each chart is a single accessible element with a composed summary: what it is,
@@ -295,6 +330,11 @@ summary entirely, and `accessibility.label` replaces everything.
295
330
  <BarChart datasource={steps} title="Bước" strings={{ 'chart.family.bar': 'biểu đồ cột' }} />
296
331
  ```
297
332
 
333
+ `HeatmapChart` names its rows and columns along the edges of the plot and shows
334
+ the key to its colours — the lowest and highest value with the ramp between them
335
+ — below it, as one accessible element. Pass `legend={false}` to leave the key
336
+ out.
337
+
298
338
  Axis labels are thinned to fit rather than overlapped, and scale with the system
299
339
  text size up to **1.6×**; beyond that the growth is capped so the plot area
300
340
  survives.
@@ -309,8 +349,9 @@ survives.
309
349
  | Progress | 8 metrics |
310
350
  | Heatmap | 1,000 cells |
311
351
 
312
- Over budget, a chart **still renders and drops nothing**. It warns once per
313
- chart in development builds, and never in production. Aggregating or
352
+ Over budget, a chart **still renders and drops nothing**: nothing is enforced
353
+ and nothing is refused. It warns once per chart in development builds, and in
354
+ production the dataset is not even measured. Aggregating or
314
355
  downsampling above these sizes is your call — the package will not silently
315
356
  decide what data to discard.
316
357
 
@@ -359,6 +400,7 @@ The single entrypoint exports these public groups:
359
400
  `STATUS_PRECEDENCE`, `DATASET_BUDGETS`, `checkBudget`, error/failure types,
360
401
  and `isChartError`
361
402
  - Interaction: selection and series interaction types plus `sameSelection`
403
+ - Heatmap colour key: `ColorScaleLegend` and `ColorScale`
362
404
  - Theme and formatting: `defaultChartTheme`, `resolveSeriesColor`, default
363
405
  formatter helpers, `NON_VALUE_TEXT`, and public theme/formatter contracts
364
406
  - Accessibility: `buildChartSummary`, `buildPointAnnouncement`, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-chart",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -18,18 +18,36 @@ accessibility support and enforced dataset budgets.
18
18
  - Shaping data to the datasource contract
19
19
  - Theming series colors or building a legend
20
20
  - Handling selection / interaction on a chart
21
- - A chart refuses to render a large dataset
21
+ - A chart is slower than expected, or a dataset is larger than its budget
22
+ - A formatter or a prop appears to have no effect
22
23
 
23
24
  ## Check what it deliberately does not do
24
25
 
25
26
  The README opens with `## What it does not do`. Read that first — it prevents building
26
27
  a feature this package intentionally excludes.
27
28
 
28
- ## Datasets are budgeted
29
+ ## Dataset budgets are a development warning, not a limit
29
30
 
30
- `DATASET_BUDGETS` and `checkBudget` enforce size limits so charts stay at 60 FPS on
31
- low-end devices. When a budget is exceeded, aggregate or window the data — do not raise
32
- the budget. See `## Dataset budgets`.
31
+ Nothing is enforced and nothing is refused: over budget a chart **still renders and
32
+ drops nothing**, warns once per chart in development, and in production does not even
33
+ measure the dataset. `DATASET_BUDGETS` and `checkBudget` are the figures and the check,
34
+ for a consumer that wants to aggregate or window its own data. Do not debug a "chart
35
+ refuses to render": no such behaviour exists. See `## Dataset budgets`.
36
+
37
+ ## Memoization has two requirements
38
+
39
+ `datasource` must be referentially stable, and so must each **formatter function**
40
+ (module scope, or `useCallback`). The `formatters` object itself may be inline, as may
41
+ `range={{ from, to }}` — those are compared by what they carry. An unstable datasource
42
+ or formatter rebuilds every scale and path string on each parent render.
43
+
44
+ ## What the numbers mean, where it is ambiguous
45
+
46
+ `DistributionChart` reads values as proportions (0..1, summing to 1) or as absolute
47
+ amounts, inferred from the data. Pass `valuesAre="absolute" | "proportions"` when counts
48
+ could be read as shares (two counts of `1`). Known values and instants must be finite:
49
+ `NaN` is a `nonFiniteValue` validation error, and a negative distribution segment is a
50
+ `negativeDistributionValue` one. Use `unknown()` for data you do not have.
33
51
 
34
52
  ## Chart theming is separate from app theming
35
53
 
@@ -38,18 +56,35 @@ Charts use `ChartThemeProvider` / `useChartTheme`, not the app-wide theme provid
38
56
 
39
57
  ## Public API
40
58
 
41
- Runtime:
59
+ Components — all five families are memoized, and all share `BaseChartProps`
60
+ (`datasource`, `status`, `stateContent`, `title`, `legend`, `theme`, `formatters`,
61
+ `strings`, `accessibility`, `height`, `style`, `footer`, selection and visibility
62
+ props):
42
63
 
43
- - `ChartThemeProvider`, `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
44
- - `ChartLegend`, `buildLegendEntries`
45
- - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
46
- - `DATASET_BUDGETS`, `checkBudget`
47
- - `isChartError`
64
+ - `BarChart` (`grouping`), `TimeSeriesChart` (`range`), `DistributionChart` (`shape`,
65
+ `innerRadiusRatio`, `valuesAre`), `ProgressChart`, `HeatmapChart` (`buckets`)
66
+ - `ChartLegend`, `ColorScaleLegend`, `buildLegendEntries`, `ChartThemeProvider`
48
67
 
49
- Types: `MarkerKind`, `ProgressReading`, `ReferenceMarker`, `BudgetCheck`, `DatasetBudget`
68
+ Runtime:
50
69
 
51
- Use `sameSelection` for selection equality — comparing selection objects by reference
52
- causes redundant re-renders.
70
+ - `known`, `unknown`, `notApplicable`, `withheld`, `isKnown`, `isWithheld`, `sumKnown`,
71
+ `participatesInTotals`
72
+ - `useChartTheme`, `defaultChartTheme`, `resolveSeriesColor`
73
+ - `findTarget`, `isUsableTarget`, `readProgress`, `sameSelection`
74
+ - `resolveStatus`, `drawsData`, `suppressesInteraction`, `STATUS_PRECEDENCE`
75
+ - `formatValueDefault`, `formatPositionDefault`, `formatLegendDefault`, `seriesRef`,
76
+ `NON_VALUE_TEXT`
77
+ - `buildChartSummary`, `buildPointAnnouncement`, `buildTotalAnnouncement`
78
+ - `DATASET_BUDGETS`, `checkBudget`, `isChartError`, `chartStrings`
79
+ - `FAMILY_POSITION_KINDS`, `familyAcceptsPositionKind`, `resolveFormat`, `resolveUnit`
80
+
81
+ Types: `ValueState`, `ChartDatasource`, `Series`, `DataPoint`, `Position`, `ChartFamily`,
82
+ `ChartStatus`, `ChartError`, `SelectionRef`, `PointInteractionEvent`, `ChartFormatters`,
83
+ `ChartTheme`, `LegendEntry`, `ColorScale`, `DistributionValues`, `ChartStrings`,
84
+ `BudgetCheck`, `DatasetBudget`
85
+
86
+ Use `sameSelection` to compare two selections; comparing the objects by reference
87
+ reports a change that did not happen.
53
88
 
54
89
  ## Where the detail lives
55
90
 
@@ -8,7 +8,8 @@
8
8
  * Withheld values never enter a summary — that would leak exactly what the
9
9
  * withheld class exists to protect (FR-033).
10
10
  */
11
- import type { ChartDatasource, ChartFamily } from '../contract/datasource';
11
+ import type { ChartDatasource, ChartFamily, Position } from '../contract/datasource';
12
+ import { formatPositionDefault } from '../contract/format';
12
13
  import type { ReferenceMarker } from '../contract/marker';
13
14
  import type { ChartStatus } from '../contract/state';
14
15
  import { formatMessage } from '@chipmobilesdk/rn-core';
@@ -44,6 +45,8 @@ export interface SummaryInput<M = unknown> {
44
45
  readonly status: ChartStatus;
45
46
  readonly title?: string;
46
47
  readonly formatValue: (value: ValueState) => string;
48
+ /** How a position is spoken; the app's axis formatter when it has one. */
49
+ readonly formatPosition?: (position: Position) => string;
47
50
  readonly incomplete?: boolean;
48
51
  /** The texts to speak; the English defaults when omitted. */
49
52
  readonly strings?: ChartStrings;
@@ -53,9 +56,12 @@ function describeExtremes<M>(
53
56
  datasource: ChartDatasource<M>,
54
57
  formatValue: (v: ValueState) => string,
55
58
  strings: ChartStrings,
59
+ formatPosition: (position: Position) => string,
56
60
  ): string {
57
- let min: { label: string; value: number } | undefined;
58
- let max: { label: string; value: number } | undefined;
61
+ type Extreme = { label: () => string; value: number; point: unknown };
62
+ let min: Extreme | undefined;
63
+ let max: Extreme | undefined;
64
+ const several = datasource.series.length > 1;
59
65
 
60
66
  for (const series of datasource.series) {
61
67
  for (const point of series.points) {
@@ -63,18 +69,25 @@ function describeExtremes<M>(
63
69
  if (!isKnown(point.value)) {
64
70
  continue;
65
71
  }
66
- const label =
67
- point.position.kind === 'category'
68
- ? point.position.category
69
- : point.position.kind === 'matrix'
70
- ? `${point.position.row} ${point.position.column}`
71
- : series.label;
72
+ // Named by where the point is. A time series used to name both
73
+ // extremes after the series, so a one-series chart reported a single
74
+ // value and lost its lowest (CHT-07). Built lazily: only two are read.
75
+ const label = (): string => {
76
+ if (point.position.kind === 'category') {
77
+ return point.position.category;
78
+ }
79
+ if (point.position.kind === 'matrix') {
80
+ return `${point.position.row} ${point.position.column}`;
81
+ }
82
+ const at = formatPosition(point.position);
83
+ return several ? `${series.label} ${at}` : at;
84
+ };
72
85
 
73
86
  if (!min || point.value.value < min.value) {
74
- min = { label, value: point.value.value };
87
+ min = { label, value: point.value.value, point };
75
88
  }
76
89
  if (!max || point.value.value > max.value) {
77
- max = { label, value: point.value.value };
90
+ max = { label, value: point.value.value, point };
78
91
  }
79
92
  }
80
93
  }
@@ -83,14 +96,15 @@ function describeExtremes<M>(
83
96
  return '';
84
97
  }
85
98
  const maxText = formatValue({ kind: 'known', value: max.value });
86
- if (min.label === max.label) {
99
+ // One figure only when the highest and the lowest are the same point.
100
+ if (min.point === max.point) {
87
101
  return formatMessage(strings['chart.summary.single'], { value: maxText });
88
102
  }
89
103
 
90
104
  return formatMessage(strings['chart.summary.extremes'], {
91
- maxLabel: max.label,
105
+ maxLabel: max.label(),
92
106
  max: maxText,
93
- minLabel: min.label,
107
+ minLabel: min.label(),
94
108
  min: formatValue({ kind: 'known', value: min.value }),
95
109
  });
96
110
  }
@@ -149,7 +163,12 @@ export function buildChartSummary<M>(input: SummaryInput<M>): string {
149
163
  heading,
150
164
  statusPhrase,
151
165
  scale,
152
- describeExtremes(input.datasource, input.formatValue, strings),
166
+ describeExtremes(
167
+ input.datasource,
168
+ input.formatValue,
169
+ strings,
170
+ input.formatPosition ?? formatPositionDefault,
171
+ ),
153
172
  describeMarkers(input.datasource.markers, input.formatValue, strings),
154
173
  input.incomplete ? strings['chart.summary.incomplete'] : '',
155
174
  ];
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What the numbers of a distribution mean (CHT-10).
3
+ *
4
+ * `proportions`: each value is already a share of the whole, in 0..1. A sum
5
+ * short of 1 is drawn short and reported incomplete. `absolute`: counts or
6
+ * amounts, normalised by their own total.
7
+ *
8
+ * Until 0.4.0 this was guessed from the values alone: anything in 0..1 was a
9
+ * proportion, so two segments counting 1 each drew two full circles on top of
10
+ * one another. The guess now also requires the shares to fit in one whole, and
11
+ * an app that knows can say so.
12
+ */
13
+ import type { ChartDatasource } from './datasource';
14
+ import { isKnown } from './value';
15
+
16
+ export type DistributionValues = 'proportions' | 'absolute';
17
+
18
+ export const DISTRIBUTION_SUM_TOLERANCE = 0.001;
19
+
20
+ export function resolveDistributionValues<M>(
21
+ datasource: ChartDatasource<M>,
22
+ declared?: DistributionValues,
23
+ ): DistributionValues {
24
+ if (declared !== undefined) {
25
+ return declared;
26
+ }
27
+ if (datasource.format?.kind === 'percent') {
28
+ return 'proportions';
29
+ }
30
+ let sum = 0;
31
+ for (const series of datasource.series) {
32
+ for (const point of series.points) {
33
+ if (!isKnown(point.value)) {
34
+ continue;
35
+ }
36
+ const value = point.value.value;
37
+ if (value < 0 || value > 1) {
38
+ return 'absolute';
39
+ }
40
+ sum += value;
41
+ }
42
+ }
43
+ return sum <= 1 + DISTRIBUTION_SUM_TOLERANCE ? 'proportions' : 'absolute';
44
+ }
@@ -18,6 +18,8 @@ export type ChartErrorCode =
18
18
  | 'familyPositionKindUnsupported'
19
19
  | 'inconsistentMatrix'
20
20
  | 'distributionNotWhole'
21
+ | 'nonFiniteValue'
22
+ | 'negativeDistributionValue'
21
23
  | 'declaredByApp'
22
24
  | 'renderException';
23
25
 
@@ -34,6 +36,10 @@ const MESSAGES: Readonly<Record<ChartErrorCode, string>> = {
34
36
  inconsistentMatrix: 'Matrix datasource rows and columns are inconsistent across points.',
35
37
  distributionNotWhole:
36
38
  'Distribution segments do not sum to the whole. The chart marks the total incomplete rather than rescaling.',
39
+ nonFiniteValue:
40
+ 'A known value or an instant is not a finite number. Use an unknown value for missing data.',
41
+ negativeDistributionValue:
42
+ 'A distribution segment has a negative value. A share of a whole cannot be negative.',
37
43
  declaredByApp: 'The application declared this chart to be in an error state.',
38
44
  renderException:
39
45
  'The chart failed while rendering and was contained. See the report for details.',
@@ -18,7 +18,7 @@ export interface SelectionRef {
18
18
  }
19
19
 
20
20
  export interface PointInteractionEvent<M = unknown> {
21
- readonly type: 'pointSelect' | 'segmentPress';
21
+ readonly type: 'pointSelect';
22
22
  readonly seriesId: string;
23
23
  readonly pointId: string;
24
24
  readonly value: ValueState;
@@ -27,7 +27,7 @@ export interface PointInteractionEvent<M = unknown> {
27
27
  }
28
28
 
29
29
  export interface SeriesInteractionEvent<M = unknown> {
30
- readonly type: 'seriesSelect' | 'visibilityChange';
30
+ readonly type: 'visibilityChange';
31
31
  readonly seriesId: string;
32
32
  readonly visible?: boolean;
33
33
  readonly metadata?: M;
@@ -41,6 +41,8 @@ export const chartStrings = defineMessages({
41
41
  'chart.state.noPermission': 'Not available',
42
42
  'chart.legend.hide': 'Hides this series',
43
43
  'chart.legend.show': 'Shows this series',
44
+ 'chart.legend.scale': 'Colour scale from {min} to {max}',
45
+ 'chart.note.incomplete': 'Some values are unavailable, so totals are incomplete.',
44
46
  },
45
47
  });
46
48
 
@@ -14,7 +14,7 @@ import { insetForAxes, plotBottom, plotRight } from './layout';
14
14
  import { horizontalMarkers, markerDomainValues } from './markers';
15
15
  import { rectBatchPath, type RectSpec } from './path';
16
16
  import { bandScale, domainFrom, linearScale, widenDegenerateDomain } from './scale';
17
- import { thinTicks, type Tick } from './ticks';
17
+ import { niceTickValues, thinTicks, type Tick } from './ticks';
18
18
  import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
19
19
 
20
20
  export type BarGrouping = 'grouped' | 'stacked';
@@ -75,21 +75,24 @@ function stackedDomainValues<M>(
75
75
  ): number[] {
76
76
  const values: number[] = [];
77
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
- }
78
+ // One pass over the points instead of a search per category and series.
79
+ const positive = new Map<string, number>();
80
+ const negative = new Map<string, number>();
81
+ for (const series of visible) {
82
+ const counted = new Set<string>();
83
+ for (const point of series.points) {
84
+ const category = categoryKey(point.position);
85
+ // The first point of a series in a category is the one drawn there.
86
+ if (counted.has(category) || !isKnown(point.value)) {
87
+ continue;
90
88
  }
89
+ counted.add(category);
90
+ const sums = point.value.value >= 0 ? positive : negative;
91
+ sums.set(category, (sums.get(category) ?? 0) + point.value.value);
91
92
  }
92
- values.push(positive, negative);
93
+ }
94
+ for (const category of categories) {
95
+ values.push(positive.get(category) ?? 0, negative.get(category) ?? 0);
93
96
  }
94
97
 
95
98
  return values;
@@ -149,6 +152,13 @@ export function buildBarGeometry<M>(
149
152
  }
150
153
  }
151
154
 
155
+ // Instants have an order of their own. First-seen order put Tuesday before
156
+ // Monday when one series started a day late, and disagreed with the
157
+ // time-series chart drawn from the same datasource (CHT-20).
158
+ if (datasource.positionKind === 'instant') {
159
+ categories.sort((a, b) => Number(a) - Number(b));
160
+ }
161
+
152
162
  const visible = datasource.series.filter(s => !s.hidden);
153
163
  const excluded = countExcluded(datasource);
154
164
  const values = [
@@ -168,7 +178,8 @@ export function buildBarGeometry<M>(
168
178
  min: values.some(v => v < 0) ? padded.min : Math.max(padded.min, 0),
169
179
  max: values.some(v => v > 0) ? padded.max : Math.min(padded.max, 0),
170
180
  });
171
- const yTickValues = [domain.min, 0, domain.max].filter((v, i, arr) => arr.indexOf(v) === i);
181
+ // Round steps a reader can count in, not the padded extremes (105.3).
182
+ const yTickValues = niceTickValues(domain, 4);
172
183
  const xLabel = (c: string) =>
173
184
  datasource.positionKind === 'instant'
174
185
  ? input.formatInstant(Number(c))
@@ -219,7 +230,7 @@ export function buildBarGeometry<M>(
219
230
  y: top,
220
231
  width: barWidth,
221
232
  height,
222
- radius: options.radius,
233
+ radius: options.radius ?? input.radius?.bar,
223
234
  });
224
235
 
225
236
  hitRegions.push({
@@ -9,6 +9,7 @@
9
9
  * PURE. No React, no SVG.
10
10
  */
11
11
  import type { ChartDatasource } from '../contract/datasource';
12
+ import { resolveDistributionValues, type DistributionValues } from '../contract/distribution';
12
13
  import { segmentKey } from '../contract/theme';
13
14
  import { isKnown, isWithheld, sumKnown } from '../contract/value';
14
15
  import type { HitRegion } from './hitTest';
@@ -21,6 +22,8 @@ export interface DistributionOptions {
21
22
  readonly shape?: DistributionShape;
22
23
  /** Donut hole as a fraction of the outer radius. */
23
24
  readonly innerRadiusRatio?: number;
25
+ /** What the values mean; inferred from the data when omitted. */
26
+ readonly valuesAre?: DistributionValues;
24
27
  }
25
28
 
26
29
  const TAU = Math.PI * 2;
@@ -69,10 +72,11 @@ export function buildDistributionGeometry<M>(
69
72
  // Proportions supplied directly (each in 0..1, summing to ~1) are honoured
70
73
  // as given; absolute values are normalised by their own total. Either way we
71
74
  // never stretch a short sum to fill the circle.
72
- const suppliedAsProportions = segments.every(
73
- s => !isKnown(s.point.value) || (s.point.value.value >= 0 && s.point.value.value <= 1),
74
- );
75
- const denominator = suppliedAsProportions ? 1 : total.total;
75
+ const suppliedAsProportions =
76
+ resolveDistributionValues(datasource, options.valuesAre) === 'proportions';
77
+ // Shares declared as proportions that add up past the whole are normalised
78
+ // rather than drawn over one another.
79
+ const denominator = suppliedAsProportions ? Math.max(1, total.total) : total.total;
76
80
  const coverage = total.total / denominator;
77
81
  const incomplete = total.incomplete || coverage < 0.999;
78
82
 
@@ -88,7 +92,7 @@ export function buildDistributionGeometry<M>(
88
92
  let cursor = plot.x;
89
93
 
90
94
  for (const { series, point } of segments) {
91
- if (!isKnown(point.value)) {
95
+ if (!isKnown(point.value) || point.value.value < 0) {
92
96
  continue;
93
97
  }
94
98
  const fraction = point.value.value / denominator;
@@ -135,7 +139,7 @@ export function buildDistributionGeometry<M>(
135
139
  let angle = 0;
136
140
 
137
141
  for (const { series, point } of segments) {
138
- if (!isKnown(point.value)) {
142
+ if (!isKnown(point.value) || point.value.value < 0) {
139
143
  continue;
140
144
  }
141
145
 
@@ -180,6 +184,7 @@ export function buildDistributionGeometry<M>(
180
184
  pointId: point.id,
181
185
  x: cx + labelRadius * Math.cos(mid),
182
186
  y: cy + labelRadius * Math.sin(mid),
187
+ arc: { cx, cy, innerRadius, outerRadius, startAngle: start, endAngle: end },
183
188
  selectable: !isWithheld(point.value),
184
189
  });
185
190
  }
@@ -15,7 +15,9 @@
15
15
  import type { ChartDatasource } from '../contract/datasource';
16
16
  import { isKnown, isWithheld } from '../contract/value';
17
17
  import type { HitRegion } from './hitTest';
18
+ import { insetForAxes } from './layout';
18
19
  import { rectBatchPath, type RectSpec } from './path';
20
+ import { thinTicks } from './ticks';
19
21
  import type { GeometryInput, MarkPath, ResolvedGeometry } from './types';
20
22
 
21
23
  export interface HeatmapOptions {
@@ -30,8 +32,6 @@ export function buildHeatmapGeometry<M>(
30
32
  input: GeometryInput,
31
33
  options: HeatmapOptions = {},
32
34
  ): ResolvedGeometry {
33
- const { plot } = input;
34
-
35
35
  const rows: string[] = [];
36
36
  const columns: string[] = [];
37
37
  const rowSeen = new Set<string>();
@@ -53,9 +53,9 @@ export function buildHeatmapGeometry<M>(
53
53
  }
54
54
  }
55
55
 
56
- if (rows.length === 0 || columns.length === 0 || plot.width <= 0) {
56
+ if (rows.length === 0 || columns.length === 0 || input.plot.width <= 0) {
57
57
  return {
58
- plot,
58
+ plot: input.plot,
59
59
  paths: [],
60
60
  markers: [],
61
61
  labels: [],
@@ -66,6 +66,18 @@ export function buildHeatmapGeometry<M>(
66
66
  };
67
67
  }
68
68
 
69
+ // Row and column names are the heatmap's axes. The plot makes room for
70
+ // them; they were computed before and never drawn (CHT-15).
71
+ const columnLabels = columns.map(input.formatCategory);
72
+ const rowLabels = rows.map(input.formatCategory);
73
+ const plot = insetForAxes(input.plot, {
74
+ xLabels: columnLabels,
75
+ yLabels: rowLabels,
76
+ fontSize: input.fontSize,
77
+ });
78
+ const columnIndex = new Map(columns.map((column, index) => [column, index]));
79
+ const rowIndex = new Map(rows.map((row, index) => [row, index]));
80
+
69
81
  const gap = options.cellGap ?? 2;
70
82
  const cellWidth = plot.width / columns.length;
71
83
  const cellHeight = plot.height / rows.length;
@@ -102,18 +114,18 @@ export function buildHeatmapGeometry<M>(
102
114
  continue;
103
115
  }
104
116
 
105
- const colIndex = columns.indexOf(point.position.column);
106
- const rowIndex = rows.indexOf(point.position.row);
107
- if (colIndex < 0 || rowIndex < 0) {
117
+ const col = columnIndex.get(point.position.column);
118
+ const row = rowIndex.get(point.position.row);
119
+ if (col === undefined || row === undefined) {
108
120
  continue;
109
121
  }
110
122
 
111
123
  const rect: RectSpec = {
112
- x: plot.x + colIndex * cellWidth + gap / 2,
113
- y: plot.y + rowIndex * cellHeight + gap / 2,
124
+ x: plot.x + col * cellWidth + gap / 2,
125
+ y: plot.y + row * cellHeight + gap / 2,
114
126
  width: Math.max(0, cellWidth - gap),
115
127
  height: Math.max(0, cellHeight - gap),
116
- radius: options.cellRadius,
128
+ radius: options.cellRadius ?? input.radius?.cell,
117
129
  };
118
130
 
119
131
  if (isKnown(point.value)) {
@@ -173,17 +185,22 @@ export function buildHeatmapGeometry<M>(
173
185
  paths,
174
186
  markers: [],
175
187
  labels: [],
176
- xTicks: columns.map((c, i) => ({
177
- value: i,
178
- position: plot.x + i * cellWidth + cellWidth / 2,
179
- label: input.formatCategory(c),
180
- })),
181
- yTicks: rows.map((r, i) => ({
188
+ xTicks: thinTicks(
189
+ columnLabels.map((label, i) => ({
190
+ value: i,
191
+ position: plot.x + i * cellWidth + cellWidth / 2,
192
+ label,
193
+ })),
194
+ input.fontSize,
195
+ plot.width,
196
+ ),
197
+ yTicks: rowLabels.map((label, i) => ({
182
198
  value: i,
183
199
  position: plot.y + i * cellHeight + cellHeight / 2,
184
- label: input.formatCategory(r),
200
+ label,
185
201
  })),
186
202
  hitRegions,
203
+ ...(hasKnown ? { colorScale: { min, max, colors: ramp.slice(0, bucketCount) } } : {}),
187
204
  incomplete: excluded > 0,
188
205
  };
189
206
  }