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
18 changes: 14 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ Forked from [SceneryStackTemplate](https://github.com/OpenPhysics/SceneryStackTe
| `src/PlateTectonicsNamespace.ts` | Namespace for color property names |
| `src/i18n/StringManager.ts` | Singleton localized string accessor |
| `src/common/EarthProjection.ts` | The interface both projections implement |
| `src/common/MapProjection.ts` | Equirectangular lon/lat ↔ view |
| `src/common/MapProjection.ts` | Equirectangular lon/lat ↔ view, plus the flat map's camera |
| `src/common/GlobeProjection.ts` | Orthographic lon/lat ↔ view, plus the globe's camera |
| `src/common/attachGlobeRotation.ts` | Drag and arrow keys → the globe's camera |
| `src/common/attachMapNavigation.ts` | Drag and arrow keys → the flat map's camera |
| `src/common/PlateReconstruction.ts` | Euler-pole rotation, plate velocities, `MOTION_FRAMES` |
| `src/common/data/dataTypes.ts` | Shapes of every dataset (hand-written) |
| `src/common/data/hotspots.ts` | Hand-maintained hotspot list |
Expand Down Expand Up @@ -85,9 +86,18 @@ header and in `doc/implementation-notes.md`. Text stays as Scenery `Text` so it
localized and reached by a screen reader.

Sphere-on-a-rectangle hazards (antimeridian wrapping, circumpolar rings, ring closure,
coastlines tearing at plate boundaries) are all handled in
`MapCanvasNode.appendPolyline`. Read its comments before touching it; each rule is
there because of a specific artifact.
coastlines tearing at plate boundaries, the seams the datasets were cut along) are all
handled in `MapCanvasNode.appendPolyline`. Read its comments before touching it; each
rule is there because of a specific artifact.

