Skip to content

Commit 965ef89

Browse files
committed
refactor(engine): split axes.ts into ticks and thinning modules
Pure mechanical extraction. Moves tick generation (continuousTicks, categoricalTicks, resolveExplicitTicks, formatTickLabel) to layout/axes/ticks.ts and label overlap detection + density thinning (ticksOverlap, thinTicksUntilFit, measureLabel) to layout/axes/thinning.ts. axes.ts keeps orchestration (computeAxes, effectiveDensity, AxesResult) and re-exports the pure helpers so the public import path ./layout/axes stays unchanged for consumers and tests. The a953932 vertical-orientation overlap fix moves intact into thinning.ts. No behavior changes; visual regression byte-identical.
1 parent a4dc5de commit 965ef89

3 files changed

Lines changed: 264 additions & 244 deletions

File tree

packages/engine/src/layout/axes.ts

Lines changed: 9 additions & 244 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* Axis computation: tick positions, labels, and axis lines.
33
*
44
* 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/.
67
*/
78

89
import type {
@@ -16,33 +17,19 @@ import type {
1617
ResolvedTheme,
1718
TextStyle,
1819
} 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';
2720
import type { ScaleBand } from 'd3-scale';
28-
import type {
29-
D3CategoricalScale,
30-
D3ContinuousScale,
31-
ResolvedScale,
32-
ResolvedScales,
33-
} from './scales';
21+
import { measureLabel, thinTicksUntilFit } from './axes/thinning';
22+
import { categoricalTicks, continuousTicks, resolveExplicitTicks } from './axes/ticks';
23+
import type { ResolvedScales } from './scales';
24+
25+
// Re-export pure helpers so external consumers (and tests) continue to import
26+
// them from './layout/axes'.
27+
export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
3428

3529
// ---------------------------------------------------------------------------
3630
// Constants
3731
// ---------------------------------------------------------------------------
3832

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-
4633
/**
4734
* Height thresholds for reducing y-axis tick density.
4835
* Below these pixel heights, we step down the density regardless of the
@@ -59,15 +46,6 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
5946
const WIDTH_MINIMAL_THRESHOLD = 150;
6047
const WIDTH_REDUCED_THRESHOLD = 300;
6148

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-
7149
/** Ordered densities from most to fewest ticks. */
7250
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
7351

@@ -106,219 +84,6 @@ export function effectiveDensity(
10684
return density;
10785
}
10886

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-
}
149-
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-
}
159-
160-
/**
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.
164-
*/
165-
export function thinTicksUntilFit(
166-
ticks: AxisTick[],
167-
fontSize: number,
168-
fontWeight: number,
169-
measureText?: MeasureTextFn,
170-
orientation: 'horizontal' | 'vertical' = 'horizontal',
171-
): 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);
284-
}
285-
// Abbreviate large numbers for axis labels
286-
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
287-
return formatNumber(num);
288-
}
289-
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-
});
320-
}
321-
32287
// ---------------------------------------------------------------------------
32388
// Public API
32489
// ---------------------------------------------------------------------------
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Tick label overlap detection and density thinning.
3+
*
4+
* Horizontal orientation (x-axis): checks label width against adjacent
5+
* positions. Vertical orientation (y-axis): checks font-based label height
6+
* against adjacent positions, ignoring text width so wide numeric labels
7+
* don't trigger aggressive thinning.
8+
*/
9+
10+
import type { AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
11+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
12+
13+
/**
14+
* Minimum gap between adjacent tick labels as a multiple of font size.
15+
* At the default 12px axis font, this yields ~12px of breathing room.
16+
*/
17+
const MIN_TICK_GAP_FACTOR = 1.0;
18+
19+
/** Always show at least this many ticks, even if they overlap. */
20+
const MIN_TICK_COUNT = 2;
21+
22+
/** Measure a single label's width using real measurement or heuristic fallback. */
23+
export function measureLabel(
24+
text: string,
25+
fontSize: number,
26+
fontWeight: number,
27+
measureText?: MeasureTextFn,
28+
): number {
29+
return measureText
30+
? measureText(text, fontSize, fontWeight).width
31+
: estimateTextWidth(text, fontSize, fontWeight);
32+
}
33+
34+
/** Check whether any adjacent tick labels overlap along the axis direction. */
35+
export function ticksOverlap(
36+
ticks: AxisTick[],
37+
fontSize: number,
38+
fontWeight: number,
39+
measureText?: MeasureTextFn,
40+
orientation: 'horizontal' | 'vertical' = 'horizontal',
41+
): boolean {
42+
if (ticks.length < 2) return false;
43+
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
44+
45+
if (orientation === 'vertical') {
46+
// Y-axis: labels are stacked vertically. Check if vertical extent
47+
// (based on font height) overlaps between adjacent ticks.
48+
// Positions decrease going up in SVG coords, so sort ascending.
49+
const sorted = [...ticks].sort((a, b) => a.position - b.position);
50+
const labelHeight = fontSize * 1.2; // lineHeight
51+
for (let i = 0; i < sorted.length - 1; i++) {
52+
const aBottom = sorted[i].position + labelHeight / 2;
53+
const bTop = sorted[i + 1].position - labelHeight / 2;
54+
if (aBottom + minGap > bTop) return true;
55+
}
56+
return false;
57+
}
58+
59+
for (let i = 0; i < ticks.length - 1; i++) {
60+
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
61+
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
62+
const aRight = ticks[i].position + aWidth / 2;
63+
const bLeft = ticks[i + 1].position - bWidth / 2;
64+
if (aRight + minGap > bLeft) return true;
65+
}
66+
return false;
67+
}
68+
69+
/**
70+
* Thin a tick array by removing every other tick until labels don't overlap.
71+
* Always keeps first and last tick. O(log n) iterations max.
72+
* Returns the original array if no thinning is needed.
73+
*/
74+
export function thinTicksUntilFit(
75+
ticks: AxisTick[],
76+
fontSize: number,
77+
fontWeight: number,
78+
measureText?: MeasureTextFn,
79+
orientation: 'horizontal' | 'vertical' = 'horizontal',
80+
): AxisTick[] {
81+
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
82+
83+
let current = ticks;
84+
while (current.length > MIN_TICK_COUNT) {
85+
// Keep first, last, and every other tick in between
86+
const thinned = [current[0]];
87+
for (let i = 2; i < current.length - 1; i += 2) {
88+
thinned.push(current[i]);
89+
}
90+
if (current.length > 1) thinned.push(current[current.length - 1]);
91+
current = thinned;
92+
93+
if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
94+
}
95+
return current;
96+
}

0 commit comments

Comments
 (0)