From 0fec058b5c49c1e0ef279a005fc06bb7bec246c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 18:21:34 +0000 Subject: [PATCH 01/10] fix: use d3.ticks for linear y-axis tick generation Replaces evenly-spaced ticks across the raw zoomed domain with d3.ticks, which picks values that are multiples of {1,2,5}*10^k. Fixes ugly endpoints like -27.7 / 52.7 on fan charts and other linear scales. forceTickCount callers keep the evenly-spaced path since d3.ticks treats its count argument as a hint, not a guarantee. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- .../src/utils/charts/__tests__/axis.test.ts | 76 +++++++++++++++++++ front_end/src/utils/charts/axis.ts | 73 ++++++++++-------- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/front_end/src/utils/charts/__tests__/axis.test.ts b/front_end/src/utils/charts/__tests__/axis.test.ts index 55c036be7f..dd6269b243 100644 --- a/front_end/src/utils/charts/__tests__/axis.test.ts +++ b/front_end/src/utils/charts/__tests__/axis.test.ts @@ -230,6 +230,82 @@ describe("generateScale", () => { }); }); + describe("d3.ticks linear scaling", () => { + it("produces nice ticks for an awkward range like [-27.7, 20]", () => { + // Given: a question with an ugly range_min that previously yielded + // endpoints like -27.7 and 52.7 with the evenly-spaced algorithm. + const params = { + displayType: QuestionType.Numeric, + axisLength: 200, + direction: ScaleDirection.Vertical, + domain: [-27.7, 20] as [number, number], + zoomedDomain: [-27.7, 20] as [number, number], + scaling: { + range_min: -27.7, + range_max: 20, + zero_point: null, + }, + }; + + // When + const scale = generateScale(params); + + // Then: labeled (major) ticks should be nice — multiples of a step + // that itself is a member of {1,2,5} * 10^k. + const labeledTicks = scale.ticks.filter( + (t) => scale.tickFormat(t) !== "" + ); + expect(labeledTicks.length).toBeGreaterThan(1); + + const first = labeledTicks[0] as number; + const second = labeledTicks[1] as number; + const last = labeledTicks[labeledTicks.length - 1] as number; + const step = second - first; + + const exponent = Math.floor(Math.log10(Math.abs(step))); + const mantissa = step / Math.pow(10, exponent); + expect([1, 2, 5]).toContain(Math.round(mantissa)); + + const eps = Math.abs(step) * 1e-9; + expect(Math.abs(first - Math.round(first / step) * step)).toBeLessThan( + eps + ); + expect(Math.abs(last - Math.round(last / step) * step)).toBeLessThan(eps); + }); + + it("produces nice ticks for [0, 100]", () => { + // Given + const params = { + displayType: QuestionType.Numeric, + axisLength: 200, + direction: ScaleDirection.Vertical, + domain: [0, 100] as [number, number], + zoomedDomain: [0, 100] as [number, number], + scaling: { + range_min: 0, + range_max: 100, + zero_point: null, + }, + }; + + // When + const scale = generateScale(params); + + // Then: labeled (major) ticks should match one of d3's expected outputs. + const labeledTicks = scale.ticks.filter( + (t) => scale.tickFormat(t) !== "" + ); + const valid1 = [0, 25, 50, 75, 100]; + const valid2 = [0, 20, 40, 60, 80, 100]; + const arraysEqual = (a: number[], b: number[]) => + a.length === b.length && a.every((v, i) => v === b[i]); + + expect( + arraysEqual(labeledTicks, valid1) || arraysEqual(labeledTicks, valid2) + ).toBe(true); + }); + }); + describe("graph force ticks count", () => { it("helper should return specified number of ticks", () => { // Given diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index be42e7e11d..93c80eb77c 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -619,37 +619,48 @@ export function generateScale({ majorTicks.push(minorTicks.at(-1) ?? 1); } else if (isNil(zeroPoint)) { // Linear Scaling - // Typical scaling, evenly spaced ticks - // choose optimal tick count to minimize the number - // of significant digits in the tick labels - const majorTickCount = forceTickCount - ? forceTickCount - : findOptimalTickCount(rangeMin, rangeMax, 4, maxLabelCount); - majorTicks = range(0, majorTickCount).map( - (i) => - Math.round( - (zoomedDomainMin + - (i / (majorTickCount - 1)) * (zoomedDomainMax - zoomedDomainMin)) * - 1000000 - ) / 1000000 - ); - const minorTicksPerMajor = findOptimalTickCount( - rangeMin, - rangeMin + (rangeMax - rangeMin) * (majorTicks[1] ?? 1 / majorTickCount), - direction === "horizontal" ? 4 : 2, - direction === "horizontal" ? 10 : 5 - ); - const minorTickCount = forceTickCount - ? forceTickCount - : (majorTickCount - 1) * minorTicksPerMajor + 1; - minorTicks = range(0, minorTickCount).map( - (i) => - Math.round( - (zoomedDomainMin + - (i / (minorTickCount - 1)) * (zoomedDomainMax - zoomedDomainMin)) * - 1000000 - ) / 1000000 - ); + if (forceTickCount) { + // forceTickCount must be honored exactly. d3.ticks treats its count + // argument as a hint, so fall back to evenly-spaced ticks here. + const majorTickCount = forceTickCount; + majorTicks = range(0, majorTickCount).map( + (i) => + Math.round( + (zoomedDomainMin + + (i / (majorTickCount - 1)) * + (zoomedDomainMax - zoomedDomainMin)) * + 1000000 + ) / 1000000 + ); + const minorTickCount = forceTickCount; + minorTicks = range(0, minorTickCount).map( + (i) => + Math.round( + (zoomedDomainMin + + (i / (minorTickCount - 1)) * + (zoomedDomainMax - zoomedDomainMin)) * + 1000000 + ) / 1000000 + ); + } else { + // Use d3.ticks() to pick nice round numbers (multiples of {1,2,5}*10^k) + // within the zoomed domain. + majorTicks = d3 + .ticks(zoomedDomainMin, zoomedDomainMax, maxLabelCount) + .map((x) => Math.round(x * 1000000) / 1000000); + const minorTicksPerMajor = findOptimalTickCount( + rangeMin, + rangeMin + + (rangeMax - rangeMin) * (majorTicks[1] ?? 1 / majorTicks.length), + direction === "horizontal" ? 4 : 2, + direction === "horizontal" ? 10 : 5 + ); + const minorTickCount = + Math.max(majorTicks.length - 1, 1) * minorTicksPerMajor + 1; + minorTicks = d3 + .ticks(zoomedDomainMin, zoomedDomainMax, minorTickCount) + .map((x) => Math.round(x * 1000000) / 1000000); + } } else { // Logarithmic Scaling // Labeled ticks are not spaced evenly, but rather rounded to the nearby From b9ad2ad5e0f07b4981c085c4a3dba2355938af43 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 19:22:15 +0000 Subject: [PATCH 02/10] fix: use d3.ticks against range space and treat forceTickCount as a hint The previous attempt picked nice values in domain space [0, 1] and only ran when forceTickCount was unset. In production, callers always pass forceTickCount and the actual data range (e.g. [-27.7, 20]) is in rangeScaling, so neither path produced nice display values. Now, for numeric linear scales, generate d3.ticks() in range space and map each result back to domain coordinates. forceTickCount becomes a hint to d3.ticks(); date axes (which would get ugly raw timestamps from d3.ticks) keep evenly-spaced behavior. Also: - minor-tick density is now derived from the major step in range units rather than an absolute tick position - evenly-spaced fallback clamps count to >= 2 to avoid divide-by-zero - new tests cover the forceTickCount + numeric and domain != range cases Co-authored-by: leonardthethird --- .../src/utils/charts/__tests__/axis.test.ts | 94 ++++++++++++++++++ front_end/src/utils/charts/axis.ts | 95 +++++++++++++------ 2 files changed, 160 insertions(+), 29 deletions(-) diff --git a/front_end/src/utils/charts/__tests__/axis.test.ts b/front_end/src/utils/charts/__tests__/axis.test.ts index dd6269b243..64e1726a60 100644 --- a/front_end/src/utils/charts/__tests__/axis.test.ts +++ b/front_end/src/utils/charts/__tests__/axis.test.ts @@ -330,6 +330,100 @@ describe("generateScale", () => { // Then expect(scale.ticks.length).toBe(FORCE_TICK_COUNT); }); + + it("treats forceTickCount as a hint for numeric linear scales so labels stay nice", () => { + // Regression: previously forceTickCount on a numeric axis bypassed + // d3.ticks and produced evenly-spaced labels like + // [-27.7, -15.78, -3.85, 8.08, 20]. We now expect d3-nice values. + const params = { + displayType: QuestionType.Numeric, + axisLength: 400, + direction: ScaleDirection.Vertical, + domain: [0, 1] as [number, number], + zoomedDomain: [0, 1] as [number, number], + scaling: { + range_min: -27.7, + range_max: 20, + zero_point: null, + }, + forceTickCount: 5, + }; + + const scale = generateScale(params); + + const labeledRangeValues = scale.ticks + .filter((t) => scale.tickFormat(t) !== "") + .map((t) => -27.7 + t * (20 - -27.7)); + + expect(labeledRangeValues.length).toBeGreaterThan(1); + const step = + (labeledRangeValues[1] as number) - (labeledRangeValues[0] as number); + const exponent = Math.floor(Math.log10(Math.abs(step))); + const mantissa = Math.abs(step) / Math.pow(10, exponent); + expect([1, 2, 5]).toContain(Math.round(mantissa)); + + const eps = Math.abs(step) * 1e-6; + labeledRangeValues.forEach((v) => { + expect(Math.abs(v - Math.round(v / step) * step)).toBeLessThan(eps); + }); + }); + + it("treats forceTickCount as a hint when domain is normalized to [0, 1]", () => { + // Production-like: domain is [0, 1], range is the actual data range. + // The labeled display values must be d3-nice in range space. + const params = { + displayType: QuestionType.Numeric, + axisLength: 400, + direction: ScaleDirection.Vertical, + domain: [0, 1] as [number, number], + zoomedDomain: [0, 1] as [number, number], + scaling: { + range_min: 0, + range_max: 100, + zero_point: null, + }, + forceTickCount: 5, + }; + + const scale = generateScale(params); + + const labeledDomainTicks = scale.ticks.filter( + (t) => scale.tickFormat(t) !== "" + ); + const labeledRangeValues = labeledDomainTicks.map((t) => 0 + t * 100); + + // d3.ticks(0, 100, 5) returns [0, 20, 40, 60, 80, 100] or + // [0, 25, 50, 75, 100] — both are acceptable nice outputs. + const valid1 = [0, 25, 50, 75, 100]; + const valid2 = [0, 20, 40, 60, 80, 100]; + const arraysEqual = (a: number[], b: number[]) => + a.length === b.length && + a.every((v, i) => Math.abs(v - (b[i] as number)) < 1e-6); + expect( + arraysEqual(labeledRangeValues, valid1) || + arraysEqual(labeledRangeValues, valid2) + ).toBe(true); + }); + + it("does not throw when forceTickCount is 1 on a date axis", () => { + // Edge case: forceTickCount === 1 used to produce NaN ticks via i/0. + const params = { + displayType: QuestionType.Date, + axisLength: 200, + direction: ScaleDirection.Vertical, + domain: [0, 1] as [number, number], + zoomedDomain: [0, 1] as [number, number], + scaling: { + range_min: 1678838400, + range_max: 1778803200, + zero_point: null, + }, + forceTickCount: 1, + }; + + const scale = generateScale(params); + scale.ticks.forEach((t) => expect(Number.isNaN(t)).toBe(false)); + }); }); }); diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 93c80eb77c..1911411d1c 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -619,47 +619,84 @@ export function generateScale({ majorTicks.push(minorTicks.at(-1) ?? 1); } else if (isNil(zeroPoint)) { // Linear Scaling - if (forceTickCount) { - // forceTickCount must be honored exactly. d3.ticks treats its count - // argument as a hint, so fall back to evenly-spaced ticks here. - const majorTickCount = forceTickCount; - majorTicks = range(0, majorTickCount).map( + if (displayType === QuestionType.Numeric) { + // Pick mathematically "nice" ticks (multiples of {1,2,5} * 10^k) in + // the actual data range, then map them back to domain coordinates. + // Doing this in range space matters when domain is normalized [0, 1] + // but the data range is something like [-27.7, 20]: nice values in + // domain space unscale to ugly display values, so we have to compute + // niceness against the values users will see. + // forceTickCount, when supplied, is treated as a hint to d3.ticks(); + // the resulting count may differ by ±1-2 in exchange for nicer values. + const tickCountHint = forceTickCount ?? maxLabelCount; + const zoomedRangeMin = scaleInternalLocation( + unscaleNominalLocation(zoomedDomainMin, domainScaling), + rangeScaling + ); + const zoomedRangeMax = scaleInternalLocation( + unscaleNominalLocation(zoomedDomainMax, domainScaling), + rangeScaling + ); + + const niceMajorRangeTicks = d3.ticks( + zoomedRangeMin, + zoomedRangeMax, + tickCountHint + ); + const rangeToDomain = (v: number) => + scaleInternalLocation( + unscaleNominalLocation(v, rangeScaling), + domainScaling + ); + majorTicks = niceMajorRangeTicks.map( + (v) => Math.round(rangeToDomain(v) * 1000000) / 1000000 + ); + + // Minor tick density is based on the major step in range units, not + // an absolute tick position. + const majorRangeStep = + niceMajorRangeTicks.length >= 2 + ? (niceMajorRangeTicks[1] as number) - + (niceMajorRangeTicks[0] as number) + : zoomedRangeMax - zoomedRangeMin; + const minorTicksPerMajor = findOptimalTickCount( + 0, + majorRangeStep, + direction === "horizontal" ? 4 : 2, + direction === "horizontal" ? 10 : 5 + ); + const minorTickCount = + Math.max(majorTicks.length - 1, 1) * minorTicksPerMajor + 1; + minorTicks = d3 + .ticks(zoomedRangeMin, zoomedRangeMax, minorTickCount) + .map((v) => Math.round(rangeToDomain(v) * 1000000) / 1000000); + } else if (forceTickCount) { + // Non-numeric linear scales (e.g. date axes) need exact, evenly-spaced + // ticks across the zoomed domain — d3.ticks would produce ugly raw + // timestamps. Clamp count to >= 2 to avoid divide-by-zero. + const count = Math.max(2, forceTickCount); + const evenlySpaced = range(0, count).map( (i) => Math.round( (zoomedDomainMin + - (i / (majorTickCount - 1)) * - (zoomedDomainMax - zoomedDomainMin)) * + (i / (count - 1)) * (zoomedDomainMax - zoomedDomainMin)) * 1000000 ) / 1000000 ); - const minorTickCount = forceTickCount; - minorTicks = range(0, minorTickCount).map( + majorTicks = evenlySpaced; + minorTicks = evenlySpaced.slice(); + } else { + const count = Math.max(2, maxLabelCount); + const evenlySpaced = range(0, count).map( (i) => Math.round( (zoomedDomainMin + - (i / (minorTickCount - 1)) * - (zoomedDomainMax - zoomedDomainMin)) * + (i / (count - 1)) * (zoomedDomainMax - zoomedDomainMin)) * 1000000 ) / 1000000 ); - } else { - // Use d3.ticks() to pick nice round numbers (multiples of {1,2,5}*10^k) - // within the zoomed domain. - majorTicks = d3 - .ticks(zoomedDomainMin, zoomedDomainMax, maxLabelCount) - .map((x) => Math.round(x * 1000000) / 1000000); - const minorTicksPerMajor = findOptimalTickCount( - rangeMin, - rangeMin + - (rangeMax - rangeMin) * (majorTicks[1] ?? 1 / majorTicks.length), - direction === "horizontal" ? 4 : 2, - direction === "horizontal" ? 10 : 5 - ); - const minorTickCount = - Math.max(majorTicks.length - 1, 1) * minorTicksPerMajor + 1; - minorTicks = d3 - .ticks(zoomedDomainMin, zoomedDomainMax, minorTickCount) - .map((x) => Math.round(x * 1000000) / 1000000); + majorTicks = evenlySpaced; + minorTicks = evenlySpaced.slice(); } } else { // Logarithmic Scaling From 0ec6a8ee7dfa4a28751b7d239d01b158b46b84b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 19:35:42 +0000 Subject: [PATCH 03/10] test: stop forceTickCount-as-hint test from straddling 10^k boundary The test reconstructs range values from domain ticks rounded to 6 decimals, so the recovered step picks up floating-point dust (9.9999711 instead of 10). Snap log10(step) to the nearest integer when it's already within 1e-3 of one, and widen the alignment tolerance to match the same precision floor. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- front_end/src/utils/charts/__tests__/axis.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/front_end/src/utils/charts/__tests__/axis.test.ts b/front_end/src/utils/charts/__tests__/axis.test.ts index 64e1726a60..312a0e6a96 100644 --- a/front_end/src/utils/charts/__tests__/axis.test.ts +++ b/front_end/src/utils/charts/__tests__/axis.test.ts @@ -356,13 +356,21 @@ describe("generateScale", () => { .map((t) => -27.7 + t * (20 - -27.7)); expect(labeledRangeValues.length).toBeGreaterThan(1); + // Domain ticks are rounded to 6 decimals before being mapped back to + // range values, so the reconstructed step picks up floating-point dust + // (e.g. 9.9999711 instead of 10). Snap log10(step) to the nearest + // integer when it's right at a 10^k boundary so we bucket correctly. const step = (labeledRangeValues[1] as number) - (labeledRangeValues[0] as number); - const exponent = Math.floor(Math.log10(Math.abs(step))); + const log = Math.log10(Math.abs(step)); + const exponent = + Math.abs(log - Math.round(log)) < 1e-3 + ? Math.round(log) + : Math.floor(log); const mantissa = Math.abs(step) / Math.pow(10, exponent); expect([1, 2, 5]).toContain(Math.round(mantissa)); - const eps = Math.abs(step) * 1e-6; + const eps = Math.abs(step) * 1e-3; labeledRangeValues.forEach((v) => { expect(Math.abs(v - Math.round(v / step) * step)).toBeLessThan(eps); }); From 015e977ae73916fa39c312a0114990c9d0e4b874 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 20:42:25 +0000 Subject: [PATCH 04/10] fix: cap nice-tick count so labels don't overlap on tight axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d3.ticks treats its count argument as a hint and picks the nicest step regardless of the resulting tick count. For ranges like [-10, 8] with target=3 it picks step 2, returning 10 ticks — which overflows small feed-card axes and produces overlapping labels. Add a niceTicksAtMost helper that walks from maxCount down until d3.ticks returns a count that fits, turning the parameter into a ceiling instead of a hint. Trades occasional sparser axes for the guarantee that labels never overlap. Minor ticks (gridlines) are unaffected — density there is desirable. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- .../src/utils/charts/__tests__/axis.test.ts | 53 +++++++++++++++---- front_end/src/utils/charts/axis.ts | 21 +++++++- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/front_end/src/utils/charts/__tests__/axis.test.ts b/front_end/src/utils/charts/__tests__/axis.test.ts index 312a0e6a96..bb28d0c993 100644 --- a/front_end/src/utils/charts/__tests__/axis.test.ts +++ b/front_end/src/utils/charts/__tests__/axis.test.ts @@ -400,17 +400,48 @@ describe("generateScale", () => { ); const labeledRangeValues = labeledDomainTicks.map((t) => 0 + t * 100); - // d3.ticks(0, 100, 5) returns [0, 20, 40, 60, 80, 100] or - // [0, 25, 50, 75, 100] — both are acceptable nice outputs. - const valid1 = [0, 25, 50, 75, 100]; - const valid2 = [0, 20, 40, 60, 80, 100]; - const arraysEqual = (a: number[], b: number[]) => - a.length === b.length && - a.every((v, i) => Math.abs(v - (b[i] as number)) < 1e-6); - expect( - arraysEqual(labeledRangeValues, valid1) || - arraysEqual(labeledRangeValues, valid2) - ).toBe(true); + // forceTickCount is treated as a ceiling, not a hint. d3 can't fit 5 + // ticks across [0, 100] with a step in {1, 2, 5} * 10^k, so we + // accept a coarser result like [0, 50, 100] (step 50, 3 ticks). + expect(labeledRangeValues.length).toBeGreaterThanOrEqual(2); + expect(labeledRangeValues.length).toBeLessThanOrEqual(5); + const step = + (labeledRangeValues[1] as number) - (labeledRangeValues[0] as number); + const log = Math.log10(Math.abs(step)); + const exponent = + Math.abs(log - Math.round(log)) < 1e-3 + ? Math.round(log) + : Math.floor(log); + const mantissa = Math.abs(step) / Math.pow(10, exponent); + expect([1, 2, 5]).toContain(Math.round(mantissa)); + }); + + it("treats forceTickCount as a ceiling, not a soft hint", () => { + // Regression for the [-10, 8] feed-card case: d3.ticks(-10, 8, 3) + // picks step 2 and returns 10 ticks, which overflowed the small + // axis and produced overlapping labels. The ceiling guarantees + // the labeled count never exceeds forceTickCount. + const params = { + displayType: QuestionType.Numeric, + axisLength: 150, + direction: ScaleDirection.Vertical, + domain: [0, 1] as [number, number], + zoomedDomain: [0, 1] as [number, number], + scaling: { + range_min: -10, + range_max: 8, + zero_point: null, + }, + forceTickCount: 3, + }; + + const scale = generateScale(params); + const labeledTicks = scale.ticks.filter( + (t) => scale.tickFormat(t) !== "" + ); + + expect(labeledTicks.length).toBeLessThanOrEqual(3); + expect(labeledTicks.length).toBeGreaterThanOrEqual(2); }); it("does not throw when forceTickCount is 1 on a date axis", () => { diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 1911411d1c..a3edb48961 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -399,6 +399,25 @@ function getSigFigCost(value: number, logarithmic: boolean = false): number { return mantissa.length; } +/** + * Like d3.ticks, but treats the count as a ceiling instead of a hint. + * d3.ticks picks the nicest step size and returns however many ticks + * fit — which can exceed the requested count and overflow tight axes. + * Walking down from maxCount until the result fits guarantees count + * <= maxCount while preserving the {1,2,5} * 10^k step guarantee. + */ +function niceTicksAtMost( + start: number, + stop: number, + maxCount: number +): number[] { + for (let c = Math.max(2, maxCount); c >= 2; c--) { + const t = d3.ticks(start, stop, c); + if (t.length <= maxCount) return t; + } + return d3.ticks(start, stop, 2); +} + /** * Take a range's min and max and finds the tick spacing that minimizes * the average number of significant digits in the tick values. @@ -638,7 +657,7 @@ export function generateScale({ rangeScaling ); - const niceMajorRangeTicks = d3.ticks( + const niceMajorRangeTicks = niceTicksAtMost( zoomedRangeMin, zoomedRangeMax, tickCountHint From 346ddc7813b8930ea218662e3a9ef4c284c27f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 21:12:40 +0000 Subject: [PATCH 05/10] fix: pick nice display labels on log axes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log branch generated evenly-spaced positions in the warped domain, scaled them to display values, then rounded to fewest sig figs. That preserved the endpoints verbatim — so range_max=52.7 showed up as a literal "52.7" tick label. Apply the same pattern as the linear branch: call niceTicksAtMost in display (range) space and unscale each result back to domain coordinates. Drop minimumSignificantRounding and its sig-fig-cost search loop entirely. Minor ticks still subdivide each major interval evenly in display space — that's what gives the gridlines their log-spaced look. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- .../src/utils/charts/__tests__/axis.test.ts | 40 +++++++ front_end/src/utils/charts/axis.ts | 111 ++++-------------- 2 files changed, 63 insertions(+), 88 deletions(-) diff --git a/front_end/src/utils/charts/__tests__/axis.test.ts b/front_end/src/utils/charts/__tests__/axis.test.ts index bb28d0c993..be7331f4ec 100644 --- a/front_end/src/utils/charts/__tests__/axis.test.ts +++ b/front_end/src/utils/charts/__tests__/axis.test.ts @@ -201,6 +201,46 @@ describe("generateScale", () => { .filter((label) => label !== ""); expect(formattedLabels.length).toBeGreaterThan(2); }); + + it("picks nice display labels on positive log axes (no awkward endpoints)", () => { + // Regression for the [1, 52.7] fan card: the previous algorithm + // preserved the range_max as a tick literally, producing labels + // like "52.7". Picking nice values in display space avoids that. + const params = { + displayType: QuestionType.Numeric, + axisLength: 200, + direction: ScaleDirection.Vertical, + domain: [0, 1] as [number, number], + zoomedDomain: [0, 1] as [number, number], + scaling: { + range_min: 1, + range_max: 52.7, + zero_point: 0, + }, + forceTickCount: 5, + }; + + const scale = generateScale(params); + const labels = scale.ticks + .map((t) => scale.tickFormat(t)) + .filter((s) => s !== ""); + + expect(labels.length).toBeGreaterThanOrEqual(2); + expect(labels).not.toContain("52.7"); + + // The labels should be evenly spaced (in display space) by a nice + // step in {1, 2, 5} * 10^k. + const values = labels.map(Number); + values.forEach((v) => expect(Number.isFinite(v)).toBe(true)); + const step = (values[1] as number) - (values[0] as number); + const log = Math.log10(Math.abs(step)); + const exponent = + Math.abs(log - Math.round(log)) < 1e-3 + ? Math.round(log) + : Math.floor(log); + const mantissa = Math.abs(step) / Math.pow(10, exponent); + expect([1, 2, 5]).toContain(Math.round(mantissa)); + }); }); describe("graph ticks formatting", () => { diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index a3edb48961..4e9e88db56 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -301,62 +301,6 @@ export function generateTimestampXScale( }; } -/** - * Takes an array of values and rounds them to the minimum - * number of significant digits such that no two values - * are rounded to the same value. - * Values must be sorted in ascending order. - */ -function minimumSignificantRounding(values: number[]): number[] { - const roundedValues: number[] = []; - const EPS = 1e-12; - - function sigfigRound(val: number, sigfigs: number): number { - if (val === 0) return 0; // Special case for zero - const divisor = 10 ** (sigfigs - Math.floor(Math.log10(Math.abs(val))) - 1); - return Math.round(val * divisor) / divisor; - } - // TODO: more intelligent ordering for rounded tick value selection - // TODO: add dextrous rounding reflecting sig fig cost algorithm - values.forEach((value, i) => { - if (i === 0 || i === values.length - 1) { - roundedValues.push(value); - return; - } - const prevValue = values[i - 1]; - const nextValue = values[i + 1]; - - if (prevValue == null || nextValue == null) { - roundedValues.push(value); - return; - } - - // flat/duplicate guards - if ( - Math.abs(value - prevValue) < EPS || - Math.abs(nextValue - value) < EPS - ) { - roundedValues.push(value); - return; - } - - let candidate = value; - for (let digits = 1; digits <= 12; digits++) { - candidate = sigfigRound(value, digits); - const denom = value - prevValue; - if ( - Math.abs(denom) < EPS || - Math.abs((value - candidate) / denom) < 0.2 - ) { - break; - } - } - roundedValues.push(candidate); - }); - - return roundedValues; -} - function getSigFigCost(value: number, logarithmic: boolean = false): number { const absValue = Math.abs(value); // take the length of mantissa of the exponential rounded @@ -719,46 +663,37 @@ export function generateScale({ } } else { // Logarithmic Scaling - // Labeled ticks are not spaced evenly, but rather rounded to the nearby - // values that have the fewest significant digits - // Then, minor ticks are spaced evenly in real space, showcasing the - // strength of the logarithmic scaling - const minLabelCount = forceTickCount ?? Math.ceil(maxLabelCount / 2) + 1; - let bestTicks: number[] = []; - let bestAvgDigits = Infinity; - for (let i = maxLabelCount; i >= minLabelCount; i--) { - const unscaledTargets = Array.from( - { length: i }, - (_, j) => - zoomedDomainMin + - ((zoomedDomainMax - zoomedDomainMin) * (j * 1)) / (i - 1) - ); - const scaledTargets = unscaledTargets.map((x) => - scaleInternalLocation(x, rangeScaling) - ); - const roundedScaledTargets = minimumSignificantRounding(scaledTargets); - const sigFigCosts = roundedScaledTargets.map((x) => - getSigFigCost(x, true) - ); - const avgDigits = sigFigCosts.reduce((sum, cost) => sum + cost, 0) / i; - if (avgDigits < bestAvgDigits) { - bestAvgDigits = avgDigits; - bestTicks = roundedScaledTargets; - } - } - majorTicks = bestTicks.map( + // Pick nice round numbers in display (range) space, then unscale each + // back to domain coordinates so they land at the right positions on + // the warped axis. The previous approach picked evenly-spaced warped + // positions and rounded them to fewest sig figs, but kept the + // endpoints verbatim — which produced ugly labels like 52.7. + const tickCountHint = forceTickCount ?? maxLabelCount; + const displayMin = scaleInternalLocation(zoomedDomainMin, rangeScaling); + const displayMax = scaleInternalLocation(zoomedDomainMax, rangeScaling); + const niceMajorRangeTicks = niceTicksAtMost( + Math.min(displayMin, displayMax), + Math.max(displayMin, displayMax), + tickCountHint + ); + + majorTicks = niceMajorRangeTicks.map( (x) => Math.round(unscaleNominalLocation(x, rangeScaling) * 1000000) / 1000000 ); + // Minor ticks subdivide each major interval evenly in display space, + // then unscale to domain — that's what makes the gridlines appear + // logarithmically spaced and showcases the warp. const tickCount = forceTickCount ? forceTickCount : (maxLabelCount - 1) * (direction === "horizontal" ? 10 : 3) + 1; - const minorTicksPerMajorInterval = (tickCount - 1) / (maxLabelCount - 1); + const minorTicksPerMajorInterval = + (tickCount - 1) / Math.max(1, niceMajorRangeTicks.length - 1); minorTicks = majorTicks.slice(); - range(0, bestTicks.length - 1).forEach((i) => { - const prevMajor = bestTicks.at(i) ?? 0; - const nextMajor = bestTicks.at(i + 1) ?? 1; + range(0, niceMajorRangeTicks.length - 1).forEach((i) => { + const prevMajor = niceMajorRangeTicks.at(i) ?? 0; + const nextMajor = niceMajorRangeTicks.at(i + 1) ?? 1; const step = (nextMajor - prevMajor) / minorTicksPerMajorInterval; for (let j = 0; j < minorTicksPerMajorInterval - 1; j++) { const newMinorTick = prevMajor + (j + 1) * step; From 2ca09633e73a091c844d3ae720586153d616288a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 00:20:05 +0000 Subject: [PATCH 06/10] fix: cap labeled ticks at 4 (and at least 2) on linear/log axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caps the d3.ticks count hint at 4 in both branches that pick nice labels. Combined with niceTicksAtMost as a ceiling and a c=1 fallback that always returns at least the endpoints, the visible label count is now bounded between 2 and 4 — small cards no longer overcrowd, and very tall axes don't stretch to a wall of labels. Also fixes a latent bug where minor ticks didn't always include the majors: with the tighter cap, d3 picks different steps for the two calls, so the major positions can be missing from the minor array — and tickFormat would then filter their labels away. Merge majors into minor explicitly to keep that invariant. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- .../src/utils/charts/__tests__/axis.test.ts | 8 +++++-- front_end/src/utils/charts/axis.ts | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/front_end/src/utils/charts/__tests__/axis.test.ts b/front_end/src/utils/charts/__tests__/axis.test.ts index be7331f4ec..a0eed9dc6f 100644 --- a/front_end/src/utils/charts/__tests__/axis.test.ts +++ b/front_end/src/utils/charts/__tests__/axis.test.ts @@ -335,8 +335,12 @@ describe("generateScale", () => { const labeledTicks = scale.ticks.filter( (t) => scale.tickFormat(t) !== "" ); - const valid1 = [0, 25, 50, 75, 100]; - const valid2 = [0, 20, 40, 60, 80, 100]; + // With the global max-4 cap, d3.ticks(0, 100, 4) returns 6 ticks + // (step 20) which exceeds the cap; the helper falls back to c=3 → + // [0, 50, 100]. [0, 25, 50, 75, 100] and the 6-tick options aren't + // achievable under the cap. + const valid1 = [0, 50, 100]; + const valid2 = [0, 25, 50, 75, 100]; const arraysEqual = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]); diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 4e9e88db56..79902b3974 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -355,11 +355,13 @@ function niceTicksAtMost( stop: number, maxCount: number ): number[] { - for (let c = Math.max(2, maxCount); c >= 2; c--) { + for (let c = Math.max(1, maxCount); c >= 1; c--) { const t = d3.ticks(start, stop, c); - if (t.length <= maxCount) return t; + if (t.length >= 2 && t.length <= maxCount) return t; } - return d3.ticks(start, stop, 2); + // Degenerate range, or nothing fits — keep the endpoints so callers + // always get at least two ticks. + return start === stop ? [start] : [start, stop]; } /** @@ -591,7 +593,7 @@ export function generateScale({ // niceness against the values users will see. // forceTickCount, when supplied, is treated as a hint to d3.ticks(); // the resulting count may differ by ±1-2 in exchange for nicer values. - const tickCountHint = forceTickCount ?? maxLabelCount; + const tickCountHint = Math.min(4, forceTickCount ?? maxLabelCount); const zoomedRangeMin = scaleInternalLocation( unscaleNominalLocation(zoomedDomainMin, domainScaling), rangeScaling @@ -630,9 +632,16 @@ export function generateScale({ ); const minorTickCount = Math.max(majorTicks.length - 1, 1) * minorTicksPerMajor + 1; - minorTicks = d3 + const denseMinor = d3 .ticks(zoomedRangeMin, zoomedRangeMax, minorTickCount) .map((v) => Math.round(rangeToDomain(v) * 1000000) / 1000000); + // Major ticks must always be a subset of minor — otherwise their + // labels get filtered out at render time (tickFormat checks major + // membership). The d3.ticks count for minor can lock onto a step + // that doesn't include the major positions, so merge explicitly. + minorTicks = Array.from(new Set([...majorTicks, ...denseMinor])).sort( + (a, b) => a - b + ); } else if (forceTickCount) { // Non-numeric linear scales (e.g. date axes) need exact, evenly-spaced // ticks across the zoomed domain — d3.ticks would produce ugly raw @@ -668,7 +677,7 @@ export function generateScale({ // the warped axis. The previous approach picked evenly-spaced warped // positions and rounded them to fewest sig figs, but kept the // endpoints verbatim — which produced ugly labels like 52.7. - const tickCountHint = forceTickCount ?? maxLabelCount; + const tickCountHint = Math.min(4, forceTickCount ?? maxLabelCount); const displayMin = scaleInternalLocation(zoomedDomainMin, rangeScaling); const displayMax = scaleInternalLocation(zoomedDomainMax, rangeScaling); const niceMajorRangeTicks = niceTicksAtMost( From 78237444b639e79b3fb2a474cb1f680e68c31b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 20:49:58 +0000 Subject: [PATCH 07/10] fix: expand tick bounds outward and honor cap on alwaysShowTicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two QA findings: 1. Some axes still showed an awkward number of labels (e.g. 4.1 through 4.6, six labels) because alwaysShowTicks tells tickFormat to label every value in the array, and the array is the dense minor list — the 4-cap only constrained majors. Return majors as the tick array when alwaysShowTicks is set so the cap actually applies to what the chart renders. 2. Other axes were missing an obvious nice-boundary label (e.g. -5 on a chart spanning roughly -3 to 5, or 1.0 on a chart spanning roughly 1.05 to 1.4). d3.ticks ceil/floors inside the bounds you give it, so when zoomedRange starts at 1.05 it never considers 1.0. Expand the bounds outward to the nearest step boundary before calling d3.ticks — same trick d3.scaleLinear().nice() uses internally. Note: tick labels still won't render if Victory's yDomain doesn't include them. Chart-side yDomain widening is a likely follow-up if the new ticks come back invisible during QA. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- front_end/src/utils/charts/axis.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 79902b3974..333d6ef425 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -356,7 +356,13 @@ function niceTicksAtMost( maxCount: number ): number[] { for (let c = Math.max(1, maxCount); c >= 1; c--) { - const t = d3.ticks(start, stop, c); + // Expand the bounds outward to the nearest step boundary so d3 can + // pick endpoints like 1.0 even when the data only goes to 1.05. + // Equivalent to what d3.scaleLinear().nice() does internally. + const step = d3.tickStep(start, stop, c); + const niceStart = Math.floor(start / step) * step; + const niceStop = Math.ceil(stop / step) * step; + const t = d3.ticks(niceStart, niceStop, c); if (t.length >= 2 && t.length <= maxCount) return t; } // Degenerate range, or nothing fits — keep the endpoints so callers @@ -878,7 +884,11 @@ export function generateScale({ // } return { - ticks: minorTicks, + // alwaysShowTicks tells the chart to label every tick value verbatim + // (it bypasses the major/minor filter in tickFormat). Returning the + // major array honors the cap-of-4 in that case; returning the dense + // minor array would let callers like group_chart blow past the cap. + ticks: alwaysShowTicks ? majorTicks : minorTicks, tickFormat: tickFormat, cursorFormat: cursorFormat, }; From 84c5d3e23b5e6c2dac3776c17eed9fed5b17e057 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 21:21:47 +0000 Subject: [PATCH 08/10] revert: undo bound expansion in niceTicksAtMost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA showed most charts dropped to a single visible tick after the expansion landed. The expansion produces ticks outside Victory's yDomain (which is the data-driven zoomedDomain), so the outer ticks get clipped and only the middle ones survive — and on tight ranges that's just one tick. Keeping the alwaysShowTicks → return majors change. To get nice boundary ticks back (e.g. 1.0 below data starting at 1.05) we'll need the chart-side yDomain widening that was option #3 in the original proposal. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- front_end/src/utils/charts/axis.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 333d6ef425..3f6aafdf4b 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -356,13 +356,7 @@ function niceTicksAtMost( maxCount: number ): number[] { for (let c = Math.max(1, maxCount); c >= 1; c--) { - // Expand the bounds outward to the nearest step boundary so d3 can - // pick endpoints like 1.0 even when the data only goes to 1.05. - // Equivalent to what d3.scaleLinear().nice() does internally. - const step = d3.tickStep(start, stop, c); - const niceStart = Math.floor(start / step) * step; - const niceStop = Math.ceil(stop / step) * step; - const t = d3.ticks(niceStart, niceStop, c); + const t = d3.ticks(start, stop, c); if (t.length >= 2 && t.length <= maxCount) return t; } // Degenerate range, or nothing fits — keep the endpoints so callers From f9315d313a5cb9e50aff100a986bf3f72f15df75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 23:09:47 +0000 Subject: [PATCH 09/10] fix: widen yDomain to encompass tick range on group/fan charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a question uses a parameterized log warp (zero_point set far from the range, like the percent-change questions with zero_point=-100), the data clusters in a small slice of [0, 1] domain space and generateTimeSeriesYDomain auto-zooms tight around it. niceTicksAtMost generates ticks for the full display range, which then live mostly outside that tight yDomain — Victory clips them, and the chart shows zero or one labels. Add widenDomainToTicks(domain, ticks) and use it in the two charts that hit this pattern (group_chart, fan_chart). Probability-axis charts (multiple_choice_chart, continuous_area_chart) already pin their yDomain to [0, 1.x] so widening would be a no-op there. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- front_end/src/components/charts/fan_chart.tsx | 8 +++++++- front_end/src/components/charts/group_chart.tsx | 8 +++++++- front_end/src/utils/charts/axis.ts | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/front_end/src/components/charts/fan_chart.tsx b/front_end/src/components/charts/fan_chart.tsx index 633af04d31..7e37c7be05 100644 --- a/front_end/src/components/charts/fan_chart.tsx +++ b/front_end/src/components/charts/fan_chart.tsx @@ -48,6 +48,7 @@ import { getAxisLeftPadding, getAxisRightPadding, getTickLabelFontSize, + widenDomainToTicks, } from "@/utils/charts/axis"; import { calculateCharWidth, @@ -881,6 +882,11 @@ function buildChartData({ } }); + // Widen yDomain to encompass every tick — log-warped questions cluster + // their data in a small slice and the auto-zoomed yDomain would clip + // tick labels that fall outside it. + const yDomain = widenDomainToTicks(finalZoom, yScale.ticks); + return { communityLines, communityAreas, @@ -889,7 +895,7 @@ function buildChartData({ resolutionPoints, emptyPoints, yScale, - yDomain: finalZoom, + yDomain, }; } diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 72c1f40d88..33a750fe22 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -44,6 +44,7 @@ import { generateTimestampXScale, getAxisRightPadding, getTickLabelFontSize, + widenDomainToTicks, } from "@/utils/charts/axis"; import { getResolutionPoint } from "@/utils/charts/resolution"; import { scaleInternalLocation, unscaleNominalLocation } from "@/utils/math"; @@ -1062,7 +1063,12 @@ function buildChartData({ alwaysShowTicks: true, }); - return { xScale, yScale, graphs, xDomain, yDomain: zoomedYDomain }; + // Widen yDomain to encompass every tick — log-warped questions cluster + // their data in a small slice and the auto-zoomed yDomain would clip + // tick labels that fall outside it. + const yDomain = widenDomainToTicks(zoomedYDomain, yScale.ticks); + + return { xScale, yScale, graphs, xDomain, yDomain }; } // Define a custom "X" symbol function diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 3f6aafdf4b..2afdcb5752 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -364,6 +364,21 @@ function niceTicksAtMost( return start === stop ? [start] : [start, stop]; } +/** + * Returns a domain that contains both the original data domain and every + * tick in the supplied array. Use to widen Victory's yDomain so that + * generateScale's tick labels actually land inside the chart's drawing + * area — important for log-warped questions where the data clusters in + * a small slice and the auto-zoomed yDomain would otherwise clip ticks. + */ +export function widenDomainToTicks( + domain: Tuple, + ticks: number[] +): Tuple { + if (ticks.length === 0) return domain; + return [Math.min(domain[0], ...ticks), Math.max(domain[1], ...ticks)]; +} + /** * Take a range's min and max and finds the tick spacing that minimizes * the average number of significant digits in the tick values. From 5736eb444d7c16510ae993734ceafee139df9150 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 00:43:39 +0000 Subject: [PATCH 10/10] fix: prefer slight-over-cap nice ticks to ugly raw endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback in niceTicksAtMost was returning [start, stop] when no c in [1, maxCount] produced a result with 2 to maxCount ticks. For ranges like [-47.6, 38.9] with maxCount=3, d3's nice step sizes land at 4 ticks (step 20) or 1 tick (step 50/100) — never 2 or 3. That falling-through to raw endpoints produced labels like "38.9" and "-47.6" — exactly the ugly numbers we set out to avoid. Try once more with c=1..4 looking for any result with 2 to 4 nice ticks; a slight cap overshoot is much better than literal data range endpoints. Stays within the global "min 2, max 4" envelope. https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL --- front_end/src/utils/charts/axis.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/front_end/src/utils/charts/axis.ts b/front_end/src/utils/charts/axis.ts index 2afdcb5752..4b2588b452 100644 --- a/front_end/src/utils/charts/axis.ts +++ b/front_end/src/utils/charts/axis.ts @@ -359,8 +359,15 @@ function niceTicksAtMost( const t = d3.ticks(start, stop, c); if (t.length >= 2 && t.length <= maxCount) return t; } - // Degenerate range, or nothing fits — keep the endpoints so callers - // always get at least two ticks. + // No c produced a count in [2, maxCount] — typically because the nice + // step sizes near maxCount land us at 1 tick on one side and >maxCount + // on the other. Try counts up to the global cap (4) looking for any + // nice result with at least 2 ticks; better to slightly exceed the + // local cap than fall back to ugly raw endpoints. + for (let c = 1; c <= 4; c++) { + const t = d3.ticks(start, stop, c); + if (t.length >= 2 && t.length <= 4) return t; + } return start === stop ? [start] : [start, stop]; }