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
21 changes: 21 additions & 0 deletions doc/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ models, for the benefit of one painter.
a compressed deep one; only the new one carries a zoom level, and only the old one knows
about DEM profiles.

### Painting order on Plate Motion

`PlateMotionCanvasNode` paints sky, then sea, then mantle, then the plates. The mantle is
**clipped to below the ground surface** — the merge of both plates' `crustTop` polylines,
sorted by x. An unclipped mantle band starts at sea level and paints straight over the
water, which is what made `showSeawaterProperty` a no-op and left every ocean floor with
sky above it rather than sea. The clip is also what lets a mountain belt standing above
the waterline still get mantle painted underneath it.

Two related traps, both handled in that file:

- **A `PlateOutline`'s three polylines must run the same direction along x.** `fillBand`
closes a band by walking the top forward and the base back; two that disagree fold the
band into a bowtie. `tests/PlateGeometry.test.ts` asserts the invariant for every
pairing, motion and time, because the failure is silent in the model and spectacular on
screen.
- **`CrossSectionScale.y` clamps.** A slab that has descended past the bottom of the view
does not vanish — every point below the floor lands *on* the floor, so it is drawn as a
horizontal smear along the bottom edge with its arrow-heads strung out sideways.
`paintSlab` trims the centreline at the first point past `scale.bottomM`.

## Time as a pure parameter

PhET's Plate Motion tab accumulated geometry frame by frame, mutating arrays of samples
Expand Down
15 changes: 15 additions & 0 deletions doc/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,21 @@ Old ocean floor is denser and its lithosphere thicker than young ocean floor bec
has had longer to cool. That single difference is what lets the screen answer "which
one subducts?" with age rather than composition.

The mantle on this screen is drawn at three densities, not one, because they are three
different temperatures of the same rock:

| Material | Density kg/m³ | Why |
|---|---|---|
| Asthenosphere | 3300 | The hot, weak mantle the plates ride on |
| Lithospheric mantle | 3400 | ~1000 K colder; α ≈ 3 × 10⁻⁵ /K puts it ~3% denser |
| Descending slab | 3450 | Colder still, and thick enough to stay that way as it sinks |

The excess density of the cold lithosphere over the asthenosphere beneath it *is* slab
pull — it is why a cooled plate can sink at all. Painting all three at one value, which
is what the screen used to do, erased that from density mode: a plate appeared to be
crust alone, floating on nothing, and the slab was invisible against the mantle it was
descending through.

### What is allowed, and why

| Motion | Plates | Result | Runs for |
Expand Down
26 changes: 26 additions & 0 deletions src/PlateTectonicsConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,30 @@ export const OLD_OCEANIC_PLATE_TOP_M = -4000;
export const OLD_OCEANIC_PLATE_BASE_M = -10000;
export const OLD_OCEANIC_PLATE_MANTLE_LITHOSPHERE_M = 55000;

/**
* Density of the rigid mantle inside a plate, below its crust, kg/m³.
*
* Deliberately higher than {@link MANTLE_DENSITY_KG_M3}, which stands for the hotter
* asthenosphere the plates ride on. The two are the same rock; the difference is
* thermal, and it is the whole reason plate tectonics happens. Lithospheric mantle
* averages something like 1000 K colder than the asthenosphere beneath it, and with a
* coefficient of thermal expansion around 3 × 10⁻⁵ /K that makes it roughly 3% denser.
* That excess is what makes a cooled plate negatively buoyant, and therefore what makes
* old ocean floor able to sink. Painting the plate and the asthenosphere at one density
* would erase the quantity in density mode.
*/
export const LITHOSPHERIC_MANTLE_DENSITY_KG_M3 = 3400;

/**
* Density of a descending slab, kg/m³.
*
* Denser still than the lithospheric mantle it is made of: a slab is thick enough to keep
* its cold interior on the way down, so it warms far more slowly than it descends and its
* contrast against the surrounding mantle grows with depth rather than fading. That
* persistent excess density is slab pull.
*/
export const SLAB_DENSITY_KG_M3 = 3450;

