Fix graticule lines vanishing across most projections - #44
Merged
Merged
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
wrapShiftdocuments 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.densifypass 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: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.
GraticuleCoverageTestsis 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, whereM 100,100 L 300,100gains a collinear midpoint at 200 — same geometry, one extra vertex from densify.Manual, in Cassini (load
Examples/Data/graticule-2.geojsonas a layer):Known, not addressed here
Two pre-existing defects this does not fix, both verified present on
main:pointInPolygonaccepts an exact vertex match but treats a point on an edge as outside. Excluded and documented inGraticuleCoverageTests; fixing it changes polygon clipping semantics on a documented hot path, so it wants its own change.boundarySplit.Independent of #43; both branch off
main.🤖 Generated with Claude Code