Skip to content

Use d3.ticks() for nicer linear scale tick generation#4689

Draft
leonardthethird wants to merge 6 commits intomainfrom
claude/fix-axis-ticks-YBbS6
Draft

Use d3.ticks() for nicer linear scale tick generation#4689
leonardthethird wants to merge 6 commits intomainfrom
claude/fix-axis-ticks-YBbS6

Conversation

@leonardthethird
Copy link
Copy Markdown
Contributor

@leonardthethird leonardthethird commented May 4, 2026

Summary

Improved linear scale tick generation by leveraging d3's ticks() function to produce "nice" round numbers instead of evenly-spaced ticks. This results in more readable axis labels that follow the pattern of multiples of {1, 2, 5} × 10^k.

Key Changes

  • Replaced evenly-spaced tick algorithm with d3.ticks(): For linear scaling without a forced tick count, the code now uses d3.ticks() to generate major ticks that are mathematically "nice" (round numbers that are easier to read and interpret)
  • Preserved forceTickCount behavior: When forceTickCount is specified, the original evenly-spaced algorithm is used to honor the exact count requirement, since d3.ticks() treats count as a hint rather than a guarantee
  • Updated minor tick generation: Minor ticks are now also generated using d3.ticks() for consistency, with the count calculated based on the spacing of major ticks
  • Added comprehensive test coverage: Two new test cases verify the behavior:
    • Validates that awkward ranges like [-27.7, 20] produce nice ticks with proper step sizes
    • Confirms that standard ranges like [0, 100] produce expected d3 tick outputs

Implementation Details

  • The change only affects the linear scaling branch (when zeroPoint is null)
  • Floating-point precision is maintained by rounding to 6 decimal places
  • The maxLabelCount parameter is passed to d3.ticks() to control the approximate number of ticks generated
  • Minor tick count calculation now uses Math.max(majorTicks.length - 1, 1) to handle edge cases safely

https://claude.ai/code/session_017gYZRiw2Sq41iLGq7KPJzL

Summary by CodeRabbit

  • Improvements

    • Enhanced chart axis tick generation for more accurate and consistent scaling across various numeric ranges.
  • Tests

    • Added comprehensive test coverage for linear-scaled axis tick generation with edge cases.

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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 628677c6-355c-4274-96b8-3b1122052a26

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR replaces linear-scaling tick generation in generateScale to leverage d3.ticks() for major ticks when forceTickCount is unset, adds explicit handling for forceTickCount with evenly spaced ticks, and introduces comprehensive tests validating the new d3-based "nice ticks" behavior for numeric domains.

Changes

Axis Tick Generation Refactor

Layer / File(s) Summary
Production Logic
front_end/src/utils/charts/axis.ts
generateScale linear-scaling path now uses d3.ticks() for major tick generation bounded by maxLabelCount, computes minor tick count via findOptimalTickCount, and generates minor ticks via d3.ticks() with that count. When forceTickCount is provided, both major and minor ticks are created as evenly spaced values across zoomedDomain with counts equal to forceTickCount.
Test Coverage
front_end/src/utils/charts/__tests__/axis.test.ts
New describe("d3.ticks linear scaling") suite validates that labeled ticks follow d3's "nice ticks" convention: for domain [-27.7, 20], ticks have mantissa in {1,2,5} and align to step within epsilon; for [0, 100], labeled ticks match one of two expected d3 sequences.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Metaculus/metaculus#4520: Modifies generateScale rounding precision for tick values, touching the same function undergoing tick generation refactoring.

Suggested reviewers

  • ncarazon

Poem

