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
89import 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' ;
2720import 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;
5946const WIDTH_MINIMAL_THRESHOLD = 150 ;
6047const 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. */
7250const 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// ---------------------------------------------------------------------------
0 commit comments