1+ /* eslint-disable no-magic-numbers */
12import { pickBy , isEmpty , isNil } from 'ramda' ;
23import { formatPrefix } from 'd3-format' ;
34import { SliderMarks } from '../types' ;
@@ -32,86 +33,75 @@ const alignDecimalValue = (v: number, d: number) =>
3233const alignValue = ( v : number , d : number ) =>
3334 decimalCount ( d ) < 1 ? alignIntValue ( v , d ) : alignDecimalValue ( v , d ) ;
3435
36+ export const applyD3Format = ( mark : number , min : number , max : number ) => {
37+ const mu_ten_factor = - 3 ;
38+ const k_ten_factor = 4 ; // values < 10000 don't get formatted
39+
40+ const ten_factor = Math . log10 ( Math . abs ( mark ) ) ;
41+ if (
42+ mark === 0 ||
43+ ( ten_factor > mu_ten_factor && ten_factor < k_ten_factor )
44+ ) {
45+ return String ( mark ) ;
46+ }
47+ const max_min_mean = ( Math . abs ( max ) + Math . abs ( min ) ) / 2 ;
48+ const si_formatter = formatPrefix ( ',.0' , max_min_mean ) ;
49+ return String ( si_formatter ( mark ) ) ;
50+ } ;
51+
3552const estimateBestSteps = (
3653 minValue : number ,
3754 maxValue : number ,
3855 stepValue : number ,
3956 sliderWidth ?: number | null
4057) => {
41- // Base desired count for 330px slider with 0-100 scale (10 marks = 11 total including endpoints)
42- let targetMarkCount = 11 ; // Default baseline
43-
44- // Scale mark density based on slider width
45- if ( sliderWidth ) {
46- const baselineWidth = 330 ;
47- const baselineMarkCount = 11 ; // 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100
48-
49- // Calculate density multiplier based on width
50- const widthMultiplier = sliderWidth / baselineWidth ;
51-
52- // Target mark count scales with width but maintains consistent density
53- // The range adjustment should be removed - we want consistent mark density based on width
54- targetMarkCount = Math . round ( baselineMarkCount * widthMultiplier ) ;
55-
56- // Ensure reasonable bounds
57- const UPPER_BOUND = 50 ;
58- targetMarkCount = Math . max ( 3 , Math . min ( targetMarkCount , UPPER_BOUND ) ) ;
59-
60- // Adjust density based on maximum character width of mark labels
61- // Estimate the maximum characters in any mark label
62- const maxValueChars = Math . max (
63- String ( minValue ) . length ,
64- String ( maxValue ) . length ,
65- String ( Math . abs ( minValue ) ) . length ,
66- String ( Math . abs ( maxValue ) ) . length
67- ) ;
68-
69- // Baseline: 3-4 characters (like "100", "250") work well with baseline density
70- // For longer labels, reduce density to prevent overlap
71- const baselineChars = 3.5 ;
72- if ( maxValueChars > baselineChars ) {
73- const charReductionFactor = baselineChars / maxValueChars ;
74- targetMarkCount = Math . round ( targetMarkCount * charReductionFactor ) ;
75- targetMarkCount = Math . max ( 2 , targetMarkCount ) ; // Ensure minimum of 2 marks
76- }
77- }
78-
79- // Calculate the ideal interval between marks based on target count
80- const range = maxValue - minValue ;
81- let idealInterval = range / ( targetMarkCount - 1 ) ;
82-
83- // Check if the step value is fractional and adjust density
84- if ( stepValue % 1 !== 0 ) {
85- // For fractional steps, reduce mark density by half to avoid clutter
86- targetMarkCount = Math . max ( 3 , Math . round ( targetMarkCount / 2 ) ) ;
87- idealInterval = range / ( targetMarkCount - 1 ) ;
88- }
58+ // Use formatted label length to account for SI formatting
59+ // (e.g. labels that look like "100M" vs "100000000")
60+ const formattedMin = applyD3Format ( minValue , minValue , maxValue ) ;
61+ const formattedMax = applyD3Format ( maxValue , minValue , maxValue ) ;
62+ const maxValueChars = Math . max ( formattedMin . length , formattedMax . length ) ;
8963
90- // Find the best interval that's a multiple of stepValue
91- // Start with multiples of stepValue and find the one closest to idealInterval
92- const stepMultipliers = [
93- // eslint-disable-next-line no-magic-numbers
94- 1 , 2 , 2.5 , 5 , 10 , 20 , 25 , 50 , 100 , 200 , 250 , 500 , 1000 ,
95- ] ;
64+ // Calculate required spacing based on label width
65+ // Estimate: 10px per character + 20px margin for spacing between labels
66+ // This provides comfortable spacing to prevent overlap
67+ const pixelsPerChar = 10 ;
68+ const spacingMargin = 20 ;
69+ const minPixelsPerMark = maxValueChars * pixelsPerChar + spacingMargin ;
9670
97- let bestInterval = stepValue ;
98- let bestDifference = Math . abs ( idealInterval - stepValue ) ;
71+ const effectiveWidth = sliderWidth || 330 ;
9972
100- for ( const multiplier of stepMultipliers ) {
101- const candidateInterval = stepValue * multiplier ;
102- const difference = Math . abs ( idealInterval - candidateInterval ) ;
73+ // Calculate maximum number of marks that can fit without overlap
74+ let targetMarkCount = Math . floor ( effectiveWidth / minPixelsPerMark ) + 1 ;
75+ targetMarkCount = Math . max ( 3 , Math . min ( targetMarkCount , 50 ) ) ;
10376
104- if ( difference < bestDifference ) {
105- bestInterval = candidateInterval ;
106- bestDifference = difference ;
107- }
108-
109- // Stop if we've gone too far beyond the ideal
110- if ( candidateInterval > idealInterval * 2 ) {
111- break ;
112- }
77+ // Calculate the ideal interval between marks based on target count
78+ const range = maxValue - minValue ;
79+ const idealInterval = range / ( targetMarkCount - 1 ) ;
80+
81+ // Calculate the multiplier needed to get close to idealInterval
82+ // Round to a "nice" number for cleaner mark placement
83+ const rawMultiplier = idealInterval / stepValue ;
84+
85+ // Round to nearest nice multiplier (1, 2, 2.5, 5, or power of 10 multiple)
86+ const magnitude = Math . pow ( 10 , Math . floor ( Math . log10 ( rawMultiplier ) ) ) ;
87+ const normalized = rawMultiplier / magnitude ; // Now between 1 and 10
88+
89+ let niceMultiplier ;
90+ if ( normalized <= 1.5 ) {
91+ niceMultiplier = 1 ;
92+ } else if ( normalized <= 2.25 ) {
93+ niceMultiplier = 2 ;
94+ } else if ( normalized <= 3.5 ) {
95+ niceMultiplier = 2.5 ;
96+ } else if ( normalized <= 5 ) {
97+ niceMultiplier = 5 ;
98+ } else {
99+ niceMultiplier = 10 ;
113100 }
114101
102+ const bestMultiplier = Math . max ( 1 , niceMultiplier * magnitude ) ;
103+ const bestInterval = stepValue * bestMultiplier ;
104+
115105 // All marks must be at valid step positions: minValue + (n * stepValue)
116106 // Find the first mark after minValue that fits our desired interval
117107 const stepsInInterval = Math . round ( bestInterval / stepValue ) ;
@@ -175,31 +165,16 @@ export const setUndefined = (
175165 return definedMarks ;
176166} ;
177167
178- export const applyD3Format = ( mark : number , min : number , max : number ) => {
179- const mu_ten_factor = - 3 ;
180- const k_ten_factor = 3 ;
181-
182- const ten_factor = Math . log10 ( Math . abs ( mark ) ) ;
183- if (
184- mark === 0 ||
185- ( ten_factor > mu_ten_factor && ten_factor < k_ten_factor )
186- ) {
187- return String ( mark ) ;
188- }
189- const max_min_mean = ( Math . abs ( max ) + Math . abs ( min ) ) / 2 ;
190- const si_formatter = formatPrefix ( ',.0' , max_min_mean ) ;
191- return String ( si_formatter ( mark ) ) ;
192- } ;
193-
194168export const autoGenerateMarks = (
195169 min : number ,
196170 max : number ,
197171 step ?: number | null ,
198172 sliderWidth ?: number | null
199173) => {
200174 const marks = [ ] ;
201- // Always use dynamic logic, but pass the provided step as a constraint
202- const effectiveStep = step || calcStep ( min , max , 0 ) ;
175+
176+ const effectiveStep = step ?? 1 ;
177+
203178 const [ start , interval , chosenStep ] = estimateBestSteps (
204179 min ,
205180 max ,
@@ -208,38 +183,18 @@ export const autoGenerateMarks = (
208183 ) ;
209184 let cursor = start ;
210185
211- // Apply a safety cap to prevent excessive mark generation while preserving existing behavior
212- // Only restrict when marks would be truly excessive (much higher than the existing UPPER_BOUND)
213- const MARK_WIDTH_PX = 20 ; // More generous spacing for width-based calculation
214- const FALLBACK_MAX_MARKS = 200 ; // High fallback to preserve existing behavior when no width
215- const ABSOLUTE_MAX_MARKS = 200 ; // Safety cap against extreme cases
216-
217- const widthBasedMax = sliderWidth
218- ? Math . max ( 10 , Math . floor ( sliderWidth / MARK_WIDTH_PX ) )
219- : FALLBACK_MAX_MARKS ;
220-
221- const maxAutoGeneratedMarks = Math . min ( widthBasedMax , ABSOLUTE_MAX_MARKS ) ;
222-
223- // Calculate how many marks would be generated with current interval
224- const estimatedMarkCount = Math . floor ( ( max - start ) / interval ) + 1 ;
225-
226- // If we would exceed the limit, increase the interval to fit within the limit
227- let actualInterval = interval ;
228- if ( estimatedMarkCount > maxAutoGeneratedMarks ) {
229- // Recalculate interval to fit exactly within the limit
230- actualInterval = ( max - start ) / ( maxAutoGeneratedMarks - 1 ) ;
231- // Round to a reasonable step multiple to keep marks clean
232- const stepMultiple = Math . ceil ( actualInterval / chosenStep ) ;
233- actualInterval = stepMultiple * chosenStep ;
234- }
235-
236- if ( ( max - cursor ) / actualInterval > 0 ) {
237- do {
186+ if ( ( max - cursor ) / interval > 0 ) {
187+ while ( cursor < max ) {
238188 marks . push ( alignValue ( cursor , chosenStep ) ) ;
239- cursor += actualInterval ;
240- } while ( cursor < max && marks . length < maxAutoGeneratedMarks ) ;
189+ const prevCursor = cursor ;
190+ cursor += interval ;
191+
192+ // Safety check: floating point precision could impact this loop
193+ if ( cursor <= prevCursor ) {
194+ break ;
195+ }
196+ }
241197
242- // do some cosmetic
243198 const discardThreshold = 1.5 ;
244199 if (
245200 marks . length >= 2 &&
0 commit comments