From 46f4aa700aafe091cc18eb025ff7539881cc31e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:44:47 +0000 Subject: [PATCH] Fix terminator line rendering on the situation globe The night-side polygon closed its ring by jumping straight from the terminator curve to the pole in a single long edge. That's invisible on flat/Mercator maps but becomes a straight chord cutting across the sphere on the globe's curved projection, producing a stray patch near the pole instead of a smooth night shadow. Densify those closing edges in ~2 degree latitude steps, same as the curve itself already is. --- .../2026-07-10-globe-terminator-chord-fix.md | 58 +++++++++++++++++++ .../components/map/TerminatorLayer.test.ts | 48 +++++++++++++++ .../src/components/map/TerminatorLayer.tsx | 30 ++++++++-- 3 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 agent_docs/tasks/2026-07-10-globe-terminator-chord-fix.md create mode 100644 frontend/src/components/map/TerminatorLayer.test.ts diff --git a/agent_docs/tasks/2026-07-10-globe-terminator-chord-fix.md b/agent_docs/tasks/2026-07-10-globe-terminator-chord-fix.md new file mode 100644 index 0000000..00459e9 --- /dev/null +++ b/agent_docs/tasks/2026-07-10-globe-terminator-chord-fix.md @@ -0,0 +1,58 @@ +# Global Situation Globe — Terminator Line Rendering Bug + +## Issue + +On the dashboard's Global Situation view (`SituationGlobe`), the night-side +terminator overlay rendered as a jagged, misplaced patch near the pole +instead of a smooth shadow across the night hemisphere. The same +`getTerminatorLayer()` geometry renders correctly on the flat 2D maps +(`TacticalMap`, `OrbitalMap`). + +Root cause: `computeTerminator()` builds the night-side polygon by sampling +the wavy terminator curve at 1° longitude steps, then closing the ring by +jumping straight from the curve to the pole with only two vertices +(`coords.push([180, ±90])`, `coords.push([-180, ±90])`). On a flat/Mercator +projection a single long edge is harmless — it just renders as a straight +vertical line. On the globe (MapLibre's native globe projection, which bends +existing vertices onto the sphere but interpolates *linearly between* them), +that same edge — which can span 100+ degrees of latitude in one hop — becomes +a straight 3D chord cutting across the visible sphere instead of following +its curvature, producing the stray wedge seen near the pole while most of +the actual night region went unrendered. + +## Solution + +Densify the two pole-closing edges the same way the terminator curve itself +is already densified: step along the fixed-longitude meridian (lon = ±180) +from the curve to the pole in ~2° latitude increments instead of a single +jump. The pole-to-pole edge (`lon=180` → `lon=-180`, both at the same pole +latitude) is left as a single edge — it's a true zero-length edge in 3D since +every longitude maps to the same point at the pole, so no subdivision is +needed there. This changes only the number of vertices along an already +straight line in flat projections (no visual change on the working 2D maps) +while giving the globe projection enough points to hug the sphere's surface. + +## Changes + +- `frontend/src/components/map/TerminatorLayer.tsx` — `computeTerminator()` + replaces the two-point pole closure with a stepped ramp (`LAT_STEP_DEG = + 2`) along each boundary meridian, symmetric on both sides so the + pole-to-pole edge still lands on the exact same 3D point at both ends. +- `frontend/src/components/map/TerminatorLayer.test.ts` (new) — regression + test asserting no edge of the generated night polygon exceeds a 5° lat/lon + step, except the expected zero-length pole-to-pole edge. + +## Verification + +- `pnpm run lint` — clean (0 warnings). +- `pnpm run typecheck` — clean. +- `pnpm run test` — 279/279 passed (278 existing + 1 new). + +## Benefits + +- The night-side overlay on the Global Situation globe now follows the + sphere's curvature correctly instead of showing a stray artifact near the + pole, matching the correct behavior already present on the flat map views. +- The regression test catches any future reintroduction of long, + undersampled edges in the terminator polygon before it reaches the globe + renderer. diff --git a/frontend/src/components/map/TerminatorLayer.test.ts b/frontend/src/components/map/TerminatorLayer.test.ts new file mode 100644 index 0000000..bebf214 --- /dev/null +++ b/frontend/src/components/map/TerminatorLayer.test.ts @@ -0,0 +1,48 @@ +/** + * Regression test for the globe-view terminator rendering bug: the polygon's + * pole-closing edges must stay finely sampled, or they render as long straight + * chords cutting across the sphere on globe/3D projections (harmless on flat + * Mercator maps, which don't care about edge length). + */ + +import { describe, expect, it } from "vitest"; + +vi.mock("@deck.gl/layers", () => { + class GeoJsonLayer { + id: string; + props: Record; + constructor(props: Record) { + this.id = props.id as string; + this.props = props; + } + } + return { GeoJsonLayer }; +}); + +import { vi } from "vitest"; +import { getTerminatorLayer } from "./TerminatorLayer"; + +describe("getTerminatorLayer", () => { + it("keeps every edge of the night polygon within a small lat/lon step", () => { + const layer = getTerminatorLayer(true) as unknown as { + props: { data: { features: [{ geometry: { coordinates: number[][][] } }] } }; + }; + const ring = layer.props.data.features[0].geometry.coordinates[0]; + + const MAX_STEP_DEG = 5; + for (let i = 1; i < ring.length; i++) { + const [lon0, lat0] = ring[i - 1]; + const [lon1, lat1] = ring[i]; + const latStep = Math.abs(lat1 - lat0); + expect(latStep).toBeLessThanOrEqual(MAX_STEP_DEG); + + // A lon jump is only harmless when both endpoints sit at the same pole + // (every longitude maps to the same 3D point there, so it's a + // zero-length edge regardless of the lon delta). + const bothAtSamePole = Math.abs(lat0) === 90 && lat0 === lat1; + if (!bothAtSamePole) { + expect(Math.abs(lon1 - lon0)).toBeLessThanOrEqual(MAX_STEP_DEG); + } + } + }); +}); diff --git a/frontend/src/components/map/TerminatorLayer.tsx b/frontend/src/components/map/TerminatorLayer.tsx index e1a0f43..95624b7 100644 --- a/frontend/src/components/map/TerminatorLayer.tsx +++ b/frontend/src/components/map/TerminatorLayer.tsx @@ -76,13 +76,31 @@ function computeTerminator(date: Date) { // To make a polygon representing the *night* side, we need to connect the terminator // to either the north or south pole, depending on season (subSolarLat). // If sun is in north hemisphere (subSolarLat > 0), night covers south pole. + const poleLat = subSolarLat > 0 ? -90 : 90; + + // These two closing edges run along a fixed meridian (lon = ±180) from the + // terminator curve to the pole. On flat/Mercator maps a single long edge is + // fine (it renders as a straight vertical line), but on the 3D globe the + // renderer only bends existing vertices onto the sphere and interpolates + // linearly *between* them — an edge spanning ~100+ degrees of latitude in + // one hop becomes a straight chord that cuts across the visible globe + // instead of following its curvature. Sample every few degrees so the + // edge hugs the sphere on both projections. + const LAT_STEP_DEG = 2; + const lastLat = coords[coords.length - 1][1]; // terminator lat at lon = 180 + const firstLat = coords[0][1]; // terminator lat at lon = -180 + + const rampSteps = Math.max(1, Math.round(Math.abs(poleLat - lastLat) / LAT_STEP_DEG)); + for (let i = 1; i <= rampSteps; i++) { + coords.push([180, lastLat + (poleLat - lastLat) * (i / rampSteps)]); + } - if (subSolarLat > 0) { - coords.push([180, -90]); - coords.push([-180, -90]); - } else { - coords.push([180, 90]); - coords.push([-180, 90]); + // Start this ramp exactly at the pole (i = returnSteps) so the pole-to-pole + // edge above lands on the same point in 3D at both ends, then step back + // down to the terminator curve's start. + const returnSteps = Math.max(1, Math.round(Math.abs(poleLat - firstLat) / LAT_STEP_DEG)); + for (let i = returnSteps; i >= 1; i--) { + coords.push([-180, firstLat + (poleLat - firstLat) * (i / returnSteps)]); } // Close the polygon