Skip to content

Fix graticule lines vanishing across most projections - #44

Merged
nighthawk merged 1 commit into
mainfrom
fix/vertical-graticule-lines
Sep 20, 2026
Merged

nighthawk merged 1 commit into
mainfrom
fix/vertical-graticule-lines

Conversation

@nighthawk

@nighthawk nighthawk commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Why

Whole families of graticule lines were rendering as nothing. Meridians disappeared in Mercator/Equirectangular/Gall-Peters, parallels disappeared at reference longitude 0, and EqualEarth and NaturalEarth were each dropping 18 lines. These looked like separate bugs in separate projections; they turned out to share one root cause.

wrapShift documents that it relies on consecutive points being close together in projected space — but the interpolator deliberately breaks that invariant. It stops subdividing the moment a span projects straight, which is right and cheap for drawing, yet leaves adjacent points arbitrarily far apart wherever the projection is locally linear over a long span. Wrap detection then can't tell "linear over a long span" from "jumped across a seam", and splits lines that never crossed anything.

The fix restores the invariant instead of loosening the heuristic that depends on it. A new Interpolator.densify pass runs after the existing one and bisects any pair further apart than a quarter of the projection size, stopping at the same floor the main pass uses — so a real discontinuity converges to a tight jump straddling the seam, which is exactly the signal wrap detection wants, while a smooth span closes up in a couple of levels.

The subtlety worth knowing about: bisection has to be path-aware. An edge from 170° to -170° means the 20° hop across the antimeridian, so splitting it in raw longitude lands on lon 0 — the long way round — and erases the crossing. An edge spanning a full 360° is the exception, meaning "all the way round", and keeps its long path rather than collapsing to zero.

No API change. The commit message has the mechanics.

Evidence

Measured over a full graticule (36 meridians + 17 parallels) across eight projections at two reference longitudes, diffed against main:

reference projection blank lines before → after
0° Equirectangular / Mercator / GallPeters 17 / 21 / 17 → 0
0° EqualEarth / NaturalEarth 18 / 18 → 0
0° Danseiji I 2 → 0
77.5° Mercator 4 → 0
both Cassini, Danseiji IV 0 → 0 (byte-identical)

97 previously-blank lines now draw, and no line got shorter anywhere. Real content is barely affected because its vertices are already dense — the EqualEarth continent bench moves 10695 → 10700 vertices.

GraticuleCoverageTests is added as a standing guard. It measures drawn length rather than point count, because the failures that shipped were lines rendering as zero-length pieces: invisible, but not absent enough for a point-count check to notice.

Test plan

Automated: 60/60 pass. The one golden-file update is testBasicSVGGeneration, where M 100,100 L 300,100 gains a collinear midpoint at 200 — same geometry, one extra vertex from densify.

Manual, in Cassini (load Examples/Data/graticule-2.geojson as a layer):

  • Cycle through every projection and confirm meridians and parallels both render. Mercator, Equirectangular and Gall-Peters are the ones that were worst.
  • Drag the reference-longitude slider through 0°. Parallels used to vanish entirely at exactly 0; they should stay put now.
  • Check Cassini specifically — far-hemisphere meridians should still fold around the top and bottom edges rather than drawing straight down the map.
  • Confirm continent rendering is unchanged (this touches the shared projection path, not just graticules).

Known, not addressed here

Two pre-existing defects this does not fix, both verified present on main:

  • A meridian lying exactly on a bezier projection's seam (reference ± 180°) is discarded wholesale, because pointInPolygon accepts an exact vertex match but treats a point on an edge as outside. Excluded and documented in GraticuleCoverageTests; fixing it changes polygon clipping semantics on a documented hot path, so it wants its own change.
  • Danseiji IV shows straight runs at the notches in its outline. Untouched by this change (its output is byte-identical), so it's a distinct cause — most likely the concave multi-crossing path in boundarySplit.

Independent of #43; both branch off main.

🤖 Generated with Claude Code

`wrapShift` documents that it "relies on the post-interpolation invariant
that consecutive points are within Interpolator.maxDiff in projected
space" — but `interpolateInto` deliberately breaks it. Its shortcut stops
subdividing the moment the projected midpoint agrees with the
straight-line midpoint, which is right and cheap for drawing, yet leaves
adjacent output points arbitrarily far apart wherever the projection is
locally linear over a long span. Wrap detection then can't tell "linear
over a long span" from "jumped across a seam", and splits lines that
never crossed anything.

Two families of graticule line were disappearing because of it:

- Full-height meridians in Mercator, Equirectangular and Gall-Peters.
  A two-point meridian projects to endpoints further apart in y than
  half the projection height, so it read as a top-to-bottom seam
  crossing and was split into a top piece and a bottom piece with an
  empty middle.

- Full-circumference parallels at reference longitude 0. Endpoints land
  exactly on ±pi and the midpoint (lon 0) projects to x=0, which *is*
  their straight-line midpoint, so the shortcut fires immediately and
  emits no interior points at all. `boundarySplit` then split a
  two-point line whose endpoints already sat on the seam, and the
  zero-length exit/entry rays collapsed it into two empty pieces.

Add `Interpolator.densify`, run over the output of the existing pass. It
bisects any pair further apart than a quarter of the projection size on
either axis — comfortably under `wrapShift`'s half-size trigger — and
stops at the same unprojected floor the main pass uses, so a genuine
discontinuity converges to a tight jump straddling the seam rather than
recursing forever. That jump is exactly the signal wrap detection wants.

Bisection is path-aware. An edge from 170° to -170° means the 20° hop
across the antimeridian, so splitting it in raw longitude (which lands on
lon 0, the long way round) would erase the crossing; an edge spanning a
full 360° is the exception, meaning "all the way round", and keeps its
long path rather than collapsing to zero.

Measured over a full graticule (36 meridians + 17 parallels) across eight
projections at two reference longitudes: 97 previously-blank lines now
draw, no line got shorter, and Cassini and Danseiji IV come out
byte-identical. Equirectangular/Mercator/GallPeters go from 17/21/17
blank lines to none at reference 0; EqualEarth and NaturalEarth from 18
each to none. Those last two were long-standing defects that happen to
share this root cause. Real content is barely affected since its
vertices are already dense — the EqualEarth continent bench moves
10695 -> 10700 vertices.

Adds GraticuleCoverageTests as a standing guard. It measures drawn
length rather than point count, because the failures that shipped were
lines rendering as zero-length pieces: invisible, but not absent enough
for a point-count check to catch.

One pre-existing defect is excluded and documented there: a meridian
lying exactly on a bezier projection's seam is discarded wholesale
because `pointInPolygon` accepts an exact vertex match but treats a point
on an edge as outside. Fixing that changes polygon clipping semantics on
a documented hot path, so it wants its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nighthawk nighthawk self-assigned this Sep 20, 2026
@nighthawk nighthawk added the bug Something isn't working label Sep 20, 2026
@nighthawk
nighthawk merged commit f67f37c into main Sep 20, 2026
8 checks passed
@nighthawk
nighthawk deleted the fix/vertical-graticule-lines branch September 20, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant