Skip to content

Danseiji plus better splits#40

Merged
nighthawk merged 15 commits into
mainfrom
danseiji-plus-better-splits
May 9, 2026
Merged

Danseiji plus better splits#40
nighthawk merged 15 commits into
mainfrom
danseiji-plus-better-splits

Conversation

@nighthawk
Copy link
Copy Markdown
Member

@nighthawk nighthawk commented May 9, 2026

Addresses part of #3, closes #10 and #21

Danseiji III

claude and others added 12 commits May 8, 2026 23:44
…/lon

Adds a required `inverse(_:)` method to the `Projection` protocol and
implements it for all 8 in-tree projections, plus a high-level
`coordinate(at:size:zoomTo:insets:coordinateSystem:)` API that turns a
screen-pixel `Point` into a `GeoJSON.Position?`. EqualEarth and
NaturalEarth use Newton-Raphson; the rest are closed-form. Out-of-image
clicks (e.g. off the globe of an Orthographic map) return nil via a new
`MapBounds.contains` helper.

https://claude.ai/code/session_01CsF6py8qHa7t2ZQpmsaHh7
Adds a bottom status bar to the macOS Cassini sample that shows the
geographic coordinate under the cursor, computed via the new
`Projection.coordinate(at:size:zoomTo:insets:coordinateSystem:)` API.
Clicking the map locks the coordinate; the status bar then offers Copy
(to clipboard, returns to live mode) and Discard (returns to live mode
without copying), with ⌘C and Esc shortcuts. Exposes
`GeoDrawer.zoomTo` so the example can mirror the drawer's projected
zoom rect when computing inverses.

https://claude.ai/code/session_01CsF6py8qHa7t2ZQpmsaHh7
Adds all six Danseiji map projections (I–VI) by Justin Kunimune as a new
optional SPM target, GeoProjectorDanseiji. Kept separate from the core
GeoProjector library so consumers that don't want the ~6.9 MB of mesh data
don't pay the cost.