The flat map **pans and zooms**, so ±180° is no longer reliably off screen and a
feature is traced relative to the camera rather than to the map's ±180° home. Both
global views therefore carry a camera in the *view*: `MapProjection` has a centre
longitude, a centre latitude and a zoom level, and `MapProjection.latitudeLimit` is
what makes vertical panning do nothing until the user zooms in — a bounded axis, not
an interaction rule. `EarthProjection.project` reports whether a point is on screen,
which on the flat map now means "inside the viewport" as well. The reasoning is in
[`doc/implementation-notes.md`](doc/implementation-notes.md#panning-and-zooming-the-flat-map).

The global map is drawn either flat or as a rotatable 3-D globe, from the same data.
`EarthCanvasNode` owns what they share — which layers exist, in what order, in what
Expand Down
58 changes: 50 additions & 8 deletions doc/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ src/
PlateTectonicsConstants.ts layout px, Earth-science quantities, time range
common/
EarthProjection.ts the interface both projections implement
MapProjection.ts equirectangular lon/lat ↔ view
MapProjection.ts equirectangular lon/lat ↔ view, with a camera
GlobeProjection.ts orthographic lon/lat ↔ view, with a camera
attachGlobeRotation.ts drag + arrow keys → the globe's camera
attachMapNavigation.ts drag + arrow keys → the flat map's camera
PlateReconstruction.ts Euler-pole rotation and plate velocities
data/
dataTypes.ts shapes of every dataset (hand-written)
Expand Down Expand Up @@ -67,12 +68,16 @@ Three problems come out of the projection, all handled in `MapCanvasNode.appendP

1. **The antimeridian.** Longitudes are unwrapped as a polyline is walked — each
vertex is nudged by whole turns to stay within half a turn of the previous one — so
a feature that straddles ±180° stays in one piece. The ring is then repeated a
world-width either side, and the clip keeps whichever copy is on screen.
a feature that straddles ±180° stays in one piece. The *first* vertex is unwrapped
against the camera instead, which puts the feature on the copy of the world the map
is looking at; the feature is then repeated a world-width either side whenever that
copy would show it too, and the clip keeps whichever copies are on screen.
2. **Circumpolar rings.** The North American plate reaches right around the Arctic and
the Antarctic plate around the South Pole, so their rings gain a whole turn of
longitude. Filling one has to route over the pole it encloses, or the fill spills
across the map.
across the map. The turns the *walk* accumulates are counted separately from the
turns that carried the feature to the camera, or a plate far from the camera would
be mistaken for one that goes round the world.
3. **Closing.** Every ring in the data repeats its first vertex at the end, so outlines
are already closed and `closePath` is only used for fills — which is just as well,
because on an unwrapped ring `closePath` would draw a chord straight across the map.
Expand All @@ -82,6 +87,40 @@ boundary tears apart correctly under reconstruction — Baja California rides th
plate away from North America. The outline is broken at those tears (the fill still
spans them) so the torn edge does not leave a stray line across the ocean.

## Panning and zooming the flat map

`MapProjection` carries a camera of the same shape as the globe's — the longitude and
latitude at the centre of the viewport — plus a zoom level the map is drawn at 2^level.
`attachMapNavigation` moves it, in the same two senses `attachGlobeRotation` uses: a
drag takes hold of the map, and the arrow keys move the viewpoint.

The two axes are not symmetric, and the projection is why. Longitude is periodic, so
panning east wraps and never stops. Latitude is bounded, so the camera is clamped to
`latitudeLimit` = 90 − 90/scale: **zero at level 0**, where the whole 180° is already on
screen and there is nothing to pan to, and opening up as zooming in shrinks what fits.
That is the whole of "left and right always, up and down once you are zoomed in" — no
interaction code special-cases it.

Three things follow from the camera, and each one is a bug that was visible before it
was fixed:

- `project` now reports **false outside the viewport**, which is what stops a plate
label being drawn over the legend and saves the canvas from plotting 9 000
epicentres that are four viewport-widths off to the side. The flat overlay is
clipped to the viewport as well, so a label at the edge is cut rather than spilling.
- `viewX` deliberately does **not** wrap: the mapping stays linear so an unwrapped
polyline keeps its shape. Wrapping is `project`'s business, for single points.
- The **dataset seams** — the ±180° slits and polar closures that `PLATES` and
`LAND_RINGS` are cut along — used to sit exactly on the edge of the viewport, where
they could not be seen. Panning moves that edge, so they are now skipped when
stroking (never when filling) by the same `isSeamSegment` rule the globe has always
used; it moved to `EarthCanvasNode` when the second caller appeared. Without it the
Pacific gets a bright line straight up the middle of it.

Reset All puts both cameras back, through `PlateTectonicsScreenView.reset` — a camera
is a way of looking at the Earth rather than a fact about it, so neither belongs in the
model. `showGlobeProperty` does, because it is a choice about what is shown.

## Drawing a sphere as a sphere

The **3-D globe** (`GlobeCanvasNode`, off by default) is the same layers in the same
Expand Down Expand Up @@ -152,9 +191,12 @@ Generated files are excluded from Biome (see `biome.json`) and formatted by
which depths pass the filter, and where in geological time the plates are.
- Every control carries an `accessibleName` (and a help text where it earns one) from
the `a11y` string group.
- `PlateTectonicsScreenView` sets an explicit `pdomOrder`: view selector → layer
checkboxes → depth filter → time slider → time controls → Reset All.
- The keyboard-help dialog has a section per interaction kind: slider, combo box, and
- `PlateTectonicsScreenView` sets an explicit `pdomOrder`: the global view and its zoom
buttons → view selector → layer checkboxes → depth filter → time slider → time
controls → Reset All. The map and the globe are both in it; whichever is hidden drops
out on its own.
- The keyboard-help dialog has a section per interaction kind: slider, moving a
draggable item (which is how both the map and the globe are moved), combo box, and
basic actions.

## Testing
Expand All @@ -166,7 +208,7 @@ Generated files are excluded from Biome (see `biome.json`) and formatted by
| `PlateReconstruction.test.ts` | Euler-pole rotation, round trips, and plate speeds against published values |
| `PlateTectonicsModel.test.ts` | layer state, depth bands, the time clock and reset |
| `CrossSectionGeometry.test.ts` | the two-band layout, crust switching, slab fitting, ridge cooling |
| `MapProjection.test.ts` | projection round trips, the 2:1 viewport, motion-arrow bearings |
| `MapProjection.test.ts` | projection round trips, the 2:1 viewport, motion-arrow bearings, the camera |
| `GlobeProjection.test.ts` | orthographic projection and its inverse, visibility, bearings, the camera |
| `geophysicalData.test.ts` | integrity of every generated dataset, plus a few facts about the Earth |
| `memory-leak.test.ts` | WeakRef + forced GC on disposables |
Expand Down
27 changes: 27 additions & 0 deletions src/PlateTectonicsConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ export const VOLCANO_MARKER_SIZE = 3.4;
/** Length in view pixels of a motion vector representing 100 mm/year. */
export const VELOCITY_VECTOR_SCALE = 26;

// ── Panning and zooming the flat map ──────────────────────────────────────────

/** Zoom level at which the whole world fits the viewport; the map opens here. */
export const MAP_MIN_ZOOM_LEVEL = 0;

/**
* Deepest zoom level, as a power of two: level 3 is 8×, which puts 45° of longitude
* across the viewport — enough to look along the Chile trench or the San Andreas
* fault. Going further would only magnify the relief raster, which is 1440 × 720 and
* is already being upscaled fourfold by then.
*/
export const MAP_MAX_ZOOM_LEVEL = 3;

/** View pixels the map pans per press of an arrow key, at every zoom level. */
export const MAP_KEYBOARD_STEP_PIXELS = 10;

/**
* How far outside the viewport a feature's centre may be and still count as on
* screen. Wide enough for the largest earthquake marker and for a plate label, so
* neither vanishes while part of it should still be visible at the edge.
*/
export const MAP_VIEWPORT_CULL_MARGIN = 30;

// ── Globe rendering ───────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -135,6 +158,10 @@ PlateTectonicsNamespace.register("PlateTectonicsConstants", {
QUAKE_RADIUS_PER_MAGNITUDE,
VOLCANO_MARKER_SIZE,
VELOCITY_VECTOR_SCALE,
MAP_MIN_ZOOM_LEVEL,
MAP_MAX_ZOOM_LEVEL,
MAP_KEYBOARD_STEP_PIXELS,
MAP_VIEWPORT_CULL_MARGIN,
GLOBE_RADIUS_MARGIN,
GLOBE_INITIAL_CENTER_LON,
GLOBE_INITIAL_CENTER_LAT,
Expand Down
18 changes: 14 additions & 4 deletions src/common/EarthProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@
* context.lineTo(projection.x, projection.y);
* }
*
* The boolean is what separates a sphere from a rectangle: on the flat map every
* point is on screen, while on the globe half the world faces away from the viewer.
* The boolean is what separates one view from the other: on the globe half the world
* faces away from the viewer, while on the flat map every point is on the map but
* only the part the camera is over is inside the viewport.
*/

import type { TReadOnlyProperty } from "scenerystack/axon";
import type { Bounds2 } from "scenerystack/dot";

/**
* Wraps a longitude into [-180, 180), so a camera longitude stays bounded however far
* the Earth is spun or panned. Shared by both projections, which each carry one.
*/
export function wrapLongitude(lon: number): number {
return ((((lon + 180) % 360) + 360) % 360) - 180;
}

export interface EarthProjection {
/** The rectangle the projection draws inside. */
readonly viewBounds: Bounds2;
Expand All @@ -47,8 +56,9 @@ export interface EarthProjection {

/**
* Projects a geographic point, writing view coordinates to {@link x} and {@link y}.
* Returns false when the point faces away from the viewer, in which case the
* coordinates are still written but must not be drawn.
* Returns false when the point is not on screen — it faces away from the viewer on
* the globe, or lies outside the viewport on a panned or zoomed flat map — in which
* case the coordinates are still written but must not be drawn.
*/
project(lon: number, lat: number): boolean;

Expand Down
7 changes: 1 addition & 6 deletions src/common/GlobeProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import { NumberProperty, type TReadOnlyProperty } from "scenerystack/axon";
import type { Bounds2 } from "scenerystack/dot";
import { GLOBE_INITIAL_CENTER_LAT, GLOBE_INITIAL_CENTER_LON, GLOBE_RADIUS_MARGIN } from "../PlateTectonicsConstants.js";
import type { EarthProjection } from "./EarthProjection.js";
import { type EarthProjection, wrapLongitude } from "./EarthProjection.js";

const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;
Expand All @@ -40,11 +40,6 @@ const RAD_TO_DEG = 180 / Math.PI;
*/
const BEARING_STEP_RAD = 1 * DEG_TO_RAD;

/** Wraps a longitude into [-180, 180), so the camera value stays bounded as it spins. */
export function wrapLongitude(lon: number): number {
return ((((lon + 180) % 360) + 360) % 360) - 180;
}

export class GlobeProjection implements EarthProjection {
public readonly viewBounds: Bounds2;

Expand Down
Loading
Loading