/** Speed a plate moves at the boundary, m per million years — i.e. 15 mm/year. */
export const PLATE_SPEED_M_PER_MYR = 15000;

Expand Down Expand Up @@ -456,6 +480,8 @@ PlateTectonicsNamespace.register("PlateTectonicsConstants", {
OLD_OCEANIC_PLATE_TOP_M,
OLD_OCEANIC_PLATE_BASE_M,
OLD_OCEANIC_PLATE_MANTLE_LITHOSPHERE_M,
LITHOSPHERIC_MANTLE_DENSITY_KG_M3,
SLAB_DENSITY_KG_M3,
PLATE_SPEED_M_PER_MYR,
SUBDUCTION_ARC_RADII_M,
SUBDUCTION_ARC_ANGLE_FRACTIONS,
Expand Down
17 changes: 15 additions & 2 deletions src/crust/model/CrustModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,22 @@ export class CrustModel implements TModel {
];
}

/** The block containing a given model x, or null between and beyond them. */
/**
* The block containing a given model x, or null between and beyond them.
*
* Half-open on the right so neighbouring blocks do not both claim a shared edge, except
* at the outermost edge of the last block, which has no neighbour to hand the point on
* to. Without that exception the right-hand edge of the viewport — which is exactly that
* outermost edge — reads as being outside every block, so the painter and the probe both
* treat the last sliver of the picture as open mantle.
*/
public columnAt(xM: number): CrustColumn | null {
return this.columns.find((column) => xM >= column.leftM && xM < column.rightM) ?? null;
const columns = this.columns;
const last = columns[columns.length - 1];
if (last && xM >= last.leftM && xM <= last.rightM) {
return last;
}
return columns.find((column) => xM >= column.leftM && xM < column.rightM) ?? null;
}

/** Which named shell a point is in, for the probe's readout and the labels. */
Expand Down
31 changes: 22 additions & 9 deletions src/crust/view/CrustLabelsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const BLOCK_LABEL_FONT = new PhetFont({ size: 12, weight: "bold" });
const LAYER_LABEL_FONT = new PhetFont(12);
const SEA_LEVEL_FONT = new PhetFont(10);

/** Gap between a block label and the surface it names, view pixels. */
const BLOCK_LABEL_MARGIN = 6;

export type CrustLabelsNodeOptions = NodeOptions;

export class CrustLabelsNode extends Node {
Expand Down Expand Up @@ -113,15 +116,25 @@ export class CrustLabelsNode extends Node {
return;
}
const centreX = scale.x((column.leftM + column.rightM) / 2);
this.addChild(
new Text(name, {
font: BLOCK_LABEL_FONT,
fill: PlateTectonicsColors.textColorProperty,
centerX: centreX,
bottom: scale.y(column.elevationM) - 6,
maxWidth: (bounds.width / model.columns.length) * 0.9,
}),
);
const surfaceY = scale.y(column.elevationM);
const label = new Text(name, {
font: BLOCK_LABEL_FONT,
fill: PlateTectonicsColors.textColorProperty,
centerX: centreX,
maxWidth: (bounds.width / model.columns.length) * 0.9,
});

// A block standing above the water gets its name in the sky above it. A block
// whose surface is under water gets it just *inside* the rock instead: the space
// above such a block is only a few pixels of sea, and a label placed there lands
// on the sea-level line and its caption — which is exactly where the user's own
// block sits at its default thickness.
if (surfaceY <= seaLevelY - label.height - BLOCK_LABEL_MARGIN) {
label.bottom = surfaceY - BLOCK_LABEL_MARGIN;
} else {
label.top = surfaceY + BLOCK_LABEL_MARGIN;
}
this.addChild(label);
});
}

Expand Down
14 changes: 12 additions & 2 deletions src/plate-motion/model/PlateGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ import { behaviorFor, type MotionType, subductingSide } from "./BoundaryRules.js
import { crustThickness, lithosphereBaseM, type PlateType, plateProperties } from "./PlateType.js";
import { SlabCurve, slabHinge } from "./SlabCurve.js";

/** One plate, as three polylines from its outer edge in towards the boundary. */
/**
* One plate, as three polylines across it.
*
* The invariant every producer here has to keep is that all three run in the *same*
* direction along x. The painter closes a band by walking one polyline forward and the
* other back, so two that disagree produce a self-crossing bowtie rather than a plate.
* Which direction that is does not matter, and it differs between behaviours.
*/
export type PlateOutline = {
readonly crustTop: readonly Vector2[];
readonly crustBase: readonly Vector2[];
Expand Down Expand Up @@ -213,8 +220,11 @@ function subductionGeometry(left: PlateType, right: PlateType, down: "left" | "r
const fromHinge = Math.abs(xM) / 120000;
top.push(new Vector2(xM, crustTopM - trenchDepthM * Math.exp(-fromHinge * fromHinge)));
}
// All three polylines run boundary → outer edge. The painter closes a band by walking
// the top forward and the base back, so a polyline that ran the other way would fold
// the band into a bowtie rather than reversing it harmlessly.
return {
crustTop: top.reverse(),
crustTop: top,
crustBase: [new Vector2(0, crustBaseM), new Vector2(outerM, crustBaseM)],
lithosphereBase: [new Vector2(0, lithosphereBaseM(downType)), new Vector2(outerM, lithosphereBaseM(downType))],
};
Expand Down
91 changes: 78 additions & 13 deletions src/plate-motion/view/PlateMotionCanvasNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import type { CrossSectionScale } from "../../common/model/CrossSectionScale.js"
import { paintArrowHead } from "../../common/view/CanvasArrows.js";
import { materialFill } from "../../common/view/EarthMaterial.js";
import PlateTectonicsColors from "../../PlateTectonicsColors.js";
import { MANTLE_DENSITY_KG_M3, SURFACE_TEMPERATURE_K } from "../../PlateTectonicsConstants.js";
import {
LITHOSPHERIC_MANTLE_DENSITY_KG_M3,
MANTLE_DENSITY_KG_M3,
SLAB_DENSITY_KG_M3,
SURFACE_TEMPERATURE_K,
} from "../../PlateTectonicsConstants.js";
import { boundaryGeometry, type PlateOutline } from "../model/PlateGeometry.js";
import type { PlateMotionModel } from "../model/PlateMotionModel.js";
import { simpleMantleTemperatureK } from "../model/PlateThermal.js";
Expand All @@ -29,6 +34,19 @@ const MOTION_ARROW_LENGTH = 46;

export type PlateMotionCanvasNodeOptions = CanvasNodeOptions;

/**
* The ground profile across the whole section, left edge to right edge.
*
* The two plates tile the section between them, but neither the order of their samples
* nor which of them covers which side is fixed — a rift walks outwards from the axis, a
* collision walks in from the far field. Sorting the combined samples by x is what makes
* this independent of that: each plate's surface is a function of x, so the merge of the
* two is the ground.
*/
function groundSurface(geometry: ReturnType<typeof boundaryGeometry>): Vector2[] {
return [...geometry.left.crustTop, ...geometry.right.crustTop].sort((a, b) => a.x - b.x);
}

export class PlateMotionCanvasNode extends CanvasNode {
private readonly model: PlateMotionModel;

Expand Down Expand Up @@ -89,14 +107,12 @@ export class PlateMotionCanvasNode extends CanvasNode {
context.fillRect(bounds.minX, scale.seaLevelY, bounds.width, bounds.maxY - scale.seaLevelY);
}

// The mantle is painted as a band rather than sampled per column: on this screen it
// is a backdrop for the plates, not the subject, and a uniform fill reads as one
// continuous medium the plates are moving *through*.
this.paintMantle(context, mode);

const left = model.leftPlateTypeProperty.value;
const right = model.rightPlateTypeProperty.value;
if (!(left && right)) {
// Nothing has been placed yet, so there is no ground for the sea to lie on and the
// mantle simply fills the section below sea level.
this.paintMantle(context, mode, null);
return;
}

Expand All @@ -105,6 +121,14 @@ export class PlateMotionCanvasNode extends CanvasNode {
? boundaryGeometry(motion, left, right, model.timeMillionsOfYearsProperty.value)
: boundaryGeometry("convergent", left, right, 0);

// The mantle is painted as a band rather than sampled per column: on this screen it
// is a backdrop for the plates, not the subject, and a uniform fill reads as one
// continuous medium the plates are moving *through*. It is clipped to below the
// ground, though — an unclipped band starts at sea level and paints over the ocean,
// which is why the sea used to be invisible everywhere the sea floor lies (that is,
// everywhere it exists).
this.paintMantle(context, mode, groundSurface(geometry));

// ── The slab, under everything it passes beneath ──────────────────────────
if (geometry.slab.length > 1) {
this.paintSlab(context, geometry.slab, geometry.slabHalfThicknessM, mode);
Expand Down Expand Up @@ -145,11 +169,37 @@ export class PlateMotionCanvasNode extends CanvasNode {
}
}

/** A uniform mantle band from the deepest plate down to the bottom of the viewport. */
private paintMantle(context: CanvasRenderingContext2D, mode: Parameters<typeof materialFill>[0]): void {
/**
* The mantle, from the ground down to the bottom of the viewport.
*
* `surface` is the ground profile across the whole section; the fill is clipped to
* below it so the mantle never intrudes into the air or the sea. Pass null before any
* plate has been placed, when there is no ground and the band starts at sea level.
*/
private paintMantle(
context: CanvasRenderingContext2D,
mode: Parameters<typeof materialFill>[0],
surface: readonly Vector2[] | null,
): void {
const scale = this.sectionScale;
const bounds = this.canvasBounds;
const topY = scale.y(0);

context.save();
let topY = scale.y(0);
if (surface && surface.length > 1) {
context.beginPath();
context.moveTo(bounds.minX, bounds.maxY);
for (const point of surface) {
context.lineTo(scale.x(point.x), scale.y(point.y));
}
context.lineTo(bounds.maxX, bounds.maxY);
context.closePath();
context.clip();

// Start at the highest point of the ground rather than at sea level, so a mountain
// belt standing above the water still gets mantle painted under it.
topY = Math.min(...surface.map((point) => scale.y(point.y)));
}

// Sampled in horizontal bands so the geotherm shows as a gradient rather than a
// single flat colour — the whole point of the temperature mode.
Expand All @@ -160,6 +210,7 @@ export class PlateMotionCanvasNode extends CanvasNode {
context.fillStyle = materialFill(mode, MANTLE_DENSITY_KG_M3, temperatureK).toCSS();
context.fillRect(bounds.minX, y, bounds.width, bandHeight + 0.75);
}
context.restore();
}

/** One plate: crust over lithospheric mantle, each as a closed band. */
Expand All @@ -171,8 +222,11 @@ export class PlateMotionCanvasNode extends CanvasNode {
): void {
const properties = plateProperties(type);

// The lithospheric mantle first, so the crust sits on top of it.
context.fillStyle = materialFill(mode, MANTLE_DENSITY_KG_M3, SURFACE_TEMPERATURE_K + 900).toCSS();
// The lithospheric mantle first, so the crust sits on top of it. Drawn at the
// lithospheric density rather than the asthenosphere's: they are the same rock at
// different temperatures, and painting both at MANTLE_DENSITY_KG_M3 made the rigid
// part of every plate vanish into its surroundings in density mode.
context.fillStyle = materialFill(mode, LITHOSPHERIC_MANTLE_DENSITY_KG_M3, SURFACE_TEMPERATURE_K + 900).toCSS();
this.fillBand(context, outline.crustBase, outline.lithosphereBase);

context.fillStyle = materialFill(mode, properties.densityKgM3, SURFACE_TEMPERATURE_K + 450).toCSS();
Expand All @@ -182,12 +236,23 @@ export class PlateMotionCanvasNode extends CanvasNode {
/** The descending slab, as a ribbon of constant thickness about its centreline. */
private paintSlab(
context: CanvasRenderingContext2D,
centreline: readonly Vector2[],
fullCentreline: readonly Vector2[],
halfThicknessM: number,
mode: Parameters<typeof materialFill>[0],
): void {
const scale = this.sectionScale;

// Cut the slab off at the bottom of the section. CrossSectionScale.y clamps, so every
// point below the floor lands *on* the floor: an untrimmed slab that has descended
// past the view is drawn as a horizontal smear along the bottom edge, with its
// arrow-heads strung out sideways along it. Keeping the first point past the floor and
// dropping the rest lets the ribbon run off the edge and stop there.
const past = fullCentreline.findIndex((point) => point.y < scale.bottomM);
const centreline = past < 0 ? fullCentreline : fullCentreline.slice(0, past + 1);
if (centreline.length < 2) {
return;
}

// Offset perpendicular to the local heading, so the ribbon keeps its thickness round
// the bend instead of pinching where the curve is tightest.
const upper: Vector2[] = [];
Expand All @@ -210,7 +275,7 @@ export class PlateMotionCanvasNode extends CanvasNode {

// A slab is cold — that is why it is dense enough to sink, and why it stays rigid
// far below the depth where the surrounding mantle does not.
context.fillStyle = materialFill(mode, MANTLE_DENSITY_KG_M3 + 100, SURFACE_TEMPERATURE_K + 500).toCSS();
context.fillStyle = materialFill(mode, SLAB_DENSITY_KG_M3, SURFACE_TEMPERATURE_K + 500).toCSS();
this.fillBand(context, upper, lower);

// Arrow-heads down the centreline, showing which way it is going.
Expand Down
17 changes: 10 additions & 7 deletions src/plate-motion/view/PlateMotionScreenView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,18 @@ export class PlateMotionScreenView extends ScreenView {
const strings = StringManager.getInstance();
const a11y = strings.getPlateMotionA11yStrings().controls;

// The chooser sits above the section, so the section starts below it.
const chooserHeight = 62;
// The chooser sits above the section, so the section starts below it. Built first and
// measured rather than allowed for by a constant: the panel's height depends on the
// font and on the length of the three localized crust names, and a guess that is too
// small puts the panel on top of the cross-section.
const chooser = new CrustChooserPanel(model, {
left: SECTION_VIEW_BOUNDS.minX,
top: SECTION_VIEW_BOUNDS.minY,
});

const bounds = new Bounds2(
SECTION_VIEW_BOUNDS.minX,
SECTION_VIEW_BOUNDS.minY + chooserHeight,
SECTION_VIEW_BOUNDS.minY + chooser.height + PANEL_SPACING,
SECTION_VIEW_BOUNDS.maxX,
SECTION_VIEW_BOUNDS.maxY,
);
Expand Down Expand Up @@ -118,10 +125,6 @@ export class PlateMotionScreenView extends ScreenView {
this.addChild(probe);

// ── Crust chooser, above the section ──────────────────────────────────────
const chooser = new CrustChooserPanel(model, {
left: SECTION_VIEW_BOUNDS.minX,
top: SECTION_VIEW_BOUNDS.minY,
});
this.addChild(chooser);

// ── Legend ────────────────────────────────────────────────────────────────
Expand Down
Loading