🐰 D3 ticks dance in patterns nice,
No more manual spacing splice,
Tick counts flow from d3's grace,
Tests ensure each holds its place!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: replacing the tick generation algorithm with d3.ticks() for linear scales to produce nicer, rounder ticks following standard conventions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-axis-ticks-YBbS6

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@front_end/src/utils/charts/axis.ts`:
- Around line 651-659: The minor-tick density calculation incorrectly uses
majorTicks[1] as an absolute position; change it to use the major step width
instead. Compute a majorStep from majorTicks (e.g., majorStep = (majorTicks[1] -
majorTicks[0]) if available, else (rangeMax - rangeMin) / Math.max(1,
majorTicks.length - 1)), then call findOptimalTickCount(rangeMin, rangeMin +
majorStep, ...) to derive minorTicksPerMajor and keep the minorTickCount
calculation using Math.max(majorTicks.length - 1, 1) * minorTicksPerMajor + 1;
handle the case where majorTicks has fewer than 2 entries by falling back to the
computed step.
- Around line 622-644: The evenly-spaced tick branch fails for forceTickCount
=== 1 due to division by (count - 1); update the logic that builds majorTicks
and minorTicks (the block using forceTickCount, majorTickCount/minorTickCount,
zoomedDomainMin/zoomedDomainMax and range) to special-case count === 1 (or clamp
count to >= 2) so you don't compute i / 0—e.g., when forceTickCount === 1
produce a single rounded tick (use the midpoint of zoomedDomainMin and
zoomedDomainMax or a defined endpoint) and otherwise keep the existing
interpolation and rounding; also add a regression test that exercises
forceTickCount = 1 alongside existing force-count tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c44f129d-472c-4549-99a1-4e679b011b16

📥 Commits

Reviewing files that changed from the base of the PR and between 4441c94 and 0fec058.

📒 Files selected for processing (2)
  • front_end/src/utils/charts/__tests__/axis.test.ts
  • front_end/src/utils/charts/axis.ts

Comment thread front_end/src/utils/charts/axis.ts Outdated
Comment on lines +622 to +644
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
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle forceTickCount === 1 before dividing by count - 1.

With forceTickCount set to 1, Lines 630 and 640 evaluate i / (count - 1) as 0 / 0, so both tick arrays become [NaN]. That breaks the exact-count path on a valid boundary input. Please special-case 1 (or clamp to >= 2) before building the evenly spaced ticks, and add a regression test next to the new force-count coverage.

Suggested fix
     if (forceTickCount) {
       // forceTickCount must be honored exactly. d3.ticks treats its count
       // argument as a hint, so fall back to evenly-spaced ticks here.
+      if (forceTickCount === 1) {
+        const tick = Math.round(zoomedDomainMin * 1000000) / 1000000;
+        majorTicks = [tick];
+        minorTicks = [tick];
+        return {
+          ticks: minorTicks,
+          tickFormat,
+          cursorFormat,
+        };
+      }
+
       const majorTickCount = forceTickCount;
       majorTicks = range(0, majorTickCount).map(
         (i) =>
           Math.round(
             (zoomedDomainMin +
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
);
if (forceTickCount) {
// forceTickCount must be honored exactly. d3.ticks treats its count
// argument as a hint, so fall back to evenly-spaced ticks here.
if (forceTickCount === 1) {
const tick = Math.round(zoomedDomainMin * 1000000) / 1000000;
majorTicks = [tick];
minorTicks = [tick];
return {
ticks: minorTicks,
tickFormat,
cursorFormat,
};
}
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
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front_end/src/utils/charts/axis.ts` around lines 622 - 644, The evenly-spaced
tick branch fails for forceTickCount === 1 due to division by (count - 1);
update the logic that builds majorTicks and minorTicks (the block using
forceTickCount, majorTickCount/minorTickCount, zoomedDomainMin/zoomedDomainMax
and range) to special-case count === 1 (or clamp count to >= 2) so you don't
compute i / 0—e.g., when forceTickCount === 1 produce a single rounded tick (use
the midpoint of zoomedDomainMin and zoomedDomainMax or a defined endpoint) and
otherwise keep the existing interpolation and rounding; also add a regression
test that exercises forceTickCount = 1 alongside existing force-count tests.

Comment thread front_end/src/utils/charts/axis.ts Outdated
Comment on lines +651 to +659
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Base minor-tick density on the major step, not the second tick value.

majorTicks[1] is an absolute position, but this calculation treats it like a normalized interval. For a domain like [0, 100], the second labeled tick can be 20, which turns the comparison span into 2000 instead of 20. That makes findOptimalTickCount() choose minor density from the wrong range whenever the domain is not normalized.

