Skip to content

Commit e692726

Browse files
committed
Further mark density bugfixes
1 parent 0ab715f commit e692726

4 files changed

Lines changed: 72 additions & 90 deletions

File tree

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/utils/computeSliderMarkers.ts

Lines changed: 40 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -33,59 +33,50 @@ const alignDecimalValue = (v: number, d: number) =>
3333
const alignValue = (v: number, d: number) =>
3434
decimalCount(d) < 1 ? alignIntValue(v, d) : alignDecimalValue(v, d);
3535

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+
3652
const estimateBestSteps = (
3753
minValue: number,
3854
maxValue: number,
3955
stepValue: number,
4056
sliderWidth?: number | null
4157
) => {
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-
}
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);
63+
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;
70+
71+
const effectiveWidth = sliderWidth || 330;
72+
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));
7876

7977
// Calculate the ideal interval between marks based on target count
8078
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-
}
79+
const idealInterval = range / (targetMarkCount - 1);
8980

9081
// Calculate the multiplier needed to get close to idealInterval
9182
// Round to a "nice" number for cleaner mark placement
@@ -102,13 +93,13 @@ const estimateBestSteps = (
10293
niceMultiplier = 2;
10394
} else if (normalized <= 3.5) {
10495
niceMultiplier = 2.5;
105-
} else if (normalized <= 7.5) {
96+
} else if (normalized <= 5) {
10697
niceMultiplier = 5;
10798
} else {
10899
niceMultiplier = 10;
109100
}
110101

111-
const bestMultiplier = niceMultiplier * magnitude;
102+
const bestMultiplier = Math.max(1, niceMultiplier * magnitude);
112103
const bestInterval = stepValue * bestMultiplier;
113104

114105
// All marks must be at valid step positions: minValue + (n * stepValue)
@@ -174,31 +165,16 @@ export const setUndefined = (
174165
return definedMarks;
175166
};
176167

177-
export const applyD3Format = (mark: number, min: number, max: number) => {
178-
const mu_ten_factor = -3;
179-
const k_ten_factor = 3;
180-
181-
const ten_factor = Math.log10(Math.abs(mark));
182-
if (
183-
mark === 0 ||
184-
(ten_factor > mu_ten_factor && ten_factor < k_ten_factor)
185-
) {
186-
return String(mark);
187-
}
188-
const max_min_mean = (Math.abs(max) + Math.abs(min)) / 2;
189-
const si_formatter = formatPrefix(',.0', max_min_mean);
190-
return String(si_formatter(mark));
191-
};
192-
193168
export const autoGenerateMarks = (
194169
min: number,
195170
max: number,
196171
step?: number | null,
197172
sliderWidth?: number | null
198173
) => {
199174
const marks = [];
200-
// Always use dynamic logic, but pass the provided step as a constraint
201-
const effectiveStep = step || calcStep(min, max, 0);
175+
176+
const effectiveStep = step ?? 1;
177+
202178
const [start, interval, chosenStep] = estimateBestSteps(
203179
min,
204180
max,

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

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const areAllMarksValidSteps = (
4242

4343
describe('Dynamic Slider Mark Density', () => {
4444
describe('Baseline behavior (330px width)', () => {
45-
test('should show ~10-11 marks for 0-100 range with step=5', () => {
45+
test('should show appropriate marks for 0-100 range with step=5 at 330px', () => {
4646
const marks = sanitizeMarks({
4747
min: 0,
4848
max: 100,
@@ -54,7 +54,9 @@ describe('Dynamic Slider Mark Density', () => {
5454
expect(marks).toBeDefined();
5555
const positions = getMarkPositions(marks);
5656

57-
expect(positions.length).toBe(11);
57+
// With pixel-based algorithm: 3-char labels need ~50px per mark
58+
// 330px / 50px = ~7 marks (plus min/max adjustment gives 8)
59+
expect(positions.length).toBe(8);
5860
expect(areAllMarksValidSteps(marks, 0, 5)).toBe(true);
5961
expect(positions).toContain(0);
6062
expect(positions).toContain(100);
@@ -220,36 +222,17 @@ describe('Dynamic Slider Mark Density', () => {
220222
});
221223
});
222224

223-
test('should reduce density by half for fractional steps', () => {
224-
const integerStep = sanitizeMarks({
225-
min: 0,
226-
max: 10,
227-
marks: undefined,
228-
step: 1, // Integer step
229-
sliderWidth: 330,
230-
});
231-
225+
test('should have appropriate density for fractional steps', () => {
232226
const fractionalStep = sanitizeMarks({
233227
min: 0,
234228
max: 10,
235229
marks: undefined,
236230
step: 0.5, // Fractional step
237-
sliderWidth: 330,
231+
sliderWidth: 1600,
238232
});
239233

240-
const integerPositions = getMarkPositions(integerStep);
241234
const fractionalPositions = getMarkPositions(fractionalStep);
242-
243-
// Fractional step should have roughly half the marks of integer step
244-
expect(fractionalPositions.length).toBeLessThan(
245-
integerPositions.length
246-
);
247-
expect(fractionalPositions.length).toBeLessThanOrEqual(
248-
Math.ceil(integerPositions.length / 2) + 1
249-
);
250-
251-
// Both should be valid steps
252-
expect(areAllMarksValidSteps(integerStep, 0, 1)).toBe(true);
235+
expect(fractionalPositions.length).toBe(21);
253236
expect(areAllMarksValidSteps(fractionalStep, 0, 0.5)).toBe(true);
254237
});
255238

@@ -413,5 +396,28 @@ describe('Dynamic Slider Mark Density', () => {
413396
expect(positions[0]).toBe(0);
414397
expect(positions[positions.length - 1]).toBe(1000000000);
415398
});
399+
400+
test('does not have all marks labeled as "2k" for range 1952 to 2007', () => {
401+
const marks = sanitizeMarks({
402+
min: 1952,
403+
max: 2007,
404+
marks: undefined,
405+
step: undefined,
406+
sliderWidth: 365,
407+
});
408+
409+
// Get all the label values (not the keys)
410+
const labels = Object.values(marks);
411+
412+
// Count unique labels
413+
const uniqueLabels = new Set(labels);
414+
415+
// Should have more than one unique label
416+
expect(uniqueLabels.size).toBeGreaterThan(1);
417+
418+
// Should NOT have all labels be "2k"
419+
const allLabels2k = labels.every(label => label === '2k');
420+
expect(allLabels2k).toBe(false);
421+
});
416422
});
417423
});

0 commit comments

Comments
 (0)