diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json index b59b45a427..2b7671e840 100644 --- a/components/dash-core-components/package-lock.json +++ b/components/dash-core-components/package-lock.json @@ -51,7 +51,7 @@ "@plotly/webpack-dash-dynamic-import": "^1.3.0", "@types/d3-format": "^3.0.4", "@types/fast-isnumeric": "^1.1.2", - "@types/jest": "^29.5.0", + "@types/jest": "^29.5.14", "@types/js-search": "^1.4.4", "@types/ramda": "^0.31.0", "@types/react": "^16.14.8", diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index d53cc6cbd6..47b800335c 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -78,7 +78,7 @@ "@plotly/webpack-dash-dynamic-import": "^1.3.0", "@types/d3-format": "^3.0.4", "@types/fast-isnumeric": "^1.1.2", - "@types/jest": "^29.5.0", + "@types/jest": "^29.5.14", "@types/js-search": "^1.4.4", "@types/ramda": "^0.31.0", "@types/react": "^16.14.8", diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css index 2254054041..bb2335c9da 100644 --- a/components/dash-core-components/src/components/css/sliders.css +++ b/components/dash-core-components/src/components/css/sliders.css @@ -74,6 +74,10 @@ box-shadow: 0 4px 6px -1px var(--Dash-Shading-Weak); } +.dash-slider-thumb:focus { + outline: 1px solid var(--Dash-Fill-Interactive-Strong); +} + .dash-slider-thumb:focus .dash-slider-tooltip, .dash-slider-thumb:hover .dash-slider-tooltip { display: block; @@ -93,6 +97,10 @@ pointer-events: none; } +.dash-slider-mark-outside-selection { + color: var(--Dash-Text-Disabled); +} + .dash-slider-mark:before { content: ''; position: absolute; @@ -119,6 +127,10 @@ width: 8px; height: 8px; border-radius: 50%; + background-color: var(--Dash-Fill-Primary-Active); +} + +.dash-slider-dot-outside-selection { background-color: var(--Dash-Fill-Disabled); } @@ -203,6 +215,15 @@ appearance: textfield; } +.dash-range-slider-input::selection, +.dash-range-slider-input::-webkit-selection { + background: var(--Dash-Fill-Primary-Active); +} + +.dash-range-slider-input:focus { + outline: none; +} + /* Hide the number input spinners */ .dash-range-slider-input::-webkit-inner-spin-button, .dash-range-slider-input::-webkit-outer-spin-button { diff --git a/components/dash-core-components/src/fragments/RangeSlider.tsx b/components/dash-core-components/src/fragments/RangeSlider.tsx index 370269fa91..697e67dfe0 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.tsx +++ b/components/dash-core-components/src/fragments/RangeSlider.tsx @@ -500,6 +500,7 @@ export default function RangeSlider(props: RangeSliderProps) { renderedMarks, !!vertical, minMaxValues, + value, !!dots, !!reverse )} @@ -508,6 +509,7 @@ export default function RangeSlider(props: RangeSliderProps) { renderSliderDots( stepValue, minMaxValues, + value, !!vertical, !!reverse )} diff --git a/components/dash-core-components/src/utils/computeSliderMarkers.ts b/components/dash-core-components/src/utils/computeSliderMarkers.ts index a3081e5460..fafaac1bd3 100644 --- a/components/dash-core-components/src/utils/computeSliderMarkers.ts +++ b/components/dash-core-components/src/utils/computeSliderMarkers.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-magic-numbers */ import {pickBy, isEmpty, isNil} from 'ramda'; import {formatPrefix} from 'd3-format'; import {SliderMarks} from '../types'; @@ -32,86 +33,75 @@ const alignDecimalValue = (v: number, d: number) => const alignValue = (v: number, d: number) => decimalCount(d) < 1 ? alignIntValue(v, d) : alignDecimalValue(v, d); +export const applyD3Format = (mark: number, min: number, max: number) => { + const mu_ten_factor = -3; + const k_ten_factor = 4; // values < 10000 don't get formatted + + const ten_factor = Math.log10(Math.abs(mark)); + if ( + mark === 0 || + (ten_factor > mu_ten_factor && ten_factor < k_ten_factor) + ) { + return String(mark); + } + const max_min_mean = (Math.abs(max) + Math.abs(min)) / 2; + const si_formatter = formatPrefix(',.0', max_min_mean); + return String(si_formatter(mark)); +}; + const estimateBestSteps = ( minValue: number, maxValue: number, stepValue: number, sliderWidth?: number | null ) => { - // Base desired count for 330px slider with 0-100 scale (10 marks = 11 total including endpoints) - let targetMarkCount = 11; // Default baseline - - // Scale mark density based on slider width - if (sliderWidth) { - const baselineWidth = 330; - const baselineMarkCount = 11; // 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 - - // Calculate density multiplier based on width - const widthMultiplier = sliderWidth / baselineWidth; - - // Target mark count scales with width but maintains consistent density - // The range adjustment should be removed - we want consistent mark density based on width - targetMarkCount = Math.round(baselineMarkCount * widthMultiplier); - - // Ensure reasonable bounds - const UPPER_BOUND = 50; - targetMarkCount = Math.max(3, Math.min(targetMarkCount, UPPER_BOUND)); - - // Adjust density based on maximum character width of mark labels - // Estimate the maximum characters in any mark label - const maxValueChars = Math.max( - String(minValue).length, - String(maxValue).length, - String(Math.abs(minValue)).length, - String(Math.abs(maxValue)).length - ); - - // Baseline: 3-4 characters (like "100", "250") work well with baseline density - // For longer labels, reduce density to prevent overlap - const baselineChars = 3.5; - if (maxValueChars > baselineChars) { - const charReductionFactor = baselineChars / maxValueChars; - targetMarkCount = Math.round(targetMarkCount * charReductionFactor); - targetMarkCount = Math.max(2, targetMarkCount); // Ensure minimum of 2 marks - } - } - - // Calculate the ideal interval between marks based on target count - const range = maxValue - minValue; - let idealInterval = range / (targetMarkCount - 1); - - // Check if the step value is fractional and adjust density - if (stepValue % 1 !== 0) { - // For fractional steps, reduce mark density by half to avoid clutter - targetMarkCount = Math.max(3, Math.round(targetMarkCount / 2)); - idealInterval = range / (targetMarkCount - 1); - } + // Use formatted label length to account for SI formatting + // (e.g. labels that look like "100M" vs "100000000") + const formattedMin = applyD3Format(minValue, minValue, maxValue); + const formattedMax = applyD3Format(maxValue, minValue, maxValue); + const maxValueChars = Math.max(formattedMin.length, formattedMax.length); - // Find the best interval that's a multiple of stepValue - // Start with multiples of stepValue and find the one closest to idealInterval - const stepMultipliers = [ - // eslint-disable-next-line no-magic-numbers - 1, 2, 2.5, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, - ]; + // Calculate required spacing based on label width + // Estimate: 10px per character + 20px margin for spacing between labels + // This provides comfortable spacing to prevent overlap + const pixelsPerChar = 10; + const spacingMargin = 20; + const minPixelsPerMark = maxValueChars * pixelsPerChar + spacingMargin; - let bestInterval = stepValue; - let bestDifference = Math.abs(idealInterval - stepValue); + const effectiveWidth = sliderWidth || 330; - for (const multiplier of stepMultipliers) { - const candidateInterval = stepValue * multiplier; - const difference = Math.abs(idealInterval - candidateInterval); + // Calculate maximum number of marks that can fit without overlap + let targetMarkCount = Math.floor(effectiveWidth / minPixelsPerMark) + 1; + targetMarkCount = Math.max(3, Math.min(targetMarkCount, 50)); - if (difference < bestDifference) { - bestInterval = candidateInterval; - bestDifference = difference; - } - - // Stop if we've gone too far beyond the ideal - if (candidateInterval > idealInterval * 2) { - break; - } + // Calculate the ideal interval between marks based on target count + const range = maxValue - minValue; + const idealInterval = range / (targetMarkCount - 1); + + // Calculate the multiplier needed to get close to idealInterval + // Round to a "nice" number for cleaner mark placement + const rawMultiplier = idealInterval / stepValue; + + // Round to nearest nice multiplier (1, 2, 2.5, 5, or power of 10 multiple) + const magnitude = Math.pow(10, Math.floor(Math.log10(rawMultiplier))); + const normalized = rawMultiplier / magnitude; // Now between 1 and 10 + + let niceMultiplier; + if (normalized <= 1.5) { + niceMultiplier = 1; + } else if (normalized <= 2.25) { + niceMultiplier = 2; + } else if (normalized <= 3.5) { + niceMultiplier = 2.5; + } else if (normalized <= 5) { + niceMultiplier = 5; + } else { + niceMultiplier = 10; } + const bestMultiplier = Math.max(1, niceMultiplier * magnitude); + const bestInterval = stepValue * bestMultiplier; + // All marks must be at valid step positions: minValue + (n * stepValue) // Find the first mark after minValue that fits our desired interval const stepsInInterval = Math.round(bestInterval / stepValue); @@ -175,22 +165,6 @@ export const setUndefined = ( return definedMarks; }; -export const applyD3Format = (mark: number, min: number, max: number) => { - const mu_ten_factor = -3; - const k_ten_factor = 3; - - const ten_factor = Math.log10(Math.abs(mark)); - if ( - mark === 0 || - (ten_factor > mu_ten_factor && ten_factor < k_ten_factor) - ) { - return String(mark); - } - const max_min_mean = (Math.abs(max) + Math.abs(min)) / 2; - const si_formatter = formatPrefix(',.0', max_min_mean); - return String(si_formatter(mark)); -}; - export const autoGenerateMarks = ( min: number, max: number, @@ -198,8 +172,9 @@ export const autoGenerateMarks = ( sliderWidth?: number | null ) => { const marks = []; - // Always use dynamic logic, but pass the provided step as a constraint - const effectiveStep = step || calcStep(min, max, 0); + + const effectiveStep = step ?? 1; + const [start, interval, chosenStep] = estimateBestSteps( min, max, @@ -208,38 +183,18 @@ export const autoGenerateMarks = ( ); let cursor = start; - // Apply a safety cap to prevent excessive mark generation while preserving existing behavior - // Only restrict when marks would be truly excessive (much higher than the existing UPPER_BOUND) - const MARK_WIDTH_PX = 20; // More generous spacing for width-based calculation - const FALLBACK_MAX_MARKS = 200; // High fallback to preserve existing behavior when no width - const ABSOLUTE_MAX_MARKS = 200; // Safety cap against extreme cases - - const widthBasedMax = sliderWidth - ? Math.max(10, Math.floor(sliderWidth / MARK_WIDTH_PX)) - : FALLBACK_MAX_MARKS; - - const maxAutoGeneratedMarks = Math.min(widthBasedMax, ABSOLUTE_MAX_MARKS); - - // Calculate how many marks would be generated with current interval - const estimatedMarkCount = Math.floor((max - start) / interval) + 1; - - // If we would exceed the limit, increase the interval to fit within the limit - let actualInterval = interval; - if (estimatedMarkCount > maxAutoGeneratedMarks) { - // Recalculate interval to fit exactly within the limit - actualInterval = (max - start) / (maxAutoGeneratedMarks - 1); - // Round to a reasonable step multiple to keep marks clean - const stepMultiple = Math.ceil(actualInterval / chosenStep); - actualInterval = stepMultiple * chosenStep; - } - - if ((max - cursor) / actualInterval > 0) { - do { + if ((max - cursor) / interval > 0) { + while (cursor < max) { marks.push(alignValue(cursor, chosenStep)); - cursor += actualInterval; - } while (cursor < max && marks.length < maxAutoGeneratedMarks); + const prevCursor = cursor; + cursor += interval; + + // Safety check: floating point precision could impact this loop + if (cursor <= prevCursor) { + break; + } + } - // do some cosmetic const discardThreshold = 1.5; if ( marks.length >= 2 && diff --git a/components/dash-core-components/src/utils/sliderRendering.tsx b/components/dash-core-components/src/utils/sliderRendering.tsx index 68f16c3d0b..0e8ae37ab9 100644 --- a/components/dash-core-components/src/utils/sliderRendering.tsx +++ b/components/dash-core-components/src/utils/sliderRendering.tsx @@ -58,6 +58,7 @@ export const renderSliderMarks = ( renderedMarks: SliderMarks, vertical: boolean, minMaxValues: {min_mark: number; max_mark: number}, + selectedValues: number[] = [], dots: boolean, reverse = false ) => { @@ -83,10 +84,30 @@ export const renderSliderMarks = ( transform: 'translateX(-50%)', }; + // Determine if mark is outside the selected range + let isOutsideSelection = false; + if (selectedValues.length === 1) { + isOutsideSelection = pos > selectedValues[0]; + } else if (selectedValues.length > 1) { + const [minValue, maxValue] = [ + selectedValues[0], + selectedValues[selectedValues.length - 1], + ]; + isOutsideSelection = pos < minValue || pos > maxValue; + } + + const outsideClassName = isOutsideSelection + ? 'dash-slider-mark-outside-selection' + : ''; + + const className = `dash-slider-mark ${ + dots ? 'with-dots' : '' + } ${outsideClassName}`.trim(); + return (