@opendata-ai/openchart-engine 6.20.0 → 6.22.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.
@@ -2,7 +2,8 @@
2
2
  * Axis computation: tick positions, labels, and axis lines.
3
3
  *
4
4
  * Generates ticks manually (no d3-axis) so we have full control over
5
- * responsive tick density and formatting.
5
+ * responsive tick density and formatting. Tick generation and label
6
+ * thinning live in sibling modules under ./axes/.
6
7
  */
7
8
 
8
9
  import type {
@@ -16,33 +17,26 @@ import type {
16
17
  ResolvedTheme,
17
18
  TextStyle,
18
19
  } from '@opendata-ai/openchart-core';
19
- import {
20
- abbreviateNumber,
21
- buildD3Formatter,
22
- buildTemporalFormatter,
23
- estimateTextWidth,
24
- formatDate,
25
- formatNumber,
26
- } from '@opendata-ai/openchart-core';
27
20
  import type { ScaleBand } from 'd3-scale';
28
- import type {
29
- D3CategoricalScale,
30
- D3ContinuousScale,
31
- ResolvedScale,
32
- ResolvedScales,
33
- } from './scales';
21
+ import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
22
+ import {
23
+ buildContinuousTicks,
24
+ categoricalTicks,
25
+ continuousTicks,
26
+ resolveExplicitTicks,
27
+ scaleSupportsTickCount,
28
+ targetTickCount,
29
+ } from './axes/ticks';
30
+ import type { ResolvedScales } from './scales';
31
+
32
+ // Re-export pure helpers so external consumers (and tests) continue to import
33
+ // them from './layout/axes'.
34
+ export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
34
35
 
35
36
  // ---------------------------------------------------------------------------
36
37
  // Constants
37
38
  // ---------------------------------------------------------------------------
38
39
 
39
- /** Base tick counts by axis label density. */
40
- const TICK_COUNTS: Record<AxisLabelDensity, number> = {
41
- full: 12,
42
- reduced: 8,
43
- minimal: 4,
44
- };
45
-
46
40
  /**
47
41
  * Height thresholds for reducing y-axis tick density.
48
42
  * Below these pixel heights, we step down the density regardless of the
@@ -59,15 +53,6 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
59
53
  const WIDTH_MINIMAL_THRESHOLD = 150;
60
54
  const WIDTH_REDUCED_THRESHOLD = 300;
61
55
 
62
- /**
63
- * Minimum gap between adjacent tick labels as a multiple of font size.
64
- * At the default 12px axis font, this yields ~12px of breathing room.
65
- */
66
- const MIN_TICK_GAP_FACTOR = 1.0;
67
-
68
- /** Always show at least this many ticks, even if they overlap. */
69
- const MIN_TICK_COUNT = 2;
70
-
71
56
  /** Ordered densities from most to fewest ticks. */
72
57
  const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
73
58
 
@@ -106,217 +91,94 @@ export function effectiveDensity(
106
91
  return density;
107
92
  }
108
93
 
109
- // ---------------------------------------------------------------------------
110
- // Label overlap detection and thinning
111
- // ---------------------------------------------------------------------------
112
-
113
- /** Measure a single label's width using real measurement or heuristic fallback. */
114
- function measureLabel(
115
- text: string,
116
- fontSize: number,
117
- fontWeight: number,
118
- measureText?: MeasureTextFn,
119
- ): number {
120
- return measureText
121
- ? measureText(text, fontSize, fontWeight).width
122
- : estimateTextWidth(text, fontSize, fontWeight);
123
- }
124
-
125
- /** Check whether any adjacent tick labels overlap along the axis direction. */
126
- export function ticksOverlap(
127
- ticks: AxisTick[],
128
- fontSize: number,
129
- fontWeight: number,
130
- measureText?: MeasureTextFn,
131
- orientation: 'horizontal' | 'vertical' = 'horizontal',
132
- ): boolean {
133
- if (ticks.length < 2) return false;
134
- const minGap = fontSize * MIN_TICK_GAP_FACTOR;
135
-
136
- if (orientation === 'vertical') {
137
- // Y-axis: labels are stacked vertically. Check if vertical extent
138
- // (based on font height) overlaps between adjacent ticks.
139
- // Positions decrease going up in SVG coords, so sort ascending.
140
- const sorted = [...ticks].sort((a, b) => a.position - b.position);
141
- const labelHeight = fontSize * 1.2; // lineHeight
142
- for (let i = 0; i < sorted.length - 1; i++) {
143
- const aBottom = sorted[i].position + labelHeight / 2;
144
- const bTop = sorted[i + 1].position - labelHeight / 2;
145
- if (aBottom + minGap > bTop) return true;
146
- }
147
- return false;
148
- }
94
+ /**
95
+ * Floor tick count for continuous axes when the axis is long enough to show
96
+ * more than a min/max pair. Keeps the editorial ~5 target when possible.
97
+ * Very short axes bypass this floor and can legitimately fall to 2.
98
+ */
99
+ const CONTINUOUS_TICK_FLOOR = 4;
149
100
 
150
- for (let i = 0; i < ticks.length - 1; i++) {
151
- const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
152
- const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
153
- const aRight = ticks[i].position + aWidth / 2;
154
- const bLeft = ticks[i + 1].position - bWidth / 2;
155
- if (aRight + minGap > bLeft) return true;
156
- }
157
- return false;
158
- }
101
+ /**
102
+ * How much D3 is allowed to overshoot what we asked for before we treat the
103
+ * output as "too dense" and step down. D3's `scale.ticks(n)` only produces
104
+ * nice step sizes (1, 2, 5 × 10^k, or calendar units for time), so a request
105
+ * for n=6 can come back with 12 quarterly dates or 10 step-5 values. Accepting
106
+ * up to 1.5× target catches the obvious overshoots without trimming acceptable
107
+ * ones.
108
+ *
109
+ * The reference point stays fixed to the initial requested count even as we
110
+ * iterate downward — we're measuring "did this candidate land near the target
111
+ * the caller actually wanted?", not "is the candidate near what we just asked
112
+ * for on this iteration?". If a candidate at n=3 returns 8 ticks, it's still
113
+ * a 1.3× overshoot of the target-6, which is fine.
114
+ */
115
+ const OVERSHOOT_TOLERANCE = 1.5;
159
116
 
160
117
  /**
161
- * Thin a tick array by removing every other tick until labels don't overlap.
162
- * Always keeps first and last tick. O(log n) iterations max.
163
- * Returns the original array if no thinning is needed.
118
+ * Fit continuous ticks by re-requesting progressively fewer ticks from the
119
+ * scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
120
+ * stepping `n` down keeps spacing uniform unlike middle-pruning which can
121
+ * strand the last tick next to an endpoint and cascade to 2 ticks.
122
+ *
123
+ * Two conditions trigger a step-down:
124
+ * 1. The label heuristic detects overlap at the initial count.
125
+ * 2. D3 overshot the requested count by more than OVERSHOOT_TOLERANCE.
126
+ * (Time scales jump between calendar units; linear scales jump between
127
+ * nice step sizes. Either can return 2× what we asked for.)
128
+ *
129
+ * Falls back to overlap-safe thinning on the best-so-far candidate if no
130
+ * count produces a clean fit. The fallback starts from the smallest candidate
131
+ * that still meets the floor, so `thinTicksUntilFit` never receives the
132
+ * overshot initial set (which was the bug this function exists to avoid).
164
133
  */
165
- export function thinTicksUntilFit(
166
- ticks: AxisTick[],
134
+ function fitContinuousTicks(
135
+ scale: ResolvedScales['x' | 'y'],
136
+ initialTicks: AxisTick[],
137
+ initialCount: number,
167
138
  fontSize: number,
168
139
  fontWeight: number,
140
+ axisLength: number,
141
+ orientation: 'horizontal' | 'vertical',
169
142
  measureText?: MeasureTextFn,
170
- orientation: 'horizontal' | 'vertical' = 'horizontal',
171
143
  ): AxisTick[] {
172
- if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
173
-
174
- let current = ticks;
175
- while (current.length > MIN_TICK_COUNT) {
176
- // Keep first, last, and every other tick in between
177
- const thinned = [current[0]];
178
- for (let i = 2; i < current.length - 1; i += 2) {
179
- thinned.push(current[i]);
180
- }
181
- if (current.length > 1) thinned.push(current[current.length - 1]);
182
- current = thinned;
183
-
184
- if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
185
- }
186
- return current;
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // Tick generation
191
- // ---------------------------------------------------------------------------
192
-
193
- /** Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog). */
194
- function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
195
- const scale = resolvedScale.scale as D3ContinuousScale;
196
-
197
- // Discretizing scales (quantile, quantize, threshold) don't have .ticks().
198
- // Use their domain thresholds as ticks instead.
199
- if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
200
- const domain = scale.domain() as unknown[];
201
- return domain.map((value: unknown) => ({
202
- value,
203
- position: (scale as D3ContinuousScale)(value as number & Date) as number,
204
- label: formatTickLabel(value, resolvedScale),
205
- }));
206
- }
207
-
208
- const explicitCount = resolvedScale.channel.axis?.tickCount;
209
- const count = explicitCount ?? TICK_COUNTS[density];
210
- const rawTicks: unknown[] = scale.ticks(count);
211
-
212
- const ticks = rawTicks.map((value: unknown) => ({
213
- value,
214
- position: scale(value as number & Date) as number,
215
- label: formatTickLabel(value, resolvedScale),
216
- }));
217
-
218
- return ticks;
219
- }
220
-
221
- /** Generate ticks for a band/point/ordinal scale. */
222
- function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
223
- const scale = resolvedScale.scale as D3CategoricalScale;
224
- const domain: string[] = scale.domain();
225
- const explicitTickCount = resolvedScale.channel.axis?.tickCount;
226
- const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
227
-
228
- // Band scales (bar charts) show all category labels by default.
229
- // Only thin when there's an explicit tickCount override or for point/ordinal scales.
230
- let selectedValues = domain;
231
- if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
232
- const step = Math.ceil(domain.length / maxTicks);
233
- selectedValues = domain.filter((_: string, i: number) => i % step === 0);
234
- }
235
-
236
- const ticks = selectedValues.map((value: string) => {
237
- // Band scales: use the center of the band
238
- const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
239
- const pos = bandScale
240
- ? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
241
- : ((scale(value) as number | undefined) ?? 0);
242
-
243
- return {
244
- value,
245
- position: pos,
246
- label: value,
247
- };
248
- });
249
-
250
- return ticks;
251
- }
252
-
253
- /** Set of continuous numeric scale types that should format as numbers. */
254
- const NUMERIC_SCALE_TYPES = new Set([
255
- 'linear',
256
- 'log',
257
- 'pow',
258
- 'sqrt',
259
- 'symlog',
260
- 'quantile',
261
- 'quantize',
262
- 'threshold',
263
- ]);
264
-
265
- /** Set of temporal scale types. */
266
- const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
267
-
268
- /** Format a tick value based on the scale type. */
269
- function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
270
- const formatStr = resolvedScale.channel.axis?.format;
271
-
272
- if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
273
- const temporalFmt = buildTemporalFormatter(formatStr);
274
- if (temporalFmt) return temporalFmt(value as Date);
275
- const useUtc = resolvedScale.type === 'utc';
276
- return formatDate(value as Date, undefined, undefined, useUtc);
277
- }
278
-
279
- if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
280
- const num = value as number;
281
- if (formatStr) {
282
- const fmt = buildD3Formatter(formatStr);
283
- if (fmt) return fmt(num);
144
+ if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
145
+
146
+ const tolerance = initialCount * OVERSHOOT_TOLERANCE;
147
+ const overshoots = initialTicks.length > tolerance;
148
+ const overlaps = ticksOverlap(initialTicks, fontSize, fontWeight, measureText, orientation);
149
+ if (!overshoots && !overlaps) return initialTicks;
150
+
151
+ // Enforce the floor only when the axis is long enough to fit that many
152
+ // labels without overlap. Very short axes can fall below.
153
+ const minThreshold =
154
+ orientation === 'vertical' ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
155
+ const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
156
+
157
+ // Track the smallest candidate that meets the floor. If no candidate fits
158
+ // cleanly, we thin this instead of the overshot `initialTicks` so the
159
+ // fallback doesn't reintroduce the cascading-to-2-ticks bug.
160
+ let bestWithinFloor: AxisTick[] | undefined;
161
+ for (let n = initialCount - 1; n >= 2; n--) {
162
+ const candidate = buildContinuousTicks(scale, n);
163
+ const candidateOvershoots = candidate.length > tolerance;
164
+ const candidateOverlaps = ticksOverlap(
165
+ candidate,
166
+ fontSize,
167
+ fontWeight,
168
+ measureText,
169
+ orientation,
170
+ );
171
+ if (!candidateOvershoots && !candidateOverlaps) {
172
+ return candidate;
284
173
  }
285
- // Abbreviate large numbers for axis labels
286
- if (Math.abs(num) >= 1000) return abbreviateNumber(num);
287
- return formatNumber(num);
174
+ if (candidate.length >= floor) bestWithinFloor = candidate;
288
175
  }
289
176
 
290
- return String(value);
291
- }
292
-
293
- /** Resolve explicit tick values from axis config into positioned ticks. */
294
- function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
295
- const scale = resolvedScale.scale;
296
- return values.map((value) => {
297
- let position: number;
298
- if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
299
- const d = value instanceof Date ? value : new Date(String(value));
300
- position = (scale as D3ContinuousScale)(d as number & Date) as number;
301
- } else if (
302
- resolvedScale.type === 'band' ||
303
- resolvedScale.type === 'point' ||
304
- resolvedScale.type === 'ordinal'
305
- ) {
306
- const s = String(value);
307
- const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
308
- position = bandScale
309
- ? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
310
- : ((scale(s as string & number) as number | undefined) ?? 0);
311
- } else {
312
- position = (scale as D3ContinuousScale)(value as number & Date) as number;
313
- }
314
- return {
315
- value,
316
- position,
317
- label: formatTickLabel(value, resolvedScale),
318
- };
319
- });
177
+ // No candidate fit cleanly. Thin whatever most recently met the floor; if
178
+ // nothing did, synthesize a floor-count set directly from the scale so we
179
+ // never hand the overshot initialTicks to the middle-pruning thinner.
180
+ const fallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
181
+ return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
320
182
  }
