Skip to content

Commit d475f28

Browse files
authored
Merge pull request #3515 from plotly/bugfix/slider-bugfixes
Bugfix: address dcc slider feedback
2 parents f86ac93 + 6d1c5e4 commit d475f28

File tree

9 files changed

+325
-144
lines changed

9 files changed

+325
-144
lines changed

components/dash-core-components/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/dash-core-components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"@plotly/webpack-dash-dynamic-import": "^1.3.0",
7979
"@types/d3-format": "^3.0.4",
8080
"@types/fast-isnumeric": "^1.1.2",
81-
"@types/jest": "^29.5.0",
81+
"@types/jest": "^29.5.14",
8282
"@types/js-search": "^1.4.4",
8383
"@types/ramda": "^0.31.0",
8484
"@types/react": "^16.14.8",

components/dash-core-components/src/components/css/sliders.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@
7474
box-shadow: 0 4px 6px -1px var(--Dash-Shading-Weak);
7575
}
7676

77+
.dash-slider-thumb:focus {
78+
outline: 1px solid var(--Dash-Fill-Interactive-Strong);
79+
}
80+
7781
.dash-slider-thumb:focus .dash-slider-tooltip,
7882
.dash-slider-thumb:hover .dash-slider-tooltip {
7983
display: block;
@@ -93,6 +97,10 @@
9397
pointer-events: none;
9498
}
9599

100+
.dash-slider-mark-outside-selection {
101+
color: var(--Dash-Text-Disabled);
102+
}
103+
96104
.dash-slider-mark:before {
97105
content: '';
98106
position: absolute;
@@ -119,6 +127,10 @@
119127
width: 8px;
120128
height: 8px;
121129
border-radius: 50%;
130+
background-color: var(--Dash-Fill-Primary-Active);
131+
}
132+
133+
.dash-slider-dot-outside-selection {
122134
background-color: var(--Dash-Fill-Disabled);
123135
}
124136

@@ -203,6 +215,15 @@
203215
appearance: textfield;
204216
}
205217

218+
.dash-range-slider-input::selection,
219+
.dash-range-slider-input::-webkit-selection {
220+
background: var(--Dash-Fill-Primary-Active);
221+
}
222+
223+
.dash-range-slider-input:focus {
224+
outline: none;
225+
}
226+
206227
/* Hide the number input spinners */
207228
.dash-range-slider-input::-webkit-inner-spin-button,
208229
.dash-range-slider-input::-webkit-outer-spin-button {

components/dash-core-components/src/fragments/RangeSlider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ export default function RangeSlider(props: RangeSliderProps) {
500500
renderedMarks,
501501
!!vertical,
502502
minMaxValues,
503+
value,
503504
!!dots,
504505
!!reverse
505506
)}
@@ -508,6 +509,7 @@ export default function RangeSlider(props: RangeSliderProps) {
508509
renderSliderDots(
509510
stepValue,
510511
minMaxValues,
512+
value,
511513
!!vertical,
512514
!!reverse
513515
)}

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

Lines changed: 71 additions & 116 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';
@@ -32,86 +33,75 @@ const alignDecimalValue = (v: number, d: number) =>
3233
const 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+
3552
const 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-
194168
export 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 &&

components/dash-core-components/src/utils/sliderRendering.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const renderSliderMarks = (
5858
renderedMarks: SliderMarks,
5959
vertical: boolean,
6060
minMaxValues: {min_mark: number; max_mark: number},
61+
selectedValues: number[] = [],
6162
dots: boolean,
6263
reverse = false
6364
) => {
@@ -83,10 +84,30 @@ export const renderSliderMarks = (
8384
transform: 'translateX(-50%)',
8485
};
8586

87+
// Determine if mark is outside the selected range
88+
let isOutsideSelection = false;
89+
if (selectedValues.length === 1) {
90+
isOutsideSelection = pos > selectedValues[0];
91+
} else if (selectedValues.length > 1) {
92+
const [minValue, maxValue] = [
93+
selectedValues[0],
94+
selectedValues[selectedValues.length - 1],
95+
];
96+
isOutsideSelection = pos < minValue || pos > maxValue;
97+
}
98+
99+
const outsideClassName = isOutsideSelection
100+
? 'dash-slider-mark-outside-selection'
101+
: '';
102+
103+
const className = `dash-slider-mark ${
104+
dots ? 'with-dots' : ''
105+
} ${outsideClassName}`.trim();
106+
86107
return (
87108
<div
88109
key={position}
89-
className={`dash-slider-mark ${dots ? 'with-dots' : ''}`}
110+
className={className}
90111
style={{
91112
...style,
92113
...(typeof mark === 'object' && mark.style
@@ -103,6 +124,7 @@ export const renderSliderMarks = (
103124
export const renderSliderDots = (
104125
stepValue: number,
105126
minMaxValues: {min_mark: number; max_mark: number},
127+
selectedValues: number[] = [],
106128
vertical: boolean,
107129
reverse = false
108130
) => {
@@ -149,10 +171,26 @@ export const renderSliderDots = (
149171
transform: 'translate(-50%, 50%)',
150172
};
151173

174+
// Determine if dot is outside the selected range
175+
let isOutsideSelection = false;
176+
if (selectedValues.length === 1) {
177+
isOutsideSelection = dotValue > selectedValues[0];
178+
} else if (selectedValues.length > 1) {
179+
const [minValue, maxValue] = [
180+
selectedValues[0],
181+
selectedValues[selectedValues.length - 1],
182+
];
183+
isOutsideSelection = dotValue < minValue || dotValue > maxValue;
184+
}
185+
186+
const className = isOutsideSelection
187+
? 'dash-slider-dot dash-slider-dot-outside-selection'
188+
: 'dash-slider-dot';
189+
152190
return (
153191
<div
154192
key={i}
155-
className="dash-slider-dot"
193+
className={className}
156194
style={{
157195
...dotStyle,
158196
}}

0 commit comments

Comments
 (0)