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 (
{ @@ -149,10 +171,26 @@ export const renderSliderDots = ( transform: 'translate(-50%, 50%)', }; + // Determine if dot is outside the selected range + let isOutsideSelection = false; + if (selectedValues.length === 1) { + isOutsideSelection = dotValue > selectedValues[0]; + } else if (selectedValues.length > 1) { + const [minValue, maxValue] = [ + selectedValues[0], + selectedValues[selectedValues.length - 1], + ]; + isOutsideSelection = dotValue < minValue || dotValue > maxValue; + } + + const className = isOutsideSelection + ? 'dash-slider-dot dash-slider-dot-outside-selection' + : 'dash-slider-dot'; + return (
{ describe('Baseline behavior (330px width)', () => { - test('should show ~10-11 marks for 0-100 range with step=5', () => { + test('should show appropriate marks for 0-100 range with step=5 at 330px', () => { const marks = sanitizeMarks({ min: 0, max: 100, @@ -54,7 +54,9 @@ describe('Dynamic Slider Mark Density', () => { expect(marks).toBeDefined(); const positions = getMarkPositions(marks); - expect(positions.length).toBe(11); + // With pixel-based algorithm: 3-char labels need ~50px per mark + // 330px / 50px = ~7 marks (plus min/max adjustment gives 8) + expect(positions.length).toBe(8); expect(areAllMarksValidSteps(marks, 0, 5)).toBe(true); expect(positions).toContain(0); expect(positions).toContain(100); @@ -220,36 +222,17 @@ describe('Dynamic Slider Mark Density', () => { }); }); - test('should reduce density by half for fractional steps', () => { - const integerStep = sanitizeMarks({ - min: 0, - max: 10, - marks: undefined, - step: 1, // Integer step - sliderWidth: 330, - }); - + test('should have appropriate density for fractional steps', () => { const fractionalStep = sanitizeMarks({ min: 0, max: 10, marks: undefined, step: 0.5, // Fractional step - sliderWidth: 330, + sliderWidth: 1600, }); - const integerPositions = getMarkPositions(integerStep); const fractionalPositions = getMarkPositions(fractionalStep); - - // Fractional step should have roughly half the marks of integer step - expect(fractionalPositions.length).toBeLessThan( - integerPositions.length - ); - expect(fractionalPositions.length).toBeLessThanOrEqual( - Math.ceil(integerPositions.length / 2) + 1 - ); - - // Both should be valid steps - expect(areAllMarksValidSteps(integerStep, 0, 1)).toBe(true); + expect(fractionalPositions.length).toBe(21); expect(areAllMarksValidSteps(fractionalStep, 0, 0.5)).toBe(true); }); @@ -338,4 +321,103 @@ describe('Dynamic Slider Mark Density', () => { expect(areAllMarksValidSteps(width660, 0, 5)).toBe(true); }); }); + + describe('Extreme ranges with large numbers', () => { + test('should not create overlapping marks for range -1 to 480256671 WITHOUT width (initial render)', () => { + const marks = sanitizeMarks({ + min: -1, + max: 480256671, + marks: undefined, + step: undefined, // Let it auto-calculate + sliderWidth: null, // Simulates initial render before width is measured + }); + + const positions = getMarkPositions(marks); + + // Should have min and max + expect(positions[0]).toBe(-1); + expect(positions[positions.length - 1]).toBe(480256671); + + // Should have reasonable number of marks to prevent overlap even without width + // With ~9-character labels (480256671), we need substantial spacing + // Labels like "45M", "95M" are ~3-4 chars, so reasonable mark count is 5-7 + expect(positions.length).toBeGreaterThanOrEqual(2); // At least min and max + expect(positions.length).toBeLessThanOrEqual(11); // Not too many to cause overlap + + // Even without explicit width, assume a reasonable default (330px baseline) + // and verify spacing would be sufficient + const estimatedSpacing = 330 / (positions.length - 1); + expect(estimatedSpacing).toBeGreaterThanOrEqual(30); + }); + + test('should not create overlapping marks for range -1 to 480256671 at 365px width', () => { + const marks = sanitizeMarks({ + min: -1, + max: 480256671, + marks: undefined, + step: undefined, // Let it auto-calculate + sliderWidth: 365, + }); + + const positions = getMarkPositions(marks); + + // Should have min and max + expect(positions[0]).toBe(-1); + expect(positions[positions.length - 1]).toBe(480256671); + + // Should have reasonable number of marks to prevent overlap + // With 365px width and ~9-character labels (480256671), we need substantial spacing + // Estimate: 9 chars * 8px/char = 72px per label, so max ~5 marks for 365px + expect(positions.length).toBeGreaterThanOrEqual(2); // At least min and max + expect(positions.length).toBeLessThanOrEqual(7); // Not too many to cause overlap + + // Verify spacing between marks is sufficient + // With 365px width, marks should be at least 50px apart for long labels + const estimatedSpacing = 365 / (positions.length - 1); + expect(estimatedSpacing).toBeGreaterThanOrEqual(50); + }); + + test('should handle very large ranges with appropriate step multipliers', () => { + const marks = sanitizeMarks({ + min: 0, + max: 1000000000, // 1 billion + marks: undefined, + step: undefined, + sliderWidth: 330, + }); + + const positions = getMarkPositions(marks); + + // Should have reasonable mark count + expect(positions.length).toBeGreaterThanOrEqual(2); + expect(positions.length).toBeLessThanOrEqual(15); + + // Should include min and max + expect(positions[0]).toBe(0); + expect(positions[positions.length - 1]).toBe(1000000000); + }); + + test('does not have all marks labeled as "2k" for range 1952 to 2007', () => { + const marks = sanitizeMarks({ + min: 1952, + max: 2007, + marks: undefined, + step: undefined, + sliderWidth: 365, + }); + + // Get all the label values (not the keys) + const labels = Object.values(marks); + + // Count unique labels + const uniqueLabels = new Set(labels); + + // Should have more than one unique label + expect(uniqueLabels.size).toBeGreaterThan(1); + + // Should NOT have all labels be "2k" + const allLabels2k = labels.every(label => label === '2k'); + expect(allLabels2k).toBe(false); + }); + }); }); diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 91ba3ca9fc..a67964c1ba 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -420,6 +420,9 @@ def update_text(data): dash_duo.wait_for_text_to_equal("#output-1", "hello world") assert dash_duo.find_element("#output-1").get_attribute("data-cb") == "hello world" + # Wait for all callbacks to complete + time.sleep(0.1) + # an initial call, one for clearing the input # and one for each hello world character assert input_call_count.value == 2 + len("hello world")