Skip to content

Commit bbcedf1

Browse files
committed
Fix bug where too many marks could be rendered on slider
1 parent 7261852 commit bbcedf1

File tree

3 files changed

+198
-54
lines changed

3 files changed

+198
-54
lines changed

components/dash-core-components/src/utils/computeSliderMarkers.ts

Lines changed: 33 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-magic-numbers */
12
import {pickBy, isEmpty, isNil} from 'ramda';
23
import {formatPrefix} from 'd3-format';
34
import {SliderMarks} from '../types';
@@ -38,7 +39,6 @@ const estimateBestSteps = (
3839
stepValue: number,
3940
sliderWidth?: number | null
4041
) => {
41-
// Base desired count for 330px slider with 0-100 scale (10 marks = 11 total including endpoints)
4242
let targetMarkCount = 11; // Default baseline
4343

4444
// Scale mark density based on slider width
@@ -87,31 +87,30 @@ const estimateBestSteps = (
8787
idealInterval = range / (targetMarkCount - 1);
8888
}
8989

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-
];
96-
97-
let bestInterval = stepValue;
98-
let bestDifference = Math.abs(idealInterval - stepValue);
99-
100-
for (const multiplier of stepMultipliers) {
101-
const candidateInterval = stepValue * multiplier;
102-
const difference = Math.abs(idealInterval - candidateInterval);
103-
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-
}
90+
// Calculate the multiplier needed to get close to idealInterval
91+
// Round to a "nice" number for cleaner mark placement
92+
const rawMultiplier = idealInterval / stepValue;
93+
94+
// Round to nearest nice multiplier (1, 2, 2.5, 5, or power of 10 multiple)
95+
const magnitude = Math.pow(10, Math.floor(Math.log10(rawMultiplier)));
96+
const normalized = rawMultiplier / magnitude; // Now between 1 and 10
97+
98+
let niceMultiplier;
99+
if (normalized <= 1.5) {
100+
niceMultiplier = 1;
101+
} else if (normalized <= 2.25) {
102+
niceMultiplier = 2;
103+
} else if (normalized <= 3.5) {
104+
niceMultiplier = 2.5;
105+
} else if (normalized <= 7.5) {
106+
niceMultiplier = 5;
107+
} else {
108+
niceMultiplier = 10;
113109
}
114110