Suggested fix
-      const minorTicksPerMajor = findOptimalTickCount(
-        rangeMin,
-        rangeMin +
-          (rangeMax - rangeMin) * (majorTicks[1] ?? 1 / majorTicks.length),
+      const majorStep =
+        majorTicks.length > 1
+          ? majorTicks[1] - majorTicks[0]
+          : zoomedDomainMax - zoomedDomainMin;
+      const domainSpan = domainMax - domainMin;
+      const scaledMajorStep =
+        domainSpan === 0
+          ? 0
+          : ((rangeMax - rangeMin) * majorStep) / domainSpan;
+      const minorTicksPerMajor = findOptimalTickCount(
+        rangeMin,
+        rangeMin + scaledMajorStep,
         direction === "horizontal" ? 4 : 2,
         direction === "horizontal" ? 10 : 5
       );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front_end/src/utils/charts/axis.ts` around lines 651 - 659, The minor-tick
density calculation incorrectly uses majorTicks[1] as an absolute position;
change it to use the major step width instead. Compute a majorStep from
majorTicks (e.g., majorStep = (majorTicks[1] - majorTicks[0]) if available, else
(rangeMax - rangeMin) / Math.max(1, majorTicks.length - 1)), then call
findOptimalTickCount(rangeMin, rangeMin + majorStep, ...) to derive
minorTicksPerMajor and keep the minorTickCount calculation using
Math.max(majorTicks.length - 1, 1) * minorTicksPerMajor + 1; handle the case
where majorTicks has fewer than 2 entries by falling back to the computed step.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

🚀 Preview Environment

Your preview environment is ready!

Resource Details
🌐 Preview URL https://metaculus-pr-4689-claude-fix-axis-ticks-ybbs6-preview.mtcl.cc
📦 Docker Image ghcr.io/metaculus/metaculus:claude-fix-axis-ticks-ybbs6-2ca0963
🗄️ PostgreSQL NeonDB branch preview/pr-4689-claude-fix-axis-ticks-ybbs6
Redis Fly Redis mtc-redis-pr-4689-claude-fix-axis-ticks-ybbs6

Details

  • Commit: ac78bdc9d60cb2f4b2eca97eab78ae9107c9e9c4
  • Branch: claude/fix-axis-ticks-YBbS6
  • Fly App: metaculus-pr-4689-claude-fix-axis-ticks-ybbs6

ℹ️ Preview Environment Info

Isolation:

  • PostgreSQL and Redis are fully isolated from production
  • Each PR gets its own database branch and Redis instance
  • Changes pushed to this PR will trigger a new deployment

Limitations:

  • Background workers and cron jobs are not deployed in preview environments
  • If you need to test background jobs, use Heroku staging environments

Cleanup:

  • This preview will be automatically destroyed when the PR is closed

@leonardthethird leonardthethird marked this pull request as draft May 4, 2026 19:00
@leonardthethird
Copy link
Copy Markdown
Contributor Author

@claude on preview environment, ticks are still displaying identically to existing code on prod

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 4, 2026

Claude finished @leonardthethird's task in 2m 9s —— View job


Root cause: the d3.ticks() branch is essentially never reached

The current change splits the linear-scaling path into two branches:

if (forceTickCount) {
  // evenly-spaced (identical to old behavior)
} else {
  // d3.ticks() — the new "nice ticks" path
}

The problem: virtually every chart in the codebase passes forceTickCount, so the new path is dead code in practice.

Confirmed callers that always pass it:

File Value
numeric_timeline.tsx:170 (the main timeline chart on every numeric question page) forFeedPage ? 3 : 5
multiple_choice_chart.tsx:858 forFeedPage ? 3 : 5
group_chart.tsx:1061 isEmbedded ? 5 : forFeedPage ? 3 : 5
fan_chart.tsx:862 forceTickCount ?? (forFeedPage ? 3 : 5)
axis.ts:923 (getYMeta) gridlines (default 5)

The only callers that can pass undefined are continuous_area_chart.tsx and minified_continuous_area_chart.tsx (they take it from props). On the preview env, the y-axis you're seeing is rendered via numeric_timeline.tsx → buildNumericChartData → helpers.ts:246 → generateScale with forceTickCount: 5, so it falls into the evenly-spaced branch and produces the same ugly ticks as prod (e.g. [-27.7, -15.775, -3.85, 8.075, 20]).

What to change

You need to actually let d3.ticks() run on the main chart. A few options:

  1. Treat forceTickCount as a hint (recommended): pass it as the count argument to d3.ticks(). d3 will produce roughly that many nice ticks. Layout tolerates ±1–2 ticks fine, and you get the round numbers you actually want. Only fall back to evenly-spaced when something truly needs an exact count (e.g. getYMeta's gridlines, where the caller draws a fixed grid).

    } else if (isNil(zeroPoint)) {
      const tickCountHint = forceTickCount ?? maxLabelCount;
      majorTicks = d3
        .ticks(zoomedDomainMin, zoomedDomainMax, tickCountHint)
        .map((x) => Math.round(x * 1000000) / 1000000);
      // …minor ticks via d3.ticks() as today
    }
  2. Drop forceYTickCount from numeric_timeline.tsx:170 and let maxLabelCount (which is already derived from axisLength) drive things. Same effect for the main chart, more surgical, but doesn't help the other chart types.

  3. Introduce a separate exactTickCount flag for the few callers that genuinely need exact counts (getYMeta, possibly the small-tile feed charts where layout is tight) and switch everyone else over.

Option 1 is the smallest change and matches the spirit of the PR. The risk is that on small feed-card charts (forFeedPage, forceTickCount: 3) d3 might return 4 or 5 ticks and crowd the label. If that bites, layer in option 3.

Want me to push option 1? If yes, I'll also add a regression test that exercises the forceTickCount + linear combination so we catch this in tests next time.
· claude/fix-axis-ticks-YBbS6

@leonardthethird
Copy link
Copy Markdown
Contributor Author

@claude yes

Copy link
Copy Markdown
Contributor Author

Expected with this PR — the fan chart on group cards passes forceTickCount: 5 (3 on feed pages), and this PR intentionally keeps the forceTickCount path on the existing evenly-spaced logic. d3.ticks() treats its count argument as a hint, not a hard constraint, so swapping it in for the fan chart would break the exact-count contract.

The improvement only shows up on linear-scale charts that don't force a count. Fan charts are a planned follow-up — we'll need to either drop the hard count requirement there or use a Talbot-Lin-style algorithm that can target a count more reliably.


Generated by Claude Code

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 4, 2026

Claude finished @leonardthethird's task in 9m 51s —— View job


Done — pushed b9ad2ad

While digging in, I found that option 1 alone wasn't enough: even when d3.ticks() did run, the original PR was calling it on the domain (typically [0, 1]) instead of the actual data range. So d3 picked nice values like 0, 0.2, 0.4, but those still unscale to ugly display values like -27.7, -18.16, -8.62 whenever domain != range (which is essentially always in production). That explains why the preview env looked unchanged.

What changed in axis.ts:620-700

  • Numeric linear branch now picks d3-nice ticks in range space (the actual data values), then maps them back to domain coordinates via scaleInternalLocation/unscaleNominalLocation. So a question with range: [-27.7, 20] and domain: [0, 1] now produces labels like -25, -20, -15, …, 20.
  • forceTickCount is a hint to d3.ticks() for numeric scales — d3 may return ±1–2 from the requested count in exchange for nice values.
  • Date axes keep evenly-spaced behavior, since d3.ticks on raw timestamps produces ugly numerical (not date-aware) values. Date callers passing forceTickCount still get exact counts.
  • Fixed CodeRabbit's two findings: minor-tick density now uses the major step, not majorTicks[1] as an absolute position; evenly-spaced fallback clamps count >= 2 so forceTickCount === 1 no longer produces NaN.

Tests added in axis.test.ts

  • forceTickCount on numeric linear → labels follow d3 {1,2,5}*10^k step (the regression you flagged)
  • Production-shape input (domain=[0,1], range=[0,100]) → labeled values match d3 nice output, not evenly-spaced 25-step
  • forceTickCount === 1 on date → no NaN ticks

Heads up

  • I couldn't run jest locally (the harness blocks shell exec for that), so the test outcomes here are reasoned-through, not observed. CI will be the source of truth.
  • For very small charts (consumer cards with forceTickCount: 2 or 3), d3 may return 3 ticks instead of 2, which can crowd tiny tiles. If you see that on the preview env, the cleanest follow-up is option 3 from the earlier comment: introduce an exactTickCount prop for those callers and keep forceTickCount as the hint.

Todo

  • Pass forceTickCount as a hint into d3.ticks()
  • Compute nice ticks in range space, map back to domain
  • Keep date axes on evenly-spaced ticks
  • Fix minor-tick step bug + forceTickCount === 1 divide-by-zero
  • Add regression tests
  • Commit and push
    · claude/fix-axis-ticks-YBbS6

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 <leonardthethird@users.noreply.github.com>
@claude claude Bot temporarily deployed to testing_env May 4, 2026 19:22 Inactive
@claude claude Bot temporarily deployed to testing_env May 4, 2026 19:22 Inactive
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
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
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants