diff --git a/CLAUDE.md b/CLAUDE.md index 392d7e2..68949b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -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 diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index 1344f7f..5298c6a 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -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) @@ -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. @@ -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 @@ -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 @@ -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 | diff --git a/src/PlateTectonicsConstants.ts b/src/PlateTectonicsConstants.ts index a32b9b5..b9b8f46 100644 --- a/src/PlateTectonicsConstants.ts +++ b/src/PlateTectonicsConstants.ts @@ -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 ─────────────────────────────────────────────────────────── /** @@ -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, diff --git a/src/common/EarthProjection.ts b/src/common/EarthProjection.ts index 1f9bee3..0ad4733 100644 --- a/src/common/EarthProjection.ts +++ b/src/common/EarthProjection.ts @@ -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; @@ -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; diff --git a/src/common/GlobeProjection.ts b/src/common/GlobeProjection.ts index 8d9da3f..9c970c4 100644 --- a/src/common/GlobeProjection.ts +++ b/src/common/GlobeProjection.ts @@ -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; @@ -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; diff --git a/src/common/MapProjection.ts b/src/common/MapProjection.ts index 28e94b4..18fa311 100644 --- a/src/common/MapProjection.ts +++ b/src/common/MapProjection.ts @@ -12,17 +12,39 @@ * const projection = new MapProjection(MAP_VIEW_BOUNDS); * const x = projection.viewX(lon); * const y = projection.viewY(lat); + * + * ── The camera ──────────────────────────────────────────────────────────────── + * Like the globe, the flat map has a camera: the longitude and latitude at the + * centre of the viewport, plus a zoom level the map is scaled by 2^level. At level 0 + * the whole world is on screen exactly as it always was, and only the longitude can + * move — there is no more latitude to show, so panning north and south is clamped to + * nothing. Zooming in shrinks what fits, and the up/down room appears with it. + * + * Longitude wraps and latitude clamps, so the map can be panned east forever but + * never past a pole. That asymmetry is the projection's, not the interaction's: + * a cylinder is periodic in longitude and bounded in latitude. */ -import type { TReadOnlyProperty } from "scenerystack/axon"; +import { NumberProperty, type TReadOnlyProperty } from "scenerystack/axon"; import type { Bounds2 } from "scenerystack/dot"; -import type { EarthProjection } from "./EarthProjection.js"; +import { Range } from "scenerystack/dot"; +import { MAP_MAX_ZOOM_LEVEL, MAP_MIN_ZOOM_LEVEL, MAP_VIEWPORT_CULL_MARGIN } from "../PlateTectonicsConstants.js"; +import { type EarthProjection, wrapLongitude } from "./EarthProjection.js"; export class MapProjection implements EarthProjection { public readonly viewBounds: Bounds2; - /** The flat map has no camera to turn, so nothing ever moves every point at once. */ - public readonly cameraProperties: readonly TReadOnlyProperty[] = []; + /** Zoom level; the map is drawn at 2^level, so level 0 fits the world in the viewport. */ + public readonly zoomLevelProperty: NumberProperty; + + /** Longitude at the centre of the viewport; horizontal panning moves it, and it wraps. */ + public readonly centerLongitudeProperty: NumberProperty; + + /** Latitude at the centre of the viewport; pinned to the equator until zoomed in. */ + public readonly centerLatitudeProperty: NumberProperty; + + /** Moving any of these moves every projected point at once. */ + public readonly cameraProperties: readonly TReadOnlyProperty[]; /** View x written by the most recent {@link project} call. */ public x = 0; @@ -36,18 +58,49 @@ export class MapProjection implements EarthProjection { /** y component of the unit vector written by the most recent {@link bearing} call. */ public bearingY = -1; + // The camera, cached from the Properties above: every projected point needs all + // three, and they only change when the user pans or zooms. + private scale = 1; + private centerLon = 0; + private centerLat = 0; + public constructor(viewBounds: Bounds2) { this.viewBounds = viewBounds; + + this.zoomLevelProperty = new NumberProperty(MAP_MIN_ZOOM_LEVEL, { + range: new Range(MAP_MIN_ZOOM_LEVEL, MAP_MAX_ZOOM_LEVEL), + numberType: "Integer", + }); + this.centerLongitudeProperty = new NumberProperty(0); + this.centerLatitudeProperty = new NumberProperty(0); + this.cameraProperties = [this.zoomLevelProperty, this.centerLongitudeProperty, this.centerLatitudeProperty]; + + this.zoomLevelProperty.link((level: number) => { + this.scale = 2 ** level; + // Zooming out shows more latitude at once, which can leave the camera further + // north or south than the new zoom level has room for. + this.centerLatitudeProperty.value = this.constrainLatitude(this.centerLatitudeProperty.value); + }); + this.centerLongitudeProperty.link((lon: number) => { + this.centerLon = lon; + }); + this.centerLatitudeProperty.link((lat: number) => { + this.centerLat = lat; + }); } /** - * Projects a geographic point, writing view coordinates to {@link x} and - * {@link y}. Always visible: an equirectangular map shows the whole world at once. + * Projects a geographic point, writing view coordinates to {@link x} and {@link y}. + * + * The longitude is first wrapped into the half-turn either side of the camera, so a + * point is placed on the copy of the world the map is currently looking at rather + * than always at its ±180° home. Returns false when the result falls outside the + * viewport, which at level 0 never happens and when zoomed in is most of the world. */ public project(lon: number, lat: number): boolean { - this.x = this.viewX(lon); + this.x = this.viewX(this.centerLon + wrapLongitude(lon - this.centerLon)); this.y = this.viewY(lat); - return true; + return this.isOnScreen(this.x, this.y); } /** @@ -66,28 +119,104 @@ export class MapProjection implements EarthProjection { this.bearingY = -Math.cos(azimuth); } - /** View x for a longitude in [-180, 180]. */ + /** + * View x for a longitude, which is *not* wrapped: the mapping stays linear so a + * polyline that has been unwrapped past the antimeridian keeps its shape, and the + * renderer repeats it a world-width either side to cover the seam. + */ public viewX(lon: number): number { - return this.viewBounds.minX + ((lon + 180) / 360) * this.viewBounds.width; + return this.viewBounds.centerX + (lon - this.centerLon) * this.pixelsPerDegree; } /** View y for a latitude in [-90, 90]; y increases downwards, so north is up. */ public viewY(lat: number): number { - return this.viewBounds.minY + ((90 - lat) / 180) * this.viewBounds.height; + return this.viewBounds.centerY - (lat - this.centerLat) * this.pixelsPerDegreeY; } - /** Longitude at a view x. */ + /** Longitude at a view x, in the same unwrapped sense as {@link viewX}. */ public longitudeAt(viewX: number): number { - return ((viewX - this.viewBounds.minX) / this.viewBounds.width) * 360 - 180; + return this.centerLon + (viewX - this.viewBounds.centerX) / this.pixelsPerDegree; } /** Latitude at a view y. */ public latitudeAt(viewY: number): number { - return 90 - ((viewY - this.viewBounds.minY) / this.viewBounds.height) * 180; + return this.centerLat - (viewY - this.viewBounds.centerY) / this.pixelsPerDegreeY; } - /** View pixels per degree of longitude. */ + /** View pixels per degree of longitude at the current zoom. */ public get pixelsPerDegree(): number { - return this.viewBounds.width / 360; + return (this.viewBounds.width / 360) * this.scale; + } + + /** Degrees of longitude one view pixel of drag is worth at the current zoom. */ + public get degreesPerPixel(): number { + return 1 / this.pixelsPerDegree; + } + + /** Width in view pixels of one whole world at the current zoom. */ + public get worldWidth(): number { + return this.viewBounds.width * this.scale; + } + + /** Height in view pixels of one whole world at the current zoom. */ + public get worldHeight(): number { + return this.viewBounds.height * this.scale; + } + + /** + * How far the camera may move from the equator: zero at level 0, where the whole + * 180° of latitude is already on screen, and approaching a pole as the visible + * span shrinks. Keeping to it is what stops the map being panned off its own top. + */ + public get latitudeLimit(): number { + return 90 - 90 / this.scale; + } + + /** True when the map is showing the whole world, its opening state. */ + public get isWholeWorld(): boolean { + return this.scale === 1; + } + + /** + * Pans the camera by the given number of degrees. Longitude wraps, so the map can + * be dragged east indefinitely; latitude is clamped to {@link latitudeLimit}, so + * the viewport never runs off the top or bottom of the map. + */ + public panBy(deltaLongitude: number, deltaLatitude: number): void { + this.centerLongitudeProperty.value = wrapLongitude(this.centerLongitudeProperty.value + deltaLongitude); + this.centerLatitudeProperty.value = this.constrainLatitude(this.centerLatitudeProperty.value + deltaLatitude); + } + + /** Returns the camera to the whole-world view the map opens on. */ + public reset(): void { + this.zoomLevelProperty.reset(); + this.centerLongitudeProperty.reset(); + this.centerLatitudeProperty.reset(); + } + + /** View pixels per degree of latitude; equal to {@link pixelsPerDegree} in a 2:1 viewport. */ + private get pixelsPerDegreeY(): number { + return (this.viewBounds.height / 180) * this.scale; + } + + /** + * True when a view point is close enough to the viewport to be worth drawing. The + * margin covers markers and labels whose centre is just outside it, which would + * otherwise pop out of existence a few pixels early instead of being clipped. + */ + private isOnScreen(viewX: number, viewY: number): boolean { + const margin = MAP_VIEWPORT_CULL_MARGIN; + return ( + viewX >= this.viewBounds.minX - margin && + viewX <= this.viewBounds.maxX + margin && + viewY >= this.viewBounds.minY - margin && + viewY <= this.viewBounds.maxY + margin + ); + } + + /** Clamps a camera latitude to what the current zoom level has room for. */ + private constrainLatitude(lat: number): number { + const limit = this.latitudeLimit; + return Math.max(-limit, Math.min(limit, lat)); } } diff --git a/src/common/attachMapNavigation.ts b/src/common/attachMapNavigation.ts new file mode 100644 index 0000000..f9f3da4 --- /dev/null +++ b/src/common/attachMapNavigation.ts @@ -0,0 +1,96 @@ +/** + * attachMapNavigation.ts + * + * Makes a Node a handle for panning a {@link MapProjection}, by pointer and by + * keyboard — the flat map's counterpart to {@link attachGlobeRotation}, and + * deliberately the same two gestures in the same two senses: + * + * - **Drag** moves the map under the pointer: drag right and the map goes right, so + * what was to the west comes into view. One view pixel of drag moves the map by + * one pixel, at every zoom level, which is what makes a drag feel like it has hold + * of the map rather than of a slider. + * - **Arrow keys** move the viewpoint instead: right looks further east, up looks + * further north. That is the opposite sense to the drag, and deliberately so — it + * is the convention every mapping application uses for arrows, and the accessible + * help text says which way they go. + * + * Panning east and west always does something, because the map wraps. Panning north + * and south does nothing until the user zooms in: while the whole world is on screen + * there is no more latitude to bring into view, so the camera is clamped to the + * equator (see `MapProjection.latitudeLimit`) and the up and down arrows are inert. + */ + +import type { TReadOnlyProperty } from "scenerystack/axon"; +import type { Vector2 } from "scenerystack/dot"; +import { DragListener, KeyboardListener, type Node } from "scenerystack/scenery"; +import { MAP_KEYBOARD_STEP_PIXELS } from "../PlateTectonicsConstants.js"; +import type { MapProjection } from "./MapProjection.js"; + +export type AttachMapNavigationOptions = { + projection: MapProjection; + /** Localized accessible name for the focusable map. */ + accessibleNameProperty: TReadOnlyProperty; + /** Localized help text describing the drag and the arrow keys. */ + accessibleHelpTextProperty?: TReadOnlyProperty; +}; + +/** + * Makes `target` a focusable, draggable map. Returns `target` for chaining. + */ +export function attachMapNavigation(target: T, options: AttachMapNavigationOptions): T { + const { projection, accessibleNameProperty, accessibleHelpTextProperty } = options; + + target.tagName = "div"; + target.focusable = true; + target.accessibleName = accessibleNameProperty; + if (accessibleHelpTextProperty) { + target.accessibleHelpText = accessibleHelpTextProperty; + } + + let lastPoint: Vector2 | null = null; + + target.addInputListener( + new DragListener({ + start: (event) => { + lastPoint = event.pointer.point.copy(); + }, + drag: (event) => { + if (!lastPoint) { + return; + } + const point = event.pointer.point; + const degreesPerPixel = projection.degreesPerPixel; + // Drag right ⇒ the map travels right ⇒ the centre longitude moves west. + // Drag down ⇒ the map travels down ⇒ the centre latitude moves north. + projection.panBy(-(point.x - lastPoint.x) * degreesPerPixel, (point.y - lastPoint.y) * degreesPerPixel); + lastPoint = point.copy(); + }, + end: () => { + lastPoint = null; + }, + }), + ); + + target.addInputListener( + new KeyboardListener({ + keys: ["arrowLeft", "arrowRight", "arrowUp", "arrowDown"], + fireOnHold: true, + fire: (_event, keysPressed) => { + // A fixed number of view pixels rather than of degrees, so one press covers + // the same distance on screen however far the map is zoomed in. + const step = MAP_KEYBOARD_STEP_PIXELS * projection.degreesPerPixel; + if (keysPressed === "arrowLeft") { + projection.panBy(-step, 0); + } else if (keysPressed === "arrowRight") { + projection.panBy(step, 0); + } else if (keysPressed === "arrowUp") { + projection.panBy(0, step); + } else if (keysPressed === "arrowDown") { + projection.panBy(0, -step); + } + }, + }), + ); + + return target; +} diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 07eb417..83cb3d3 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -10,7 +10,8 @@ "divergent": "Spreading ridge — Mid-Atlantic", "transform": "Transform fault — San Andreas", "globe": "Show as 3-D globe", - "globeHint": "Drag the globe to turn the Earth" + "globeHint": "Drag the globe to turn the Earth", + "mapHint": "Drag the map to move it east and west; zoom in for a closer look" }, "layers": { "title": "Layers", @@ -75,7 +76,7 @@ }, "currentDetails": "{{view}} {{layers}} {{depths}} {{time}}", "viewDetails": { - "global": "The global map is showing.", + "global": "The global map is showing. It can be moved east and west, and zoomed in to look at one region closely.", "globe": "The Earth is showing as a globe, which can be turned to bring any part of it into view.", "subduction": "A cross-section through the Chile trench is showing, where the Nazca plate subducts beneath South America.", "divergent": "A cross-section through the Mid-Atlantic Ridge is showing, where North America and Africa spread apart.", @@ -101,6 +102,11 @@ "globeToggleHelp": "Draw the global map on a globe that can be turned, instead of a flat rectangular map.", "globe": "Globe", "globeHelp": "Drag the globe to turn the Earth. With the globe focused, the left and right arrow keys move the view east and west, and the up and down arrow keys move it north and south.", + "map": "Map", + "mapHelp": "Drag the map to move it. With the map focused, the left and right arrow keys move the view east and west; once the map is zoomed in, the up and down arrow keys move it north and south.", + "zoomIn": "Zoom in on the map", + "zoomOut": "Zoom out from the map", + "zoomHelp": "Zoom in to look at one region closely, or out to bring the whole world back.", "plates": "Tectonic plates", "plateBoundaries": "Plate boundaries", "motionVectors": "Motion vectors", diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index aecbe39..1ee2672 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -10,7 +10,8 @@ "divergent": "Dorsal de expansión — Atlántico Medio", "transform": "Falla transformante — San Andrés", "globe": "Mostrar como globo 3D", - "globeHint": "Arrastre el globo para girar la Tierra" + "globeHint": "Arrastre el globo para girar la Tierra", + "mapHint": "Arrastre el mapa para moverlo de este a oeste; acérquese para verlo en detalle" }, "layers": { "title": "Capas", @@ -75,7 +76,7 @@ }, "currentDetails": "{{view}} {{layers}} {{depths}} {{time}}", "viewDetails": { - "global": "Se muestra el mapa global.", + "global": "Se muestra el mapa global. Puede moverse hacia el este y el oeste, y ampliarse para observar de cerca una región.", "globe": "La Tierra se muestra como un globo, que puede girarse para traer cualquier región al campo de visión.", "subduction": "Se muestra un corte transversal de la fosa de Chile, donde la placa de Nazca subduce bajo América del Sur.", "divergent": "Se muestra un corte transversal de la dorsal Mesoatlántica, donde América del Norte y África se separan.", @@ -101,6 +102,11 @@ "globeToggleHelp": "Dibujar el mapa global sobre un globo que puede girarse, en lugar de un mapa rectangular plano.", "globe": "Globo", "globeHelp": "Arrastre el globo para girar la Tierra. Con el globo seleccionado, las flechas izquierda y derecha mueven la vista hacia el este y el oeste, y las flechas arriba y abajo la mueven hacia el norte y el sur.", + "map": "Mapa", + "mapHelp": "Arrastre el mapa para moverlo. Con el mapa seleccionado, las flechas izquierda y derecha mueven la vista hacia el este y el oeste; una vez ampliado el mapa, las flechas arriba y abajo la mueven hacia el norte y el sur.", + "zoomIn": "Acercar el mapa", + "zoomOut": "Alejar el mapa", + "zoomHelp": "Acérquese para observar de cerca una región, o aléjese para recuperar el mundo entero.", "plates": "Placas tectónicas", "plateBoundaries": "Límites de placas", "motionVectors": "Vectores de movimiento", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 0586280..3c02ff9 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -10,7 +10,8 @@ "divergent": "Dorsale d'expansion — Atlantique", "transform": "Faille transformante — San Andreas", "globe": "Afficher en globe 3D", - "globeHint": "Faites glisser le globe pour faire tourner la Terre" + "globeHint": "Faites glisser le globe pour faire tourner la Terre", + "mapHint": "Faites glisser la carte pour la déplacer d'est en ouest ; zoomez pour regarder de plus près" }, "layers": { "title": "Couches", @@ -75,7 +76,7 @@ }, "currentDetails": "{{view}} {{layers}} {{depths}} {{time}}", "viewDetails": { - "global": "La carte mondiale est affichée.", + "global": "La carte mondiale est affichée. Elle peut être déplacée vers l'est et vers l'ouest, et agrandie pour examiner une région de près.", "globe": "La Terre est affichée en globe, que l'on peut faire tourner pour amener n'importe quelle région dans le champ de vision.", "subduction": "Une coupe transversale de la fosse du Chili est affichée, où la plaque de Nazca s'enfonce sous l'Amérique du Sud.", "divergent": "Une coupe transversale de la dorsale médio-atlantique est affichée, où l'Amérique du Nord et l'Afrique s'écartent.", @@ -101,6 +102,11 @@ "globeToggleHelp": "Dessiner la carte mondiale sur un globe que l'on peut faire tourner, au lieu d'une carte rectangulaire plate.", "globe": "Globe", "globeHelp": "Faites glisser le globe pour faire tourner la Terre. Lorsque le globe est sélectionné, les flèches gauche et droite déplacent la vue vers l'est et vers l'ouest, et les flèches haut et bas la déplacent vers le nord et vers le sud.", + "map": "Carte", + "mapHelp": "Faites glisser la carte pour la déplacer. Lorsque la carte est sélectionnée, les flèches gauche et droite déplacent la vue vers l'est et vers l'ouest ; une fois la carte agrandie, les flèches haut et bas la déplacent vers le nord et vers le sud.", + "zoomIn": "Agrandir la carte", + "zoomOut": "Réduire la carte", + "zoomHelp": "Agrandissez pour examiner une région de près, ou réduisez pour retrouver le monde entier.", "plates": "Plaques tectoniques", "plateBoundaries": "Limites de plaques", "motionVectors": "Vecteurs de mouvement", diff --git a/src/plate-tectonics/model/PlateTectonicsModel.ts b/src/plate-tectonics/model/PlateTectonicsModel.ts index 9c2a509..da1e60b 100644 --- a/src/plate-tectonics/model/PlateTectonicsModel.ts +++ b/src/plate-tectonics/model/PlateTectonicsModel.ts @@ -39,24 +39,30 @@ export const TIME_RANGE = new Range(-TIME_RANGE_MYR, TIME_RANGE_MYR); export class PlateTectonicsModel implements TModel { // ── Layer visibility ──────────────────────────────────────────────────────── + // + // Every layer starts off. The sim opens on a bare ocean-and-coastline map, and the + // question it asks is which of these datasets to put on it — which is a question a + // student can only see if the answer is not already drawn for them. Switching two + // layers on and finding where they coincide is the interaction hint the screen + // summary gives, and it only means anything from an empty map. /** The per-plate colour wash and plate outlines. */ - public readonly showPlatesProperty = new BooleanProperty(true); + public readonly showPlatesProperty = new BooleanProperty(false); /** Plate boundaries, colour-coded divergent / convergent / transform. */ - public readonly showBoundariesProperty = new BooleanProperty(true); + public readonly showBoundariesProperty = new BooleanProperty(false); /** Absolute plate motion vectors, scaled in mm/year. */ - public readonly showVectorsProperty = new BooleanProperty(true); + public readonly showVectorsProperty = new BooleanProperty(false); /** Earthquake epicentres, sized by magnitude and coloured by depth. */ - public readonly showEarthquakesProperty = new BooleanProperty(true); + public readonly showEarthquakesProperty = new BooleanProperty(false); /** Holocene volcanoes and intraplate hotspots. */ - public readonly showVolcanoesProperty = new BooleanProperty(true); + public readonly showVolcanoesProperty = new BooleanProperty(false); /** The shaded relief raster: land topography and ocean-floor bathymetry. */ - public readonly showTopographyProperty = new BooleanProperty(true); + public readonly showTopographyProperty = new BooleanProperty(false); // ── Filtering ─────────────────────────────────────────────────────────────── diff --git a/src/plate-tectonics/view/EarthCanvasNode.ts b/src/plate-tectonics/view/EarthCanvasNode.ts index d2b791b..84b3fc9 100644 --- a/src/plate-tectonics/view/EarthCanvasNode.ts +++ b/src/plate-tectonics/view/EarthCanvasNode.ts @@ -55,6 +55,36 @@ export type RingMode = "fill" | "stroke" | "open"; /** Depth bands in draw order: deep first, so the shallow crowd along the trenches stays on top. */ const DEPTH_BANDS: readonly DepthBand[] = ["deep", "intermediate", "shallow"]; +/** + * How close to ±180° of longitude, or to a pole, a vertex has to be for its segment to + * count as a dataset seam. The seams sit on those lines exactly, so this only has to + * absorb the last digit of the stored coordinate — see {@link isSeamSegment}. + */ +const SEAM_TOLERANCE_DEGREES = 1e-6; + +/** + * Whether a source segment is a seam cut into the dataset to make it fit a rectangle, + * rather than a real edge of the feature. + * + * A plate that straddles the antimeridian is stored as a polygon slit open along + * ±180°, and one that reaches a pole is closed off along the pole itself — fourteen + * such segments in `PLATES`, plus two along the poles. They are not edges of anything, + * so they are never *stroked*: on the globe they would draw as bright lines up the + * middle of the Pacific and across the Arctic, and on the flat map they do the same as + * soon as the map is panned off centre and ±180° stops being the edge of the viewport. + * They are still *filled*, because the polygon needs them to close. + * + * Judged on the source coordinates, because a seam is a property of how the dataset + * was cut, not of where the reconstruction has since carried it. + */ +export function isSeamSegment(lonA: number, latA: number, lonB: number, latB: number): boolean { + const onAntimeridian = + Math.abs(Math.abs(lonA) - 180) < SEAM_TOLERANCE_DEGREES && Math.abs(Math.abs(lonB) - 180) < SEAM_TOLERANCE_DEGREES; + const alongPole = + Math.abs(Math.abs(latA) - 90) < SEAM_TOLERANCE_DEGREES && Math.abs(Math.abs(latB) - 90) < SEAM_TOLERANCE_DEGREES; + return onAntimeridian || alongPole; +} + export type EarthCanvasNodeOptions = CanvasNodeOptions; export abstract class EarthCanvasNode extends CanvasNode { diff --git a/src/plate-tectonics/view/GlobeCanvasNode.ts b/src/plate-tectonics/view/GlobeCanvasNode.ts index ac3b881..56bc6b1 100644 --- a/src/plate-tectonics/view/GlobeCanvasNode.ts +++ b/src/plate-tectonics/view/GlobeCanvasNode.ts @@ -31,10 +31,11 @@ */ import type { CanvasNodeOptions } from "scenerystack/scenery"; -import { type GlobeProjection, wrapLongitude } from "../../common/GlobeProjection.js"; +import { wrapLongitude } from "../../common/EarthProjection.js"; +import type { GlobeProjection } from "../../common/GlobeProjection.js"; import PlateTectonicsColors from "../../PlateTectonicsColors.js"; import type { PlateTectonicsModel } from "../model/PlateTectonicsModel.js"; -import { EarthCanvasNode, type RingMode } from "./EarthCanvasNode.js"; +import { EarthCanvasNode, isSeamSegment, type RingMode } from "./EarthCanvasNode.js"; const DEG_TO_RAD = Math.PI / 180; const RAD_TO_DEG = 180 / Math.PI; @@ -64,13 +65,6 @@ const MAX_SEGMENT_DEGREES = 5; /** Starting size of the scratch buffers, chosen to cover most features in one go. */ const INITIAL_BUFFER_CAPACITY = 1024; -/** - * How close to ±180° of longitude, or to a pole, a vertex has to be for its segment to - * count as a dataset seam. The seams sit on those lines exactly, so this only has to - * absorb the last digit of the stored coordinate — see {@link isSeamSegment}. - */ -const SEAM_TOLERANCE_DEGREES = 1e-6; - const mod2pi = (angle: number): number => ((angle % TWO_PI) + TWO_PI) % TWO_PI; export type GlobeCanvasNodeOptions = CanvasNodeOptions; @@ -573,26 +567,6 @@ function interpolateGreatCircle(lonA: number, latA: number, lonB: number, latB: * The separation is the flat-Earth approximation — near enough at these sizes, and it * only decides how finely to sample. */ -/** - * Whether a source segment is a seam cut into the dataset to make it fit a rectangle, - * rather than a real edge of the feature. - * - * A plate that straddles the antimeridian is stored as a polygon slit open along - * ±180°, and one that reaches a pole is closed off along the pole itself — fourteen - * such segments in `PLATES`, plus two along the poles. On the flat map they fall - * exactly on the edge of the viewport and are never seen. On a globe the antimeridian - * is an ordinary meridian, so they would draw as bright lines up the middle of the - * Pacific and across the Arctic; they are therefore not stroked. They are still - * *filled*, because the polygon needs them to close. - */ -function isSeamSegment(lonA: number, latA: number, lonB: number, latB: number): boolean { - const onAntimeridian = - Math.abs(Math.abs(lonA) - 180) < SEAM_TOLERANCE_DEGREES && Math.abs(Math.abs(lonB) - 180) < SEAM_TOLERANCE_DEGREES; - const alongPole = - Math.abs(Math.abs(latA) - 90) < SEAM_TOLERANCE_DEGREES && Math.abs(Math.abs(latB) - 90) < SEAM_TOLERANCE_DEGREES; - return onAntimeridian || alongPole; -} - function subdivisionsFor(lonA: number, latA: number, lonB: number, latB: number): number { const deltaLat = latB - latA; const deltaLon = wrapLongitude(lonB - lonA); diff --git a/src/plate-tectonics/view/MapCanvasNode.ts b/src/plate-tectonics/view/MapCanvasNode.ts index 7e2416c..e242e4c 100644 --- a/src/plate-tectonics/view/MapCanvasNode.ts +++ b/src/plate-tectonics/view/MapCanvasNode.ts @@ -9,6 +9,11 @@ * is peculiar to drawing a sphere on a rectangle: the antimeridian, circumpolar * rings, and ring closure. Each rule below is there because of a specific artifact; * read the comments before touching them. + * + * The map can be panned and zoomed, which turns the antimeridian from a fixed seam at + * the edge of the viewport into a seam that can be anywhere — so a feature is traced + * relative to wherever the camera is looking, and repeated a world-width either side + * whenever a neighbouring copy of the world would show it too. */ import type { Bounds2 } from "scenerystack/dot"; @@ -16,11 +21,23 @@ import type { CanvasNodeOptions } from "scenerystack/scenery"; import type { MapProjection } from "../../common/MapProjection.js"; import PlateTectonicsColors from "../../PlateTectonicsColors.js"; import type { PlateTectonicsModel } from "../model/PlateTectonicsModel.js"; -import { EarthCanvasNode, type RingMode } from "./EarthCanvasNode.js"; +import { EarthCanvasNode, isSeamSegment, type RingMode } from "./EarthCanvasNode.js"; /** Longitude jump (degrees) that means a polyline wrapped across the antimeridian. */ const ANTIMERIDIAN_JUMP = 180; +/** + * Whether the segment arriving at the vertex starting at index `i` of a flat + * `[lon, lat, …]` array is one of the seams the dataset was cut along. False at the + * first vertex, which no segment arrives at. + */ +function isSeamAt(coords: readonly number[], i: number): boolean { + return ( + i >= 2 && + isSeamSegment(coords[i - 2] as number, coords[i - 1] as number, coords[i] as number, coords[i + 1] as number) + ); +} + /** Longitude span above which a ring is treated as encircling a pole. */ const CIRCUMPOLAR_SPAN_DEGREES = 300; @@ -30,8 +47,9 @@ export class MapCanvasNode extends EarthCanvasNode { private readonly mapProjection: MapProjection; private readonly mapBounds: Bounds2; - /** True when the polyline most recently traced wrapped across the antimeridian. */ - private wrapped = false; + /** View-x extent of the polyline most recently traced at offset zero. */ + private featureMinX = 0; + private featureMaxX = 0; public constructor(model: PlateTectonicsModel, projection: MapProjection, options?: MapCanvasNodeOptions) { super(model, projection, options); @@ -49,32 +67,39 @@ export class MapCanvasNode extends EarthCanvasNode { /** * Paints the relief raster when it applies, and otherwise a flat ocean with - * coastlines on top. The raster is drawn straight across the viewport because it - * is rendered on exactly this grid by `npm run build-data`. + * coastlines on top. The raster covers exactly one world, because it is rendered on + * exactly this grid by `npm run build-data`, so it is drawn into the world rectangle + * and repeated either side for whichever part of the seam is on screen. */ protected override paintBase(context: CanvasRenderingContext2D): void { + // Ocean first even under the raster: the raster's copies meet at a fractional + // pixel once the map is panned, and ocean is a better colour to see through a + // hairline seam than whatever was on the canvas before. + context.fillStyle = PlateTectonicsColors.oceanColorProperty.value.toCSS(); + context.fillRect(this.mapBounds.minX, this.mapBounds.minY, this.mapBounds.width, this.mapBounds.height); + if (this.showRelief && this.reliefImage) { - context.drawImage( - this.reliefImage, - this.mapBounds.minX, - this.mapBounds.minY, - this.mapBounds.width, - this.mapBounds.height, - ); + const left = this.mapProjection.viewX(-180); + const top = this.mapProjection.viewY(90); + const worldWidth = this.mapProjection.worldWidth; + const worldHeight = this.mapProjection.worldHeight; + for (const offsetX of [-worldWidth, 0, worldWidth]) { + if (this.worldCopyVisible(left, left + worldWidth, offsetX)) { + context.drawImage(this.reliefImage, left + offsetX, top, worldWidth, worldHeight); + } + } return; } - context.fillStyle = PlateTectonicsColors.oceanColorProperty.value.toCSS(); - context.fillRect(this.mapBounds.minX, this.mapBounds.minY, this.mapBounds.width, this.mapBounds.height); - this.paintLandRings(context); } // ── Path helpers ──────────────────────────────────────────────────────────── /** - * Appends one feature, repeating it either side of the map when it wraps across - * the antimeridian so the wrapped half is not simply missing. + * Appends one feature, repeating it a world-width either side whenever the copy of + * the world there would put it on screen — which is what covers the antimeridian + * seam, wherever the camera has moved it to. */ protected override appendFeature( context: CanvasRenderingContext2D, @@ -84,22 +109,35 @@ export class MapCanvasNode extends EarthCanvasNode { tearAtFrameChanges = false, ): void { this.appendPolyline(context, coords, frames, mode, tearAtFrameChanges, 0); - if (this.wrapped) { - // The feature runs off one side of the map, so repeat it a world-width either - // way; the clip keeps whichever copy is on screen. - this.appendPolyline(context, coords, frames, mode, tearAtFrameChanges, -this.mapBounds.width); - this.appendPolyline(context, coords, frames, mode, tearAtFrameChanges, this.mapBounds.width); + + const worldWidth = this.mapProjection.worldWidth; + for (const offsetX of [-worldWidth, worldWidth]) { + if (this.worldCopyVisible(this.featureMinX, this.featureMaxX, offsetX)) { + this.appendPolyline(context, coords, frames, mode, tearAtFrameChanges, offsetX); + } } } /** - * Appends one polyline to the current path, shifted by `offsetX` view pixels. + * True when something spanning `[minX, maxX]` in view pixels, shifted by `offsetX`, + * has any part inside the viewport. One world-width either side is enough: no + * feature in the data spans more than a full turn of longitude, so a copy two + * worlds away can never reach back into a viewport the nearer copy misses. + */ + private worldCopyVisible(minX: number, maxX: number, offsetX: number): boolean { + return maxX + offsetX >= this.mapBounds.minX && minX + offsetX <= this.mapBounds.maxX; + } + + /** + * Appends one polyline to the current path, shifted by `offsetX` view pixels, and — + * at offset zero — records its view-x extent for {@link appendFeature}. * * Longitudes are unwrapped as the polyline is walked — each vertex is nudged by * whole turns so it stays within half a turn of the previous one — which keeps a * feature that straddles the antimeridian in one piece instead of stringing a - * chord back across the map. {@link wrapped} records that this happened, so the - * caller knows to repeat the feature either side. + * chord back across the map. The *first* vertex is unwrapped against the camera + * instead, which puts the whole feature on the copy of the world the map is + * currently looking at. */ private appendPolyline( context: CanvasRenderingContext2D, @@ -115,37 +153,48 @@ export class MapCanvasNode extends EarthCanvasNode { // to be filled across that gap, but drawing the outline across it would leave a // stray line over the ocean, so the outline is broken there instead. const breakAtFrameChanges = tearAtFrameChanges && perVertex && mode !== "fill" && !this.reconstruction.isPresentDay; + // ±180° used to be the edge of the viewport, where the seams the dataset was cut + // along could not be seen. Panning moves that edge, so an outline now has to be + // broken at them or the Pacific gets a bright line up the middle of it. + const breakAtSeams = mode !== "fill"; + const centerLon = this.mapProjection.centerLongitudeProperty.value; let previousFrame = -1; let previousLon = Number.NaN; + // Whole turns that carry the feature to the camera, fixed at the first vertex, + // kept apart from the turns the walk accumulates so that `turns` below keeps + // meaning "this ring went right round the world". + let originTurns = 0; let turns = 0; let firstX = 0; let lastX = 0; let latitudeSum = 0; let minLon = Number.POSITIVE_INFINITY; let maxLon = Number.NEGATIVE_INFINITY; - if (offsetX === 0) { - this.wrapped = false; - } + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; for (let i = 0; i < coords.length; i += 2) { const vertex = i / 2; const frame = perVertex ? ((frames[vertex] as number) ?? 0) : frames; this.reconstruction.transform(coords[i] as number, coords[i + 1] as number, frame); - let lon = this.reconstruction.lon + turns * 360; + if (vertex === 0) { + originTurns = Math.round((centerLon - this.reconstruction.lon) / 360); + } + let lon = this.reconstruction.lon + (originTurns + turns) * 360; if (vertex > 0 && Math.abs(lon - previousLon) > ANTIMERIDIAN_JUMP) { const correction = Math.round((previousLon - lon) / 360); turns += correction; lon += correction * 360; - this.wrapped = true; } const x = this.mapProjection.viewX(lon) + offsetX; const y = this.mapProjection.viewY(this.reconstruction.lat); + const torn = (breakAtFrameChanges && frame !== previousFrame) || (breakAtSeams && isSeamAt(coords, i)); if (vertex === 0) { context.moveTo(x, y); firstX = x; - } else if (breakAtFrameChanges && frame !== previousFrame) { + } else if (torn) { context.moveTo(x, y); } else { context.lineTo(x, y); @@ -155,25 +204,45 @@ export class MapCanvasNode extends EarthCanvasNode { latitudeSum += this.reconstruction.lat; minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); previousLon = lon; } + if (offsetX === 0) { + this.featureMinX = minX; + this.featureMaxX = maxX; + } + // Every ring in the data repeats its first vertex at the end, so an outline is // already closed and needs no `closePath` — which is just as well, because on a // ring that has been unwrapped past the antimeridian `closePath` would draw a - // chord straight across the map. - // - // A fill does need closing, and a ring that gains a whole turn of longitude - // encircles a pole — the North American plate reaches right around the Arctic, - // the Antarctic plate around the South Pole — so its fill is routed over that - // pole rather than being cut straight across. + // chord straight across the map. A fill does need closing. if (mode === "fill") { - if (turns !== 0 || maxLon - minLon > CIRCUMPOLAR_SPAN_DEGREES) { - const poleY = this.mapProjection.viewY(latitudeSum >= 0 ? 90 : -90); - context.lineTo(lastX, poleY); - context.lineTo(firstX, poleY); - } - context.closePath(); + this.closeFill(context, { + circumpolar: turns !== 0 || maxLon - minLon > CIRCUMPOLAR_SPAN_DEGREES, + northern: latitudeSum >= 0, + firstX, + lastX, + }); + } + } + + /** + * Closes a filled ring. A ring that gains a whole turn of longitude encircles a pole + * — the North American plate reaches right around the Arctic, the Antarctic plate + * around the South Pole — so its fill is routed over that pole rather than being cut + * straight across the map. + */ + private closeFill( + context: CanvasRenderingContext2D, + ring: { circumpolar: boolean; northern: boolean; firstX: number; lastX: number }, + ): void { + if (ring.circumpolar) { + const poleY = this.mapProjection.viewY(ring.northern ? 90 : -90); + context.lineTo(ring.lastX, poleY); + context.lineTo(ring.firstX, poleY); } + context.closePath(); } } diff --git a/src/plate-tectonics/view/PlateTectonicsKeyboardHelpContent.ts b/src/plate-tectonics/view/PlateTectonicsKeyboardHelpContent.ts index 24e25ff..685ddf6 100644 --- a/src/plate-tectonics/view/PlateTectonicsKeyboardHelpContent.ts +++ b/src/plate-tectonics/view/PlateTectonicsKeyboardHelpContent.ts @@ -4,12 +4,12 @@ * Content for the keyboard-help dialog (the "?" button in the navigation bar). * * The sim has four kinds of keyboard interaction, so the dialog has a section for - * each: the geological-time slider, turning the globe, the view combo box, and the + * each: the geological-time slider, moving the Earth, the view combo box, and the * usual basic actions (tab, checkboxes and radio buttons, Reset All). * - * Turning the globe is documented with the stock "move draggable items" section: - * the globe *is* the draggable item, and its arrow keys behave exactly as that - * section describes. + * Moving the Earth — turning the globe, panning the flat map — is documented with the + * stock "move draggable items" section: whichever global view is showing *is* the + * draggable item, and both take the arrow keys exactly as that section describes. */ import { diff --git a/src/plate-tectonics/view/PlateTectonicsScreenView.ts b/src/plate-tectonics/view/PlateTectonicsScreenView.ts index 673351b..158de51 100644 --- a/src/plate-tectonics/view/PlateTectonicsScreenView.ts +++ b/src/plate-tectonics/view/PlateTectonicsScreenView.ts @@ -17,18 +17,27 @@ * The flat map, the globe and the cross-section share one viewport; the view selector * and the globe checkbox decide which of the three is visible. The relief raster is * fetched here, once, and handed to both map canvases when it has decoded. + * + * Both global views can be moved: the globe turns, and the flat map pans and zooms. + * Their cameras live here in the view rather than in the model, because a camera is + * a way of looking at the Earth rather than a fact about it — which is why Reset All + * puts both of them back through {@link PlateTectonicsScreenView.reset}. */ import { Shape } from "scenerystack/kite"; import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; import { Circle, Node, Rectangle, Text } from "scenerystack/scenery"; -import { PhetFont, ResetAllButton } from "scenerystack/scenery-phet"; +import { PhetFont, PlusMinusZoomButtonGroup, ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; import { attachGlobeRotation } from "../../common/attachGlobeRotation.js"; +import { attachMapNavigation } from "../../common/attachMapNavigation.js"; import reliefImageUrl from "../../common/data/generated/relief.png"; import { GlobeProjection } from "../../common/GlobeProjection.js"; import { MapProjection } from "../../common/MapProjection.js"; -import { FLAT_RESET_ALL_BUTTON_OPTIONS } from "../../common/PlateTectonicsButtonOptions.js"; +import { + FLAT_RECTANGULAR_BUTTON_OPTIONS, + FLAT_RESET_ALL_BUTTON_OPTIONS, +} from "../../common/PlateTectonicsButtonOptions.js"; import { StringManager } from "../../i18n/StringManager.js"; import PlateTectonicsColors from "../../PlateTectonicsColors.js"; import { MAP_VIEW_BOUNDS, PANEL_SPACING, SCREEN_VIEW_MARGIN } from "../../PlateTectonicsConstants.js"; @@ -49,9 +58,13 @@ const NOTE_FONT = new PhetFont(11); export type PlateTectonicsScreenViewOptions = ScreenViewOptions; +/** Gap between the zoom buttons and the corner of the viewport they sit in. */ +const ZOOM_BUTTON_MARGIN = 6; + export class PlateTectonicsScreenView extends ScreenView { private readonly mapCanvas: MapCanvasNode; private readonly globeCanvas: GlobeCanvasNode; + private readonly mapProjection: MapProjection; private readonly globeProjection: GlobeProjection; public constructor( @@ -67,16 +80,30 @@ export class PlateTectonicsScreenView extends ScreenView { const strings = StringManager.getInstance(); const a11y = strings.getPlateTectonicsA11yStrings().controls; - const projection = new MapProjection(MAP_VIEW_BOUNDS); + this.mapProjection = new MapProjection(MAP_VIEW_BOUNDS); this.globeProjection = new GlobeProjection(MAP_VIEW_BOUNDS); // ── Viewport ────────────────────────────────────────────────────────────── // Three things share the viewport: the flat map, the globe, and a cross-section. // Each carries its own plate-label overlay, because the labels are positioned by // the projection they belong to. - this.mapCanvas = new MapCanvasNode(model, projection); - const flatOverlay = new PlateOverlayNode(model, projection); - const flatView = new Node({ children: [this.mapCanvas, flatOverlay] }); + this.mapCanvas = new MapCanvasNode(model, this.mapProjection); + const flatOverlay = new PlateOverlayNode(model, this.mapProjection); + // The canvas clips its own painting; the labels are Scenery nodes, so once the + // map can be panned they need clipping too or one near the edge spills over the + // frame and onto the legend. + flatOverlay.clipArea = Shape.bounds(MAP_VIEW_BOUNDS); + const flatView = attachMapNavigation(new Node({ children: [this.mapCanvas, flatOverlay] }), { + projection: this.mapProjection, + accessibleNameProperty: a11y.mapStringProperty, + accessibleHelpTextProperty: a11y.mapHelpStringProperty, + }); + // Only the viewport takes the drag, and the focus highlight traces it, so the map + // reads as the one rectangular thing it is. + const mapShape = Shape.bounds(MAP_VIEW_BOUNDS); + flatView.mouseArea = mapShape; + flatView.touchArea = mapShape; + flatView.focusHighlight = mapShape; this.globeCanvas = new GlobeCanvasNode(model, this.globeProjection); const globeOverlay = new PlateOverlayNode(model, this.globeProjection); @@ -107,13 +134,37 @@ export class PlateTectonicsScreenView extends ScreenView { cornerRadius: 2, }); + // Zoom sits in the corner of the map it acts on, the way it does on any map, and + // over the Southern Ocean rather than over anything worth looking at. + const mapZoomButtons = new PlusMinusZoomButtonGroup(this.mapProjection.zoomLevelProperty, { + orientation: "horizontal", + spacing: 4, + buttonOptions: { + ...FLAT_RECTANGULAR_BUTTON_OPTIONS, + baseColor: PlateTectonicsColors.controlSurfaceColorProperty, + stroke: PlateTectonicsColors.panelBorderColorProperty, + cornerRadius: 3, + xMargin: 7, + yMargin: 7, + }, + iconOptions: { fill: PlateTectonicsColors.controlSurfaceTextColorProperty }, + accessibleNameZoomIn: a11y.zoomInStringProperty, + accessibleNameZoomOut: a11y.zoomOutStringProperty, + accessibleHelpTextZoomIn: a11y.zoomHelpStringProperty, + accessibleHelpTextZoomOut: a11y.zoomHelpStringProperty, + right: MAP_VIEW_BOUNDS.maxX - ZOOM_BUTTON_MARGIN, + bottom: MAP_VIEW_BOUNDS.maxY - ZOOM_BUTTON_MARGIN, + }); + this.addChild(flatView); this.addChild(globeView); this.addChild(crossSectionView); this.addChild(viewportFrame); + this.addChild(mapZoomButtons); model.isFlatMapProperty.link((isFlatMap: boolean) => { flatView.visible = isFlatMap; + mapZoomButtons.visible = isFlatMap; }); model.isGlobeProperty.link((isGlobe: boolean) => { globeView.visible = isGlobe; @@ -195,11 +246,14 @@ export class PlateTectonicsScreenView extends ScreenView { this.loadReliefImage(); // ── Accessibility: keyboard / reading traversal order ───────────────────── - // The globe comes first: it is the only thing in the play area that can be - // operated, so a keyboard user should reach it before the controls. + // The map comes first: it is the only thing in the play area that can be + // operated, so a keyboard user should reach it before the controls. Whichever of + // the flat map and the globe is hidden drops out of the order on its own. this.addChild( new Node({ pdomOrder: [ + flatView, + mapZoomButtons, globeView, ...viewPanel.focusOrder, ...layerPanel.focusOrder, @@ -223,8 +277,9 @@ export class PlateTectonicsScreenView extends ScreenView { image.src = reliefImageUrl; } - /** Resets view-side state: only the globe's camera, which is not model state. */ + /** Resets view-side state: the two cameras, which are not model state. */ public reset(): void { + this.mapProjection.reset(); this.globeProjection.reset(); } diff --git a/src/plate-tectonics/view/ViewControlPanel.ts b/src/plate-tectonics/view/ViewControlPanel.ts index 1e1684e..fb85bd7 100644 --- a/src/plate-tectonics/view/ViewControlPanel.ts +++ b/src/plate-tectonics/view/ViewControlPanel.ts @@ -106,8 +106,9 @@ export class ViewControlPanel extends PlateTectonicsPanel { globeCheckbox.enabled = !isCrossSection; }); - // Shown only while the globe is up, where it is the one thing a first-time user - // will not guess: that the Earth on screen can be taken hold of and turned. + // One hint per global view, because in both cases the thing a first-time user + // will not guess is the same: that the Earth on screen can be taken hold of and + // moved. Only one of the two is ever showing, so they share a slot. const globeHint = new Text(viewStrings.globeHintStringProperty, { font: HINT_FONT, fill: PlateTectonicsColors.secondaryTextColorProperty, @@ -117,6 +118,15 @@ export class ViewControlPanel extends PlateTectonicsPanel { globeHint.visible = isGlobe; }); + const mapHint = new Text(viewStrings.mapHintStringProperty, { + font: HINT_FONT, + fill: PlateTectonicsColors.secondaryTextColorProperty, + maxWidth: CONTROL_PANEL_WIDTH - 30, + }); + model.isFlatMapProperty.link((isFlatMap: boolean) => { + mapHint.visible = isFlatMap; + }); + const content = new VBox({ spacing: 6, align: "left", @@ -128,6 +138,7 @@ export class ViewControlPanel extends PlateTectonicsPanel { comboBox, globeCheckbox, globeHint, + mapHint, ], }); diff --git a/tests/GlobeProjection.test.ts b/tests/GlobeProjection.test.ts index c11841a..f68c160 100644 --- a/tests/GlobeProjection.test.ts +++ b/tests/GlobeProjection.test.ts @@ -8,7 +8,8 @@ import { Bounds2 } from "scenerystack/dot"; import { describe, expect, it } from "vitest"; -import { GlobeProjection, wrapLongitude } from "../src/common/GlobeProjection.js"; +import { wrapLongitude } from "../src/common/EarthProjection.js"; +import { GlobeProjection } from "../src/common/GlobeProjection.js"; import { GLOBE_INITIAL_CENTER_LAT, GLOBE_INITIAL_CENTER_LON } from "../src/PlateTectonicsConstants.js"; const bounds = new Bounds2(10, 20, 738, 384); diff --git a/tests/MapProjection.test.ts b/tests/MapProjection.test.ts index f7ef67f..fbc6dc5 100644 --- a/tests/MapProjection.test.ts +++ b/tests/MapProjection.test.ts @@ -2,17 +2,22 @@ * MapProjection.test.ts * * The equirectangular projection that keeps every overlay in register with the - * relief raster. + * relief raster, and the camera that pans and zooms it. */ import { Bounds2 } from "scenerystack/dot"; import { describe, expect, it } from "vitest"; import { MapProjection } from "../src/common/MapProjection.js"; -import { MAP_VIEW_BOUNDS } from "../src/PlateTectonicsConstants.js"; +import { MAP_MAX_ZOOM_LEVEL, MAP_VIEW_BOUNDS } from "../src/PlateTectonicsConstants.js"; const bounds = new Bounds2(10, 20, 730, 380); const projection = new MapProjection(bounds); +/** A projection of its own, so a test that moves the camera cannot disturb another. */ +function freshProjection(): MapProjection { + return new MapProjection(bounds); +} + describe("MapProjection", () => { it("maps the corners of the world to the corners of the viewport", () => { expect(projection.viewX(-180)).toBeCloseTo(bounds.minX, 9); @@ -39,7 +44,7 @@ describe("MapProjection", () => { } }); - it("shows the whole world, so every point projects on screen", () => { + it("shows the whole world at level 0, so every point projects on screen", () => { for (const [lon, lat] of [ [0, 0], [-180, -90], @@ -47,7 +52,6 @@ describe("MapProjection", () => { [139.7, 35.7], ] as const) { expect(projection.project(lon, lat)).toBe(true); - expect(projection.x).toBeCloseTo(projection.viewX(lon), 9); expect(projection.y).toBeCloseTo(projection.viewY(lat), 9); } }); @@ -75,3 +79,114 @@ describe("MapProjection", () => { expect(simProjection.pixelsPerDegree).toBeCloseTo(perDegreeX, 9); }); }); + +describe("MapProjection camera", () => { + it("opens on the whole world, centred on the equator and the prime meridian", () => { + const map = freshProjection(); + expect(map.zoomLevelProperty.value).toBe(0); + expect(map.centerLongitudeProperty.value).toBe(0); + expect(map.centerLatitudeProperty.value).toBe(0); + expect(map.isWholeWorld).toBe(true); + expect(map.worldWidth).toBeCloseTo(bounds.width, 9); + }); + + it("puts the camera's longitude at the centre of the viewport", () => { + const map = freshProjection(); + map.panBy(90, 0); + expect(map.centerLongitudeProperty.value).toBeCloseTo(90, 9); + expect(map.viewX(90)).toBeCloseTo(bounds.centerX, 9); + }); + + it("wraps eastward panning instead of stopping at the antimeridian", () => { + const map = freshProjection(); + map.panBy(200, 0); + expect(map.centerLongitudeProperty.value).toBeCloseTo(-160, 9); + map.panBy(-40, 0); + expect(map.centerLongitudeProperty.value).toBeCloseTo(160, 9); + }); + + it("places a point on the copy of the world the camera is looking at", () => { + // Centred on the Pacific, Fiji (178°E) and Samoa (172°W) are neighbours, so they + // must land near each other rather than at opposite edges of the viewport. + const map = freshProjection(); + map.panBy(175, 0); + + map.project(178, -18); + const fijiX = map.x; + map.project(-172, -14); + const samoaX = map.x; + + expect(Math.abs(samoaX - fijiX)).toBeLessThan(map.pixelsPerDegree * 20); + expect(map.project(178, -18)).toBe(true); + }); + + it("reports a point outside the viewport as not on screen", () => { + const map = freshProjection(); + map.zoomLevelProperty.value = 2; + expect(map.project(0, 0)).toBe(true); + // A quarter turn away at 4×, which is four viewport widths off to the side. + expect(map.project(90, 0)).toBe(false); + }); + + it("cannot be panned off the equator until it is zoomed in", () => { + const map = freshProjection(); + expect(map.latitudeLimit).toBe(0); + map.panBy(0, 40); + expect(map.centerLatitudeProperty.value).toBe(0); + + map.zoomLevelProperty.value = 1; + expect(map.latitudeLimit).toBeCloseTo(45, 9); + map.panBy(0, 40); + expect(map.centerLatitudeProperty.value).toBeCloseTo(40, 9); + map.panBy(0, 40); + expect(map.centerLatitudeProperty.value).toBeCloseTo(45, 9); + }); + + it("keeps the map covering the viewport when it is zoomed back out", () => { + const map = freshProjection(); + map.zoomLevelProperty.value = MAP_MAX_ZOOM_LEVEL; + map.panBy(0, 90); + expect(map.centerLatitudeProperty.value).toBeCloseTo(map.latitudeLimit, 9); + + map.zoomLevelProperty.value = 0; + expect(map.centerLatitudeProperty.value).toBe(0); + expect(map.viewY(90)).toBeCloseTo(bounds.minY, 9); + expect(map.viewY(-90)).toBeCloseTo(bounds.maxY, 9); + }); + + it("scales by a factor of two per zoom level", () => { + const map = freshProjection(); + const wholeWorld = map.pixelsPerDegree; + map.zoomLevelProperty.value = 3; + expect(map.pixelsPerDegree).toBeCloseTo(wholeWorld * 8, 9); + expect(map.worldWidth).toBeCloseTo(bounds.width * 8, 9); + expect(map.degreesPerPixel).toBeCloseTo(1 / map.pixelsPerDegree, 9); + }); + + it("round-trips longitude and latitude at every zoom level", () => { + const map = freshProjection(); + for (let level = 0; level <= MAP_MAX_ZOOM_LEVEL; level++) { + map.zoomLevelProperty.value = level; + map.panBy(37, 12); + for (const [lon, lat] of [ + [0, 0], + [-155.5, 19.6], + [139.7, 35.7], + ] as const) { + expect(map.longitudeAt(map.viewX(lon))).toBeCloseTo(lon, 9); + expect(map.latitudeAt(map.viewY(lat))).toBeCloseTo(lat, 9); + } + } + }); + + it("resets to the whole world", () => { + const map = freshProjection(); + map.zoomLevelProperty.value = 2; + map.panBy(120, 30); + + map.reset(); + expect(map.zoomLevelProperty.value).toBe(0); + expect(map.centerLongitudeProperty.value).toBe(0); + expect(map.centerLatitudeProperty.value).toBe(0); + }); +}); diff --git a/tests/PlateTectonicsModel.test.ts b/tests/PlateTectonicsModel.test.ts index 4780987..9c946b8 100644 --- a/tests/PlateTectonicsModel.test.ts +++ b/tests/PlateTectonicsModel.test.ts @@ -12,17 +12,17 @@ import { depthBand, passesDepthFilter } from "../src/plate-tectonics/model/Earth import { PlateTectonicsModel, TIME_RANGE } from "../src/plate-tectonics/model/PlateTectonicsModel.js"; describe("PlateTectonicsModel", () => { - it("starts with every layer on, at the present day, on the flat global map", () => { + it("starts with every layer off, at the present day, on the flat global map", () => { const model = new PlateTectonicsModel(); - expect(model.showPlatesProperty.value).toBe(true); + expect(model.showPlatesProperty.value).toBe(false); expect(model.showGlobeProperty.value).toBe(false); expect(model.isFlatMapProperty.value).toBe(true); expect(model.isGlobeProperty.value).toBe(false); - expect(model.showBoundariesProperty.value).toBe(true); - expect(model.showVectorsProperty.value).toBe(true); - expect(model.showEarthquakesProperty.value).toBe(true); - expect(model.showVolcanoesProperty.value).toBe(true); - expect(model.showTopographyProperty.value).toBe(true); + expect(model.showBoundariesProperty.value).toBe(false); + expect(model.showVectorsProperty.value).toBe(false); + expect(model.showEarthquakesProperty.value).toBe(false); + expect(model.showVolcanoesProperty.value).toBe(false); + expect(model.showTopographyProperty.value).toBe(false); expect(model.earthquakeDepthFilterProperty.value).toBe("all"); expect(model.selectedViewProperty.value).toBe("global"); expect(model.timeMillionsOfYearsProperty.value).toBe(0); @@ -112,25 +112,25 @@ describe("PlateTectonicsModel", () => { it("resetTime returns to the present without touching the layers", () => { const model = new PlateTectonicsModel(); - model.showVolcanoesProperty.value = false; + model.showVolcanoesProperty.value = true; model.timeMillionsOfYearsProperty.value = -20; model.timer.isPlayingProperty.value = true; model.resetTime(); expect(model.timeMillionsOfYearsProperty.value).toBe(0); expect(model.timer.isPlayingProperty.value).toBe(false); - expect(model.showVolcanoesProperty.value).toBe(false); + expect(model.showVolcanoesProperty.value).toBe(true); }); it("reset() restores every property", () => { const model = new PlateTectonicsModel(); - model.showPlatesProperty.value = false; + model.showPlatesProperty.value = true; model.showGlobeProperty.value = true; - model.showBoundariesProperty.value = false; - model.showVectorsProperty.value = false; - model.showEarthquakesProperty.value = false; - model.showVolcanoesProperty.value = false; - model.showTopographyProperty.value = false; + model.showBoundariesProperty.value = true; + model.showVectorsProperty.value = true; + model.showEarthquakesProperty.value = true; + model.showVolcanoesProperty.value = true; + model.showTopographyProperty.value = true; model.earthquakeDepthFilterProperty.value = "deep"; model.selectedViewProperty.value = "transform"; model.timeSpeedProperty.value = TimeSpeed.FAST; @@ -138,13 +138,13 @@ describe("PlateTectonicsModel", () => { model.reset(); - expect(model.showPlatesProperty.value).toBe(true); + expect(model.showPlatesProperty.value).toBe(false); expect(model.showGlobeProperty.value).toBe(false); - expect(model.showBoundariesProperty.value).toBe(true); - expect(model.showVectorsProperty.value).toBe(true); - expect(model.showEarthquakesProperty.value).toBe(true); - expect(model.showVolcanoesProperty.value).toBe(true); - expect(model.showTopographyProperty.value).toBe(true); + expect(model.showBoundariesProperty.value).toBe(false); + expect(model.showVectorsProperty.value).toBe(false); + expect(model.showEarthquakesProperty.value).toBe(false); + expect(model.showVolcanoesProperty.value).toBe(false); + expect(model.showTopographyProperty.value).toBe(false); expect(model.earthquakeDepthFilterProperty.value).toBe("all"); expect(model.selectedViewProperty.value).toBe("global"); expect(model.timeSpeedProperty.value).toBe(TimeSpeed.NORMAL);