111+
const bestMultiplier = niceMultiplier * magnitude;
112+
const bestInterval = stepValue * bestMultiplier;
113+
115114
// All marks must be at valid step positions: minValue + (n * stepValue)
116115
// Find the first mark after minValue that fits our desired interval
117116
const stepsInInterval = Math.round(bestInterval / stepValue);
@@ -208,38 +207,18 @@ export const autoGenerateMarks = (
208207
);
209208
let cursor = start;
210209

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 {
210+
if ((max - cursor) / interval > 0) {
211+
while (cursor < max) {
238212
marks.push(alignValue(cursor, chosenStep));
239-
cursor += actualInterval;
240-
} while (cursor < max && marks.length < maxAutoGeneratedMarks);
213+
const prevCursor = cursor;
214+
cursor += interval;
215+
216+
// Safety check: floating point precision could impact this loop
217+
if (cursor <= prevCursor) {
218+
break;
219+
}
220+
}
241221

242-
// do some cosmetic
243222
const discardThreshold = 1.5;
244223
if (
245224
marks.length >= 2 &&
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from dash import Dash, dcc, html
2+
3+
4+
def test_slsl_extreme_range_marks_density(dash_dcc):
5+
"""
6+
Test that extreme ranges don't generate too many overlapping marks.
7+
8+
With min=-1, max=480256671, and container width ~365px, we should have
9+
no more than ~7 marks to prevent overlap (given the long labels).
10+
"""
11+
app = Dash(__name__)
12+
app.layout = html.Div(
13+
style={"width": "365px"},
14+
children=[
15+
dcc.RangeSlider(
16+
id="rangeslider-extreme",
17+
min=-1,
18+
max=480256671,
19+
value=[-1, 480256671],
20+
)
21+
],
22+
)
23+
24+
dash_dcc.start_server(app)
25+
26+
# Wait for component to render
27+
dash_dcc.wait_for_element("#rangeslider-extreme")
28+
29+
# Count the rendered marks
30+
marks = dash_dcc.find_elements(".dash-slider-mark")
31+
mark_count = len(marks)
32+
33+
print(f"Number of marks rendered: {mark_count}")
34+
35+
# Get the actual mark text to verify what's rendered
36+
mark_texts = [mark.text for mark in marks]
37+
print(f"Mark labels: {mark_texts}")
38+
39+
# Should have between 2 and 7 marks (min/max plus a few intermediate)
40+
assert 2 <= mark_count <= 7, (
41+
f"Expected 2-7 marks for extreme range, but found {mark_count}. "
42+
f"This suggests overlapping marks. Labels: {mark_texts}"
43+
)
44+
45+
# Verify min and max are included
46+
assert "-1" in mark_texts, "Min value (-1) should be included in marks"
47+
assert any(
48+
"480" in text or "M" in text for text in mark_texts
49+
), "Max value should be included in marks"
50+
51+
assert dash_dcc.get_logs() == []
52+
53+
54+
def test_slsl_extreme_range_no_width(dash_dcc):
55+
"""
56+
Test that extreme ranges work even before width is measured.
57+
58+
This simulates the initial render state where sliderWidth is null.
59+
"""
60+
app = Dash(__name__)
61+
app.layout = html.Div(
62+
# No explicit width, so ResizeObserver will measure it
63+
children=[
64+
dcc.RangeSlider(
65+
id="rangeslider-no-width",
66+
min=-1,
67+
max=480256671,
68+
value=[-1, 480256671],
69+
)
70+
],
71+
)
72+
73+
dash_dcc.start_server(app)
74+
75+
# Wait for component to render
76+
dash_dcc.wait_for_element("#rangeslider-no-width")
77+
78+
# Count the rendered marks
79+
marks = dash_dcc.find_elements(".dash-slider-mark")
80+
mark_count = len(marks)
81+
82+
print(f"Number of marks rendered (no explicit width): {mark_count}")
83+
84+
# Even without explicit width, should not have too many marks
85+
assert (
86+
mark_count <= 7
87+
), f"Expected <= 7 marks even without explicit width, but found {mark_count}"
88+
89+
assert dash_dcc.get_logs() == []

components/dash-core-components/tests/unit/computeSliderMarkers.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,80 @@ describe('Dynamic Slider Mark Density', () => {
338338
expect(areAllMarksValidSteps(width660, 0, 5)).toBe(true);
339339
});
340340
});
341+
342+
describe('Extreme ranges with large numbers', () => {
343+
test('should not create overlapping marks for range -1 to 480256671 WITHOUT width (initial render)', () => {
344+
const marks = sanitizeMarks({
345+
min: -1,
346+
max: 480256671,
347+
marks: undefined,
348+
step: undefined, // Let it auto-calculate
349+
sliderWidth: null, // Simulates initial render before width is measured
350+
});
351+
352+
const positions = getMarkPositions(marks);
353+
354+
// Should have min and max
355+
expect(positions[0]).toBe(-1);
356+
expect(positions[positions.length - 1]).toBe(480256671);
357+
358+
// Should have reasonable number of marks to prevent overlap even without width
359+
// With ~9-character labels (480256671), we need substantial spacing
360+
// Labels like "45M", "95M" are ~3-4 chars, so reasonable mark count is 5-7
361+
expect(positions.length).toBeGreaterThanOrEqual(2); // At least min and max
362+
expect(positions.length).toBeLessThanOrEqual(11); // Not too many to cause overlap
363+
364+
// Even without explicit width, assume a reasonable default (330px baseline)
365+
// and verify spacing would be sufficient
366+
const estimatedSpacing = 330 / (positions.length - 1);
367+
expect(estimatedSpacing).toBeGreaterThanOrEqual(30);
368+
});
369+
370+
test('should not create overlapping marks for range -1 to 480256671 at 365px width', () => {
371+
const marks = sanitizeMarks({
372+
min: -1,
373+
max: 480256671,
374+
marks: undefined,
375+
step: undefined, // Let it auto-calculate
376+
sliderWidth: 365,
377+
});
378+
379+
const positions = getMarkPositions(marks);
380+
381+
// Should have min and max
382+
expect(positions[0]).toBe(-1);
383+
expect(positions[positions.length - 1]).toBe(480256671);
384+
385+
// Should have reasonable number of marks to prevent overlap
386+
// With 365px width and ~9-character labels (480256671), we need substantial spacing
387+
// Estimate: 9 chars * 8px/char = 72px per label, so max ~5 marks for 365px
388+
expect(positions.length).toBeGreaterThanOrEqual(2); // At least min and max
389+
expect(positions.length).toBeLessThanOrEqual(7); // Not too many to cause overlap
390+
391+
// Verify spacing between marks is sufficient
392+
// With 365px width, marks should be at least 50px apart for long labels
393+
const estimatedSpacing = 365 / (positions.length - 1);
394+
expect(estimatedSpacing).toBeGreaterThanOrEqual(50);
395+
});
396+
397+
test('should handle very large ranges with appropriate step multipliers', () => {
398+
const marks = sanitizeMarks({
399+
min: 0,
400+
max: 1000000000, // 1 billion
401+
marks: undefined,
402+
step: undefined,
403+
sliderWidth: 330,
404+
});
405+
406+
const positions = getMarkPositions(marks);
407+
408+
// Should have reasonable mark count
409+
expect(positions.length).toBeGreaterThanOrEqual(2);
410+
expect(positions.length).toBeLessThanOrEqual(15);
411+
412+
// Should include min and max
413+
expect(positions[0]).toBe(0);
414+
expect(positions[positions.length - 1]).toBe(1000000000);
415+
});
416+
});
341417
});

0 commit comments

Comments
 (0)