- Vendor the six danseijiX.csv mesh files under
  Sources/GeoProjectorDanseiji/Resources/, unchanged from upstream
  (https://github.com/jkunimune/Map-Projections, MIT licensed).
- Port the forward-projection algorithm from upstream Danseiji.java
  (mesh-cell barycentric interpolation). Inverse projection is intentionally
  not exposed — the Projection protocol only requires forward.
- Expose Projections.DanseijiI … Projections.DanseijiVI, matching the
  one-struct-per-projection pattern used by NaturalEarth, EqualEarth, etc.
- Add DanseijiVariant — a sibling of ProjectionMode for clients that want
  Codable, enum-driven selection across the six variants.
- Cache parsed mesh data per variant under an NSLock so initialisers stay
  cheap.
- Wire the Cassini example app to expose the six new variants in its picker.

Closes part of #3.
…i0gH' into claude/add-danseiji-projection-c9Abs
The merged inverse-projection feature added a required `inverse(_:)` method
to the `Projection` protocol, which Danseiji structs need to implement.

- Extend the CSV parser to read the `pixels` section (phi, lam samples on a
  rectangular grid) plus the edge polygon's bounding box, both of which were
  previously skipped.
- Port `DanseijiProjection.inverse` from upstream `Danseiji.java`: ray-cast
  point-in-polygon test against the edge, then bilinear interpolation of the
  pixel grid in 3-D Cartesian space (so meridians don't blow up at the poles)
  before converting back to (lon, lat).
- Apply the reference longitude shift on the way out.
- Add inverse round-trip tests over a coarse lat/lon grid for all six variants
  (loose tolerance — ~3° — given the bilinear interpolation step).
Replace the willWrap-based splitter with a generic boundary-crossing
detector that consults each projection's mapBounds in projected radian
space. Polygons whose exterior crosses the projection edge are now split
at the crossing and closed along the boundary, instead of drawn as long
chords across (or beyond) the visible map.

Fixes the "huge sweeping triangles / horizontal stripes" rendering
glitches on Danseiji III & IV (whose bezier outlines are interrupted),
the orthographic limb chord, and the antimeridian-crossing chord on
cylindrical projections (Antarctica's exterior wraps -180° to +180°).

Removes Projection.willWrap entirely — the boundary-aware split subsumes
its job in the renderer, and the per-projection overrides existed only
to feed it. Projection.point(for:size:…) drops its tupled Bool return
in the same change (callers were only the renderer).

- Sources/GeoProjector/MapBounds+Contains.swift: new public helpers
  firstIntersection(from:to:projectionSize:) and
  boundaryArc(from:to:projectionSize:) covering rectangle, ellipse, and
  bezier outlines. contains(_:projectionSize:) made public.
- Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift: new helper file with
  boundarySplit and boundarySplitClosed.
- Sources/GeoDrawer/GeoDrawer.swift: convertLine rewritten on top of the
  new helpers; new convertPolygon variant closes each split piece along
  the projection boundary so polygon fill follows the edge.
- Tests/GeoDrawerTests/BoundarySplitTests.swift: covers the geometric
  primitives plus end-to-end splits through Equirectangular, Orthographic,
  and a synthetic interrupted bezier.

Antarctica's residual unfilled gap near the south pole (the GeoJSON
stops at lat -85.6° and never reaches -90°) is left as a follow-up.
…ctions

Three independent visual bugs all shared the same root cause family —
the boundary splitter not handling antimeridian/antipode wrap cleanly,
plus floating-point edge cases.

- `GeoDrawer+BoundarySplit`: detect wrap-length jumps in every prev/curr
  case (inside↔inside, inside↔outside, outside↔inside) and shift the
  far-side point onto the near-side before computing the boundary
  intersection. Also detect paired exit/entry crossings for concave
  bezier notches, and merge first/last raw pieces when the input ring
  is closed and starts/ends inside the bounds.
- `MapBounds.allIntersections`: generalises `firstIntersection` to
  return every crossing with a de-dup pass for segment-through-vertex
  cases. Powers the concave-notch split.
- `AzimuthalEquidistant.project`: clamp `cos(c)` to `[-1, 1]` before
  `acos` and reject points within `1e-6` of the antipode where
  `k = c / sin(c)` blows up.

Tests cover the regressions:
- equirectangular and bezier antimeridian splits (closed polygon and
  bare line)
- bezier concave-notch split
- Equal Earth + Natural Earth real-continents at non-zero longitude
  references
- Azimuthal antipode rejection and grid-wide finiteness

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The slider-drag and projection-switch lag in the example app was driven by
re-projecting all continents per slider tick. A few hot paths were doing
significantly more work than they needed to:

- `Interpolator.interpolate` projected `a` and `b` at every recursion step,
  even though both are already projected by the caller (and re-projected
  again by the parent recursion). Refactor to a tail-recursive helper that
  threads `aProj`/`bProj` through, so each unprojected midpoint is
  projected exactly once. Also drops the `[c_proj].compactMap.map`
  allocation and the `lefty + middy + righty` array concatenations in
  favour of a single in-place output buffer.

- `GeoDrawer.projectLine` was projecting every interior polygon vertex
  twice (once at the end of pair (i-1, i), once at the start of pair
  (i, i+1)). Project the whole vertex list once and feed those
  projections into the new `Interpolator.interpolateInto`. As a
  side-effect, the SVG output no longer contains duplicated vertices
  back-to-back, so the SVG snapshot tests are updated.

- `Point.distanceSquared` and `Point.halfway` now do `dx * dx` instead of
  `pow(_, 2)` and direct multiplication instead of division. Tagged
  `@inline(__always)` since they sit in the deepest hot path. EqualEarth's
  `poly9`/`poly8` likewise pull `pow` out in favour of explicit products.

- `MapBounds.pointInPolygon` swaps array subscripts for
  `withUnsafeBufferPointer`, which removes the per-element bounds checks
  that dominate Debug builds. Hoists the loop-invariant `p.x`/`p.y` to
  locals.

- `boundarySplit` no longer pays for the `mapBounds.allIntersections`
  search on every (true, true) pair when the boundary is convex
  (`.rectangle`, `.ellipse`, or convex-bezier projections like Equal
  Earth and Natural Earth). A convex shape can't be exited and re-entered
  by a single straight segment with both endpoints inside, so the
  multi-crossing detector is only useful for the genuinely concave
  Danseiji notches. Convexity is detected once per `boundarySplit` call
  via a new `MapBounds.isConvex` helper.

Bench (98 polygons × 5 reps, world continents):
- Equal Earth     706ms → 136ms Debug   |   17.5ms → 4.6ms Release
- Orthographic    129ms →  35ms Debug   |   14.0ms → 4.8ms Release

Tests: 55 pass (3 SVG snapshots updated for de-duplicated vertices,
2 new `PerfBench` cases).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings in three commits from `main`:
- c043d34 Add `inverse` function (#38)
- 1fe35c6 Add tests for SVG zoom, plus add a bunch to .gitignore (#37)
- 1a7b4f3 Disable unused reference sliders in Cassini example (#39)

Conflicts resolved by keeping this branch's versions, since the
`inverse` work and `MapBounds.contains` helper that landed on `main`
were already present here (via the earlier Danseiji and boundary-split
commits) — the branch versions go further:

- `MapBounds+Contains.swift`: keep this branch's superset, which adds
  `firstIntersection`, `allIntersections`, `boundaryArc`, and
  `isConvex`. `contains` stays `public` so cross-module callers in
  `GeoDrawer` can use it.
- `Projection.swift`: drop the `willWrap` requirement that came back via
  `main`; `boundary-aware split` (commit 34c3140 + the wrap detection in
  b535f8c) replaces it.
- `Projections+Orthographic.swift`: drop the equivalent `willWrap`
  override.
- `InverseTests.swift`: drop the trailing `?.0` tuple unwrap in the new
  click-round-trip test — `point(for:size:…)` returns `Point?` now.
- `Examples/Cassini/ContentView+Model.swift`: keep the Danseiji enum
  cases AND extend the new `usesReferenceLatitude` /
  `usesReferenceLongitude` switches to cover them. Danseiji's mesh data
  is computed for a fixed orientation, so latitude is unused; longitude
  rotates the map east/west, so it's used.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
V's mesh emphasises continents over oceans; VI weighs population alongside
area. Both are hand-tuned, asymmetric meshes — rotating the reference
longitude shears the deformations relative to the underlying geography in
a way that stops being meaningful. (IV is similarly hand-tuned but its
distortions are unique enough that playing with the reference is fun, so
keep that one.)

V and VI now ignore the constructor's `reference` argument and pin
`reference` at `(0, 0)`. The Cassini example marks them as not using the
reference longitude, so that slider is disabled when V or VI is selected.

New `vAndVIIgnoreReference` test in `DanseijiTests` confirms a non-zero
reference produces the same projected coordinates as the centred default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Danseiji III–VI have edge polygons that aren't symmetric around the
geographic origin (the mesh data was hand-tuned that way: III's centre
sits at (+0.26, +0.56), IV's at (-0.49, +1.45), V's at (+0.34, +0.25),
VI's at (+0.59, +0.72)). The canvas-fitting code in `simpleTranslate`
assumed the projection rectangle was `[-w/2, +w/2] × [-h/2, +h/2]` and
fit `projectionSize` (computed as 2 × max(|xMin|, |xMax|) etc.) into the
canvas. With asymmetric edges that meant the actual map sat off-centre
and partway off-canvas, and edge insets weren't respected.

Add a `visibleBounds: Rect` to `Projection` describing the rectangle in
projection-radians that should be fit to the canvas. Default is
`projectionSize` centred at the origin, so all existing projections are
unaffected. `simpleTranslate` and `simpleUntranslate` now normalise
against `visibleBounds` rather than assuming centred-at-origin.

Danseiji projections override `visibleBounds` to return the
`DanseijiData.edgeBounds` rectangle that the mesh parser already
computed, so canvas fitting hugs the actual edge polygon for every
variant. I and II are unchanged because their edges happen to be
centred at the origin already.

New `edgePolygonIsCentredInCanvas` and `edgeInsetsShrinkProportionally`
tests run all six variants and assert opposing canvas margins match,
every edge vertex is inside the canvas, and edge insets shrink the map
on every side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@nighthawk nighthawk self-assigned this May 9, 2026
@nighthawk nighthawk added bug Something isn't working enhancement New feature or request labels May 9, 2026
@nighthawk nighthawk linked an issue May 9, 2026 that may be closed by this pull request
@nighthawk nighthawk mentioned this pull request May 9, 2026
9 tasks
nighthawk and others added 3 commits May 9, 2026 14:47
- The `PerfBench` struct ended up *outside* the `#if canImport(Testing)`
  guard, so Linux builds (which lack swift-testing) failed compiling
  with "unknown attribute 'Test'" on every benchmark method. Move the
  struct back inside the guard.
- Switch the Danseiji resource declaration from `.copy("Resources")`
  to `.process("Resources")`. SPM's `.copy` produces a bare directory
  bundle that fails iOS CodeSign with "bundle format unrecognized,
  invalid, or unsuitable" — `.process` emits a properly-structured
  bundle (with Info.plist) that signs cleanly. The CSV files are still
  loaded via `Bundle.module.url(forResource:withExtension:)` and the
  Danseiji unit tests continue to pass.

Verified locally with `swift test` (62/62 pass), plus
`xcodebuild -sdk iphoneos` and Cassini on `iphonesimulator` both build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Note that we now tag releases (the package is on `0.x` so far). Replace
  the "no tagged versions yet" branch dependency with a `from: "0.1.0"`
  example.
- Add `GeoProjectorDanseiji` to the library list, with a short note that
  it's a separate product so apps that don't use it don't pull in the
  ~1 MB of mesh data.
- Mention `inverse` and `coordinate(at:...)` as a first-class capability
  of the projection API (added in #38, but the README still spoke of
  forward projection only). Add a short example that maps a click back
  to lat/lon.
- Drop the dangling `?.0` tuple in the projection example — the API
  returns `Point?` since the boundary-aware split rewrite landed.
- Tidy the platform-conventions sentence (the previous version had
  swapped lat/lon and only mentioned macOS); call out that
  `GeoMapView` is `NSView` on macOS and `UIView` elsewhere.
- Mention that `GeoDrawer` can also draw to a `CGContext` or to SVG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@nighthawk nighthawk merged commit 76eb385 into main May 9, 2026
8 checks passed
@nighthawk nighthawk deleted the danseiji-plus-better-splits branch May 9, 2026 04:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue when drawing shapes spanning anti-meridian UI glitch of large polygons going outside map bounds

2 participants