Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions agent_docs/tasks/2026-07-10-globe-terminator-chord-fix.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions frontend/src/components/map/TerminatorLayer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
constructor(props: Record<string, unknown>) {
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);
}
}
});
});
30 changes: 24 additions & 6 deletions frontend/src/components/map/TerminatorLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading