@chipmobilesdk/rn-chart 0.1.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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * SVG path-string builders.
3
+ *
4
+ * This is where the "never bridge a gap" guarantee (FR-012) becomes
5
+ * structural rather than a rule someone has to remember. A missing value does
6
+ * not produce a coordinate — it ends the current subpath. The next known value
7
+ * starts a fresh one with an `M` command. There is physically no line segment
8
+ * spanning the gap, so no amount of downstream carelessness can draw one.
9
+ *
10
+ * Batching into path data is also what keeps element counts flat: one `Path`
11
+ * per series regardless of point count (research.md R2).
12
+ *
13
+ * PURE. No React, no SVG imports — this module emits strings.
14
+ */
15
+
16
+ export interface Pt {
17
+ readonly x: number;
18
+ readonly y: number;
19
+ }
20
+
21
+ /** A point that may be absent, mirroring the value contract. */
22
+ export type MaybePt = Pt | null;
23
+
24
+ function n(value: number): string {
25
+ // Two decimals is well below sub-pixel visibility and keeps `d` strings
26
+ // short, which matters when they cross the bridge on every data change.
27
+ return Number.isFinite(value) ? value.toFixed(2) : '0';
28
+ }
29
+
30
+ /**
31
+ * Polyline through the known points, broken at every gap.
32
+ *
33
+ * `null` entries are missing values. They terminate the current subpath; they
34
+ * are never interpolated, carried forward, or skipped over.
35
+ */
36
+ export function linePath(points: readonly MaybePt[]): string {
37
+ let d = '';
38
+ let penDown = false;
39
+
40
+ for (const p of points) {
41
+ if (p === null) {
42
+ // Lift the pen. The gap stays a gap.
43
+ penDown = false;
44
+ continue;
45
+ }
46
+ if (!penDown) {
47
+ d += `M${n(p.x)} ${n(p.y)}`;
48
+ penDown = true;
49
+ } else {
50
+ d += `L${n(p.x)} ${n(p.y)}`;
51
+ }
52
+ }
53
+
54
+ return d;
55
+ }
56
+
57
+ /**
58
+ * Filled area between the polyline and a baseline, broken at gaps in the same
59
+ * way: each contiguous run of known points becomes its own closed shape.
60
+ */
61
+ export function areaPath(
62
+ points: readonly MaybePt[],
63
+ baselineY: number,
64
+ ): string {
65
+ let d = '';
66
+ let run: Pt[] = [];
67
+
68
+ const flush = () => {
69
+ if (run.length === 0) {
70
+ return;
71
+ }
72
+ d += `M${n(run[0].x)} ${n(baselineY)}`;
73
+ for (const p of run) {
74
+ d += `L${n(p.x)} ${n(p.y)}`;
75
+ }
76
+ d += `L${n(run[run.length - 1].x)} ${n(baselineY)}Z`;
77
+ run = [];
78
+ };
79
+
80
+ for (const p of points) {
81
+ if (p === null) {
82
+ flush();
83
+ } else {
84
+ run.push(p);
85
+ }
86
+ }
87
+ flush();
88
+
89
+ return d;
90
+ }
91
+
92
+ export interface RectSpec {
93
+ readonly x: number;
94
+ readonly y: number;
95
+ readonly width: number;
96
+ readonly height: number;
97
+ readonly radius?: number;
98
+ }
99
+
100
+ /** One rectangle as a subpath, with optional corner rounding. */
101
+ export function rectSubpath(r: RectSpec): string {
102
+ const radius = Math.min(r.radius ?? 0, r.width / 2, Math.abs(r.height) / 2);
103
+
104
+ if (radius <= 0) {
105
+ return (
106
+ `M${n(r.x)} ${n(r.y)}` +
107
+ `h${n(r.width)}v${n(r.height)}h${n(-r.width)}Z`
108
+ );
109
+ }
110
+
111
+ const { x, y, width: w, height: h } = r;
112
+ return (
113
+ `M${n(x + radius)} ${n(y)}` +
114
+ `h${n(w - radius * 2)}` +
115
+ `a${n(radius)} ${n(radius)} 0 0 1 ${n(radius)} ${n(radius)}` +
116
+ `v${n(h - radius * 2)}` +
117
+ `a${n(radius)} ${n(radius)} 0 0 1 ${n(-radius)} ${n(radius)}` +
118
+ `h${n(-(w - radius * 2))}` +
119
+ `a${n(radius)} ${n(radius)} 0 0 1 ${n(-radius)} ${n(-radius)}` +
120
+ `v${n(-(h - radius * 2))}` +
121
+ `a${n(radius)} ${n(radius)} 0 0 1 ${n(radius)} ${n(-radius)}Z`
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Many rectangles batched into one path — how bars and heatmap cells stay at
127
+ * one element per series or per intensity bucket.
128
+ */
129
+ export function rectBatchPath(rects: readonly RectSpec[]): string {
130
+ let d = '';
131
+ for (const r of rects) {
132
+ d += rectSubpath(r);
133
+ }
134
+ return d;
135
+ }
136
+
137
+ export interface ArcSpec {
138
+ readonly cx: number;
139
+ readonly cy: number;
140
+ readonly outerRadius: number;
141
+ readonly innerRadius: number;
142
+ /** Radians, clockwise from 12 o'clock. */
143
+ readonly startAngle: number;
144
+ readonly endAngle: number;
145
+ }
146
+
147
+ function polar(cx: number, cy: number, r: number, angle: number): Pt {
148
+ // Offset by -90 degrees so 0 radians is 12 o'clock, which is where users
149
+ // expect a donut to start.
150
+ const a = angle - Math.PI / 2;
151
+ return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
152
+ }
153
+
154
+ /**
155
+ * Donut or pie segment. An inner radius of 0 yields a pie wedge.
156
+ *
157
+ * A full-circle segment is drawn as two half-arcs, because SVG cannot express
158
+ * a 360-degree arc in a single command — start and end would coincide and
159
+ * nothing would be drawn.
160
+ */
161
+ export function arcPath(spec: ArcSpec): string {
162
+ const { cx, cy, outerRadius: ro, innerRadius: ri } = spec;
163
+ const sweep = spec.endAngle - spec.startAngle;
164
+ const full = Math.abs(sweep) >= Math.PI * 2 - 1e-9;
165
+
166
+ if (full) {
167
+ const mid = spec.startAngle + Math.PI;
168
+ return (
169
+ arcPath({ ...spec, endAngle: mid }) +
170
+ arcPath({ ...spec, startAngle: mid, endAngle: spec.startAngle + Math.PI * 2 })
171
+ );
172
+ }
173
+
174
+ const largeArc = Math.abs(sweep) > Math.PI ? 1 : 0;
175
+ const o0 = polar(cx, cy, ro, spec.startAngle);
176
+ const o1 = polar(cx, cy, ro, spec.endAngle);
177
+
178
+ if (ri <= 0) {
179
+ return (
180
+ `M${n(cx)} ${n(cy)}` +
181
+ `L${n(o0.x)} ${n(o0.y)}` +
182
+ `A${n(ro)} ${n(ro)} 0 ${largeArc} 1 ${n(o1.x)} ${n(o1.y)}Z`
183
+ );
184
+ }
185
+
186
+ const i1 = polar(cx, cy, ri, spec.endAngle);
187
+ const i0 = polar(cx, cy, ri, spec.startAngle);
188
+ return (
189
+ `M${n(o0.x)} ${n(o0.y)}` +
190
+ `A${n(ro)} ${n(ro)} 0 ${largeArc} 1 ${n(o1.x)} ${n(o1.y)}` +
191
+ `L${n(i1.x)} ${n(i1.y)}` +
192
+ `A${n(ri)} ${n(ri)} 0 ${largeArc} 0 ${n(i0.x)} ${n(i0.y)}Z`
193
+ );
194
+ }
195
+
196
+ /** Straight line, for axes, baselines, and threshold markers. */
197
+ export function linearSegment(from: Pt, to: Pt): string {
198
+ return `M${n(from.x)} ${n(from.y)}L${n(to.x)} ${n(to.y)}`;
199
+ }
@@ -0,0 +1,204 @@
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 { plotRight } from './layout';
16
+ import { rectSubpath } from './path';
17
+ import type {
18
+ GeometryInput,
19
+ GeometryLabel,
20
+ MarkPath,
21
+ ResolvedGeometry,
22
+ } from './types';
23
+
24
+ export interface ProgressOptions {
25
+ readonly orientation?: 'horizontal' | 'vertical';
26
+ readonly trackRadius?: number;
27
+ }
28
+
29
+ /** How far past the target an overshoot bar is allowed to extend visually. */
30
+ const OVERSHOOT_DISPLAY_CAP = 1.25;
31
+
32
+ export function buildProgressGeometry<M>(
33
+ datasource: ChartDatasource<M>,
34
+ input: GeometryInput,
35
+ options: ProgressOptions = {},
36
+ ): ResolvedGeometry {
37
+ const { plot } = input;
38
+ const metrics = datasource.series.flatMap(series =>
39
+ series.points.map(point => ({ series, point })),
40
+ );
41
+
42
+ if (metrics.length === 0 || plot.width <= 0) {
43
+ return {
44
+ plot,
45
+ paths: [],
46
+ markers: [],
47
+ labels: [],
48
+ xTicks: [],
49
+ yTicks: [],
50
+ hitRegions: [],
51
+ incomplete: false,
52
+ };
53
+ }
54
+
55
+ const rowHeight = plot.height / metrics.length;
56
+ const barHeight = Math.min(rowHeight * 0.45, 24);
57
+ const radius = options.trackRadius ?? barHeight / 2;
58
+
59
+ const paths: MarkPath[] = [];
60
+ const labels: GeometryLabel[] = [];
61
+ const hitRegions: HitRegion[] = [];
62
+ let incomplete = false;
63
+
64
+ metrics.forEach(({ series, point }, index) => {
65
+ 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);
68
+
69
+ // Track: the full width, always drawn, so an empty metric still reads as
70
+ // a metric rather than as blank space.
71
+ paths.push({
72
+ key: `track-${series.id}-${point.id}`,
73
+ seriesId: series.id,
74
+ d: rectSubpath({
75
+ x: plot.x,
76
+ y: rowY,
77
+ width: plot.width,
78
+ height: barHeight,
79
+ radius,
80
+ }),
81
+ color: input.seriesColors[series.id],
82
+ pattern: 'solid',
83
+ filled: true,
84
+ opacity: 0.15,
85
+ });
86
+
87
+ if (!isKnown(point.value)) {
88
+ incomplete = true;
89
+ // No fill at all, and a texture rather than a zero-length bar — a gap
90
+ // must not look like "none of the target achieved".
91
+ paths.push({
92
+ key: `nonvalue-${series.id}-${point.id}`,
93
+ seriesId: series.id,
94
+ d: rectSubpath({
95
+ x: plot.x,
96
+ y: rowY,
97
+ width: plot.width,
98
+ height: barHeight,
99
+ radius,
100
+ }),
101
+ color: input.seriesColors[series.id],
102
+ pattern: 'solid',
103
+ filled: true,
104
+ texture: input.nonValueTextures[point.value.kind],
105
+ opacity: 0.5,
106
+ });
107
+ } else {
108
+ const proportion = reading.proportion;
109
+ // With no usable target there is no proportion to draw, so the fill
110
+ // shows the value's presence rather than a fabricated percentage.
111
+ const fillFraction =
112
+ proportion === undefined
113
+ ? 1
114
+ : Math.min(proportion, OVERSHOOT_DISPLAY_CAP);
115
+
116
+ paths.push({
117
+ key: `fill-${series.id}-${point.id}`,
118
+ seriesId: series.id,
119
+ d: rectSubpath({
120
+ x: plot.x,
121
+ y: rowY,
122
+ width: Math.max(0, plot.width * Math.min(fillFraction, 1)),
123
+ height: barHeight,
124
+ radius,
125
+ }),
126
+ color: input.seriesColors[series.id],
127
+ pattern: 'solid',
128
+ filled: true,
129
+ });
130
+
131
+ // Overshoot is drawn beyond the target line rather than clamped away,
132
+ // so exceeding a goal is visible instead of indistinguishable from
133
+ // exactly meeting it.
134
+ if (reading.overshoot && proportion !== undefined) {
135
+ const overshootWidth =
136
+ plot.width * (Math.min(proportion, OVERSHOOT_DISPLAY_CAP) - 1);
137
+ paths.push({
138
+ key: `overshoot-${series.id}-${point.id}`,
139
+ seriesId: series.id,
140
+ d: rectSubpath({
141
+ x: plot.x + plot.width,
142
+ y: rowY + barHeight * 0.2,
143
+ width: Math.max(0, overshootWidth),
144
+ height: barHeight * 0.6,
145
+ radius: radius / 2,
146
+ }),
147
+ color: input.seriesColors[series.id],
148
+ pattern: 'dashed',
149
+ filled: true,
150
+ opacity: 0.7,
151
+ });
152
+ }
153
+ }
154
+
155
+ const valueText = isKnown(point.value)
156
+ ? input.formatValue(point.value.value)
157
+ : '';
158
+ // Only append a percentage when one is real.
159
+ const proportionText =
160
+ reading.proportion === undefined
161
+ ? ''
162
+ : ` (${Math.round(reading.proportion * 100)}%)`;
163
+
164
+ labels.push({
165
+ key: `label-${series.id}-${point.id}`,
166
+ text: input.formatCategory(
167
+ point.position.kind === 'category' ? point.position.category : series.label,
168
+ ),
169
+ x: plot.x,
170
+ y: rowY - 4,
171
+ anchor: 'start',
172
+ });
173
+
174
+ if (valueText) {
175
+ labels.push({
176
+ key: `value-${series.id}-${point.id}`,
177
+ text: `${valueText}${proportionText}`,
178
+ x: plotRight(plot),
179
+ y: rowY - 4,
180
+ anchor: 'end',
181
+ });
182
+ }
183
+
184
+ hitRegions.push({
185
+ seriesId: series.id,
186
+ pointId: point.id,
187
+ x: plot.x + plot.width / 2,
188
+ y: rowY + barHeight / 2,
189
+ bounds: { x: plot.x, y: rowY, width: plot.width, height: barHeight },
190
+ selectable: !isWithheld(point.value),
191
+ });
192
+ });
193
+
194
+ return {
195
+ plot,
196
+ paths,
197
+ markers: [],
198
+ labels,
199
+ xTicks: [],
200
+ yTicks: [],
201
+ hitRegions,
202
+ incomplete,
203
+ };
204
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Scales: domain values to pixel positions.
3
+ *
4
+ * PURE. No React, no SVG — enforced by the ESLint override in `.eslintrc.js`
5
+ * and by geometryPurity.test.ts.
6
+ */
7
+
8
+ export interface Range {
9
+ readonly min: number;
10
+ readonly max: number;
11
+ }
12
+
13
+ export interface LinearScale {
14
+ readonly domain: Range;
15
+ readonly rangePx: Range;
16
+ readonly project: (value: number) => number;
17
+ readonly invert: (px: number) => number;
18
+ }
19
+
20
+ /**
21
+ * Nudges a zero-width domain outward so an all-identical dataset still gets a
22
+ * usable axis. Without this the projection divides by zero and every point
23
+ * lands on the same pixel — or on NaN.
24
+ */
25
+ function widenDegenerateDomain(domain: Range): Range {
26
+ if (domain.max > domain.min) {
27
+ return domain;
28
+ }
29
+ const value = domain.min;
30
+ // Scale the padding to the magnitude so large values do not get a
31
+ // meaninglessly tiny window, and zero still gets a sensible one.
32
+ const pad = value === 0 ? 1 : Math.abs(value) * 0.5;
33
+ return { min: value - pad, max: value + pad };
34
+ }
35
+
36
+ export function linearScale(domain: Range, rangePx: Range): LinearScale {
37
+ const safeDomain = widenDegenerateDomain(domain);
38
+ const domainSpan = safeDomain.max - safeDomain.min;
39
+ const pxSpan = rangePx.max - rangePx.min;
40
+
41
+ return {
42
+ domain: safeDomain,
43
+ rangePx,
44
+ project: (value: number) =>
45
+ rangePx.min + ((value - safeDomain.min) / domainSpan) * pxSpan,
46
+ invert: (px: number) =>
47
+ safeDomain.min + ((px - rangePx.min) / pxSpan) * domainSpan,
48
+ };
49
+ }
50
+
51
+ export interface BandScale {
52
+ readonly categories: readonly string[];
53
+ readonly bandWidth: number;
54
+ readonly step: number;
55
+ readonly project: (category: string) => number | undefined;
56
+ readonly centerOf: (category: string) => number | undefined;
57
+ readonly invert: (px: number) => string | undefined;
58
+ }
59
+
60
+ /**
61
+ * Discrete positions for category axes.
62
+ *
63
+ * `padding` is the fraction of each step left as gap, so 0.2 means bars occupy
64
+ * 80% of their slot.
65
+ */
66
+ export function bandScale(
67
+ categories: readonly string[],
68
+ rangePx: Range,
69
+ padding = 0.2,
70
+ ): BandScale {
71
+ const count = Math.max(categories.length, 1);
72
+ const pxSpan = rangePx.max - rangePx.min;
73
+ const step = pxSpan / count;
74
+ const bandWidth = step * (1 - padding);
75
+ const offset = (step - bandWidth) / 2;
76
+
77
+ const indexOf = new Map<string, number>();
78
+ categories.forEach((c, i) => {
79
+ if (!indexOf.has(c)) {
80
+ indexOf.set(c, i);
81
+ }
82
+ });
83
+
84
+ const project = (category: string): number | undefined => {
85
+ const i = indexOf.get(category);
86
+ return i === undefined ? undefined : rangePx.min + i * step + offset;
87
+ };
88
+
89
+ return {
90
+ categories,
91
+ bandWidth,
92
+ step,
93
+ project,
94
+ centerOf: (category: string) => {
95
+ const start = project(category);
96
+ return start === undefined ? undefined : start + bandWidth / 2;
97
+ },
98
+ invert: (px: number) => {
99
+ const i = Math.floor((px - rangePx.min) / step);
100
+ return i >= 0 && i < categories.length ? categories[i] : undefined;
101
+ },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Time is just a linear scale over epoch milliseconds. Kept as a named
107
+ * function so call sites read clearly and so tick generation can branch on it.
108
+ */
109
+ export function timeScale(domain: Range, rangePx: Range): LinearScale {
110
+ return linearScale(domain, rangePx);
111
+ }
112
+
113
+ /**
114
+ * Domain from known values, padded so marks do not touch the plot edges.
115
+ *
116
+ * `includeZero` matters for bar charts: a bar's length is only meaningful when
117
+ * measured from zero. Time-series charts leave it off so a narrow band of
118
+ * values stays legible.
119
+ */
120
+ export function domainFrom(
121
+ values: readonly number[],
122
+ options: { includeZero?: boolean; padFraction?: number } = {},
123
+ ): Range {
124
+ if (values.length === 0) {
125
+ return { min: 0, max: 1 };
126
+ }
127
+
128
+ let min = values[0];
129
+ let max = values[0];
130
+ for (const v of values) {
131
+ if (v < min) {
132
+ min = v;
133
+ }
134
+ if (v > max) {
135
+ max = v;
136
+ }
137
+ }
138
+
139
+ if (options.includeZero) {
140
+ min = Math.min(min, 0);
141
+ max = Math.max(max, 0);
142
+ }
143
+
144
+ const pad = (max - min) * (options.padFraction ?? 0);
145
+ return { min: min - pad, max: max + pad };
146
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Tick generation and label thinning (FR-070).
3
+ *
4
+ * React Native offers no synchronous text measurement, and an `onLayout`
5
+ * round-trip per label would cost a second layout pass — enough to breach the
6
+ * first-paint budget. So we estimate width from character count and thin ticks
7
+ * to fit.
8
+ *
9
+ * The estimate is deliberately generous: over-estimating drops one tick too
10
+ * many, which is legible. Under-estimating produces overlapping text, which is
11
+ * the failure FR-070 exists to prevent. See research.md R6.
12
+ *
13
+ * PURE. No React, no SVG.
14
+ */
15
+ import type { Range } from './scale';
16
+
17
+ /**
18
+ * Average glyph advance as a fraction of font size, for the Latin digits and
19
+ * letters that dominate axis labels. Rounded up from the ~0.5-0.55 typical of
20
+ * the platform sans-serif faces.
21
+ */
22
+ export const AVERAGE_ADVANCE_RATIO = 0.62;
23
+
24
+ /** Minimum breathing room between two adjacent labels, in px. */
25
+ export const LABEL_GUTTER = 8;
26
+
27
+ export function estimateTextWidth(text: string, fontSize: number): number {
28
+ return text.length * fontSize * AVERAGE_ADVANCE_RATIO;
29
+ }
30
+
31
+ export interface Tick {
32
+ /** Domain value this tick represents. */
33
+ readonly value: number;
34
+ /** Pixel position along the axis. */
35
+ readonly position: number;
36
+ readonly label: string;
37
+ }
38
+
39
+ /**
40
+ * "Nice" step sizes: 1, 2, 5 and their powers of ten. Standard axis practice —
41
+ * these are the intervals people read fluently.
42
+ */
43
+ function niceStep(rawStep: number): number {
44
+ if (rawStep <= 0 || !Number.isFinite(rawStep)) {
45
+ return 1;
46
+ }
47
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
48
+ const normalized = rawStep / magnitude;
49
+
50
+ if (normalized <= 1) {
51
+ return magnitude;
52
+ }
53
+ if (normalized <= 2) {
54
+ return 2 * magnitude;
55
+ }
56
+ if (normalized <= 5) {
57
+ return 5 * magnitude;
58
+ }
59
+ return 10 * magnitude;
60
+ }
61
+
62
+ export function niceTickValues(domain: Range, targetCount: number): number[] {
63
+ const span = domain.max - domain.min;
64
+ if (span <= 0 || targetCount < 1) {
65
+ return [domain.min];
66
+ }
67
+
68
+ const step = niceStep(span / targetCount);
69
+ const start = Math.ceil(domain.min / step) * step;
70
+
71
+ const values: number[] = [];
72
+ // Guard against a pathological step producing an unbounded loop.
73
+ for (let v = start, guard = 0; v <= domain.max && guard < 1000; v += step) {
74
+ // Re-round to kill floating-point drift such as 0.30000000000000004.
75
+ values.push(Number((Math.round(v / step) * step).toFixed(10)));
76
+ guard += 1;
77
+ }
78
+ return values.length > 0 ? values : [domain.min];
79
+ }
80
+
81
+ /**
82
+ * Drops ticks until no two labels can overlap.
83
+ *
84
+ * Thinning is preferred over rotation as the default: rotated labels are harder
85
+ * to read on a phone and cost vertical space a mobile chart cannot spare.
86
+ * Rotation stays available as an opt-in for dense category axes.
87
+ *
88
+ * The first tick is always kept, so an axis never renders empty.
89
+ */
90
+ export function thinTicks(
91
+ ticks: readonly Tick[],
92
+ fontSize: number,
93
+ axisLengthPx: number,
94
+ ): Tick[] {
95
+ if (ticks.length <= 1) {
96
+ return [...ticks];
97
+ }
98
+
99
+ let widest = 0;
100
+ for (const t of ticks) {
101
+ widest = Math.max(widest, estimateTextWidth(t.label, fontSize));
102
+ }
103
+
104
+ const slot = widest + LABEL_GUTTER;
105
+ const maxLabels = Math.max(1, Math.floor(axisLengthPx / slot));
106
+ if (ticks.length <= maxLabels) {
107
+ return [...ticks];
108
+ }
109
+
110
+ const stride = Math.ceil(ticks.length / maxLabels);
111
+ return ticks.filter((_, i) => i % stride === 0);
112
+ }
113
+
114
+ /**
115
+ * How much space an axis must reserve for its labels, at the current system
116
+ * font scale (FR-071).
117
+ */
118
+ export function reserveAxisSpace(
119
+ labels: readonly string[],
120
+ fontSize: number,
121
+ orientation: 'horizontal' | 'vertical',
122
+ ): number {
123
+ if (labels.length === 0) {
124
+ return 0;
125
+ }
126
+ if (orientation === 'horizontal') {
127
+ // One line of text plus a gutter.
128
+ return fontSize * 1.4 + LABEL_GUTTER;
129
+ }
130
+
131
+ let widest = 0;
132
+ for (const label of labels) {
133
+ widest = Math.max(widest, estimateTextWidth(label, fontSize));
134
+ }
135
+ return widest + LABEL_GUTTER;
136
+ }
137
+
138
+ /**
139
+ * Shortens a label that cannot fit, signalling the truncation with an ellipsis.
140
+ * Callers must expose the full value through an accessible label (FR-070).
141
+ */
142
+ export function truncateToWidth(
143
+ text: string,
144
+ fontSize: number,
145
+ maxWidthPx: number,
146
+ ): string {
147
+ if (estimateTextWidth(text, fontSize) <= maxWidthPx) {
148
+ return text;
149
+ }
150
+ const perChar = fontSize * AVERAGE_ADVANCE_RATIO;
151
+ const fits = Math.max(1, Math.floor(maxWidthPx / perChar) - 1);
152
+ return `${text.slice(0, fits)}…`;
153
+ }