321
183
 
322
184
  // ---------------------------------------------------------------------------
@@ -385,19 +247,21 @@ export function computeAxes(
385
247
 
386
248
  if (scales.x) {
387
249
  const axisConfig = scales.x.channel.axis;
250
+ const isContinuousX =
251
+ scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
252
+
253
+ const xTargetCount = isContinuousX
254
+ ? targetTickCount(chartArea.width, xDensity, 'x')
255
+ : undefined;
388
256
 
389
257
  // Use explicit tick values from axis config if provided
390
258
  let allTicks: AxisTick[];
391
259
  if (axisConfig?.values) {
392
260
  allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
393
- } else if (
394
- scales.x.type === 'band' ||
395
- scales.x.type === 'point' ||
396
- scales.x.type === 'ordinal'
397
- ) {
261
+ } else if (!isContinuousX) {
398
262
  allTicks = categoricalTicks(scales.x, xDensity);
399
263
  } else {
400
- allTicks = continuousTicks(scales.x, xDensity);
264
+ allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
401
265
  }
402
266
 
403
267
  // Gridlines use the full tick set so they remain visible even when labels
@@ -410,9 +274,25 @@ export function computeAxes(
410
274
  // Thin tick labels to prevent overlap (skip for band scales which use
411
275
  // auto-rotation, and when the user set an explicit tickCount or values).
412
276
  const shouldThin = scales.x.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
413
- const ticks = shouldThin
414
- ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
415
- : allTicks;
277
+ let ticks: AxisTick[];
278
+ if (!shouldThin) {
279
+ ticks = allTicks;
280
+ } else if (isContinuousX) {
281
+ // Continuous x-axis: re-request ticks at a lower count on overlap so
282
+ // time-scale quartile/monthly jumps don't leave a too-dense axis.
283
+ ticks = fitContinuousTicks(
284
+ scales.x,
285
+ allTicks,
286
+ xTargetCount ?? allTicks.length,
287
+ fontSize,
288
+ fontWeight,
289
+ chartArea.width,
290
+ 'horizontal',
291
+ measureText,
292
+ );
293
+ } else {
294
+ ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
295
+ }
416
296
 
417
297
  // Auto-rotate labels when band scale labels would overlap.
418
298
  // Uses max label width (not average) since one long label is enough to overlap.
@@ -454,26 +334,45 @@ export function computeAxes(
454
334
 
455
335
  if (scales.y) {
456
336
  const axisConfig = scales.y.channel.axis;
337
+ const isContinuousY =
338
+ scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
339
+
340
+ // Target count from pixels-per-tick when we have a continuous y-axis.
341
+ const yTargetCount = isContinuousY
342
+ ? targetTickCount(chartArea.height, yDensity, 'y')
343
+ : undefined;
457
344
 
458
345
  // Use explicit tick values from axis config if provided
459
346
  let allTicks: AxisTick[];
460
347
  if (axisConfig?.values) {
461
348
  allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
462
- } else if (
463
- scales.y.type === 'band' ||
464
- scales.y.type === 'point' ||
465
- scales.y.type === 'ordinal'
466
- ) {
349
+ } else if (!isContinuousY) {
467
350
  allTicks = categoricalTicks(scales.y, yDensity);
468
351
  } else {
469
- allTicks = continuousTicks(scales.y, yDensity);
352
+ allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
470
353
  }
471
354
 
472
355
  // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
473
356
  const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
474
- const ticks = shouldThin
475
- ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical')
476
- : allTicks;
357
+ let ticks: AxisTick[];
358
+ if (!shouldThin) {
359
+ ticks = allTicks;
360
+ } else if (isContinuousY) {
361
+ // Continuous y-axis: re-request ticks at a lower count on overlap so
362
+ // spacing stays uniform and we don't collapse to min/max.
363
+ ticks = fitContinuousTicks(
364
+ scales.y,
365
+ allTicks,
366
+ yTargetCount ?? allTicks.length,
367
+ fontSize,
368
+ fontWeight,
369
+ chartArea.height,
370
+ 'vertical',
371
+ measureText,
372
+ );
373
+ } else {
374
+ ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical');
375
+ }
477
376
 
478
377
  // Gridlines match the tick set so every gridline has a label.
479
378
  const gridlines: Gridline[] = ticks.map((t) => ({