13
Plate Tectonics Sandbox
drag to set plate motion · click to trigger earthquake
idle
484 lines · vanilla
view source
// Plate tectonics sandbox: two crustal slabs floating on mantle.
//
// The user's pointer X-coordinate selects the plate-boundary mode:
// left third : convergent (collision/subduction)
// middle : transform (sliding past — strike-slip fault)
// right third : divergent (rift; new oceanic crust upwells)
//
// Each plate has a `type` (continental | oceanic) and a horizontal velocity.
// At a convergent boundary:
// • continental + continental → both crusts buckle UP into mountains
// • oceanic + continental → oceanic slab subducts under the continent,
// forming a trench and a volcanic arc
// • oceanic + oceanic → the older (left) one subducts; island arc
// At a divergent boundary, magma fills the gap; we lay down new oceanic
// crust between the plates (a mid-ocean ridge in cross-section).
// At a transform boundary, the plates slide past horizontally and we record
// stress that occasionally bursts as an earthquake.
//
// Time is in millions of years (Ma). 1 real second ≈ 0.5 Ma here so the
// loop turns over in a watchable 5–30 s window.
//
// Click anywhere to inject an earthquake at the boundary: a red particle
// burst from the rupture point. Click on a plate to flip its type (a
// quick way to flip continental↔oceanic and see different collisions).
const MA_PER_SEC = 0.5;
let W = 0, H = 0;
// Layout (recomputed on resize).
let surfaceY; // y-coordinate of the crust top (sea level)
let mantleTop; // y where mantle band begins (below crust)
let mantleBot; // y where mantle band ends (above deep)
// Two plates. Each has left/right edge x-positions and a kind.
// We keep a single "boundary x" between them. Plates extend from canvas
// edges to that boundary.
let boundaryX;
let leftPlate; // { type: 'continental'|'oceanic', baseHue, thickness }
let rightPlate;
// Mountain heights (per-column array) above surfaceY at the boundary side
// of continental plates. Index 0 = column 0 (leftmost). Values in pixels.
let mountainsL; // Float32Array length W
let mountainsR; // Float32Array length W
// Subduction state: when an oceanic plate is being consumed, we draw a slab
// dipping into the mantle. `subductSide` is -1 if the left plate dives,
// +1 if the right plate dives, 0 if no subduction.
let subductSide;
let subductProgress; // 0..1 visual progression of the slab
let volcanoes; // array of { x, height } for arc volcanoes (built over time)
// Divergent state: a "rift zone" widens at the boundary and is filled with
// new oceanic crust. We track the rift width in px.
let riftWidth;
let riftCenter; // x of the rift center (the boundary; drifts as plates move)
// Transform state: accumulated shear stress; releases as earthquakes when
// it exceeds threshold.
let shearStress;
// Earthquake particles.
let quakeParts; // [{x,y,vx,vy,life}]
// HUD/time.
let timeMa;
let mode; // 'converge' | 'transform' | 'diverge'
let lastMode;
// Backdrop dust specks (mantle convection sparkle).
let mantleSpecks; // Float32Array of (x01, y01, b)
function layout() {
surfaceY = Math.round(H * 0.42);
mantleTop = Math.round(H * 0.62);
mantleBot = Math.round(H * 0.92);
boundaryX = boundaryX || Math.round(W * 0.5);
riftCenter = riftCenter || boundaryX;
// Invalidate cached gradients so they regenerate at the new surfaceY.
_cachedGradY = -1;
}
function seedSpecks(n) {
mantleSpecks = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
mantleSpecks[i * 3] = Math.random();
mantleSpecks[i * 3 + 1] = Math.random();
mantleSpecks[i * 3 + 2] = 0.3 + Math.random() * 0.7;
}
}
function makePlate(type) {
return {
type,
thickness: type === 'continental' ? 38 : 22,
// Slight per-instance color variation.
tone: 0.85 + Math.random() * 0.3,
};
}
function resetState() {
leftPlate = makePlate(Math.random() < 0.5 ? 'continental' : 'oceanic');
rightPlate = makePlate(Math.random() < 0.5 ? 'continental' : 'oceanic');
boundaryX = Math.round(W * 0.5);
riftCenter = boundaryX;
mountainsL = new Float32Array(Math.max(1, W));
mountainsR = new Float32Array(Math.max(1, W));
subductSide = 0;
subductProgress = 0;
volcanoes = [];
riftWidth = 0;
shearStress = 0;
quakeParts = [];
timeMa = 0;
mode = 'transform';
lastMode = null;
}
function init({ width, height }) {
W = width; H = height;
layout();
seedSpecks(180);
resetState();
}
function modeFromMouse(mx) {
// Normalize: 0..1. <0.33 = converge; 0.33–0.66 = transform; >0.66 = diverge.
const f = Math.max(0, Math.min(1, mx / W));
if (f < 0.33) return 'converge';
if (f > 0.66) return 'diverge';
return 'transform';
}
// Plate velocities in px/sec. Positive = right-ward.
function platesVelocity(m) {
const SPEED = 14; // visual; 1 unit Ma at this scale
if (m === 'converge') return { vL: SPEED, vR: -SPEED };
if (m === 'diverge') return { vL: -SPEED, vR: SPEED };
// transform: opposite directions but no boundary motion (slip)
return { vL: SPEED, vR: -SPEED, slip: true };
}
function triggerQuake(x, y, magnitude) {
const n = 24 + Math.floor(magnitude * 18);
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = 40 + Math.random() * 140 * magnitude;
quakeParts.push({
x, y,
vx: Math.cos(a) * s,
vy: Math.sin(a) * s * 0.7,
life: 0.6 + Math.random() * 0.7,
max: 1.0,
});
}
}
function boundaryRuptureY() {
// The earthquake epicentre depends on mode.
if (mode === 'converge') {
// near collision zone, mid-crust.
return surfaceY + 6;
}
if (mode === 'diverge') {
// mid-ocean ridge: at the crust surface where magma upwells.
return surfaceY - 2;
}
// transform: at the fault, mid-crust.
return surfaceY + 8;
}
function stepPhysics(dt) {
// Convert real seconds to Ma for the HUD.
timeMa += dt * MA_PER_SEC;
const v = platesVelocity(mode);
// ------ CONVERGENT ------
if (mode === 'converge') {
// The convergence rate determines how fast we build mountains / subduct.
const rate = (Math.abs(v.vL) + Math.abs(v.vR)) * dt; // px of approach per frame
const conv = (rate / W) * 0.6; // normalized
const Lcont = leftPlate.type === 'continental';
const Rcont = rightPlate.type === 'continental';
if (Lcont && Rcont) {
// Continental-continental: both sides buckle upward near boundary.
subductSide = 0;
subductProgress *= (1 - dt * 0.5); // fade any prior subduction slab
const reach = Math.round(Math.min(W * 0.18, 100));
for (let i = 0; i < reach; i++) {
const grow = (1 - i / reach) * conv * 80;
const li = Math.max(0, boundaryX - i);
const ri = Math.min(W - 1, boundaryX + i);
mountainsL[li] += grow;
mountainsR[ri] += grow;
}
// Slow inward drift of the boundary itself (collision crumple).
boundaryX += (v.vL + v.vR) * 0.0 * dt; // ~0
} else if (Lcont && !Rcont) {
// Right (oceanic) subducts beneath left (continental).
subductSide = +1;
subductProgress = Math.min(1, subductProgress + dt * 0.45);
// Build a volcanic arc inland of the trench (on the continent).
const reach = Math.round(Math.min(W * 0.10, 70));
for (let i = 0; i < reach; i++) {
const grow = (1 - i / reach) * conv * 55;
const li = Math.max(0, boundaryX - i - 8); // arc inset from trench
mountainsL[li] += grow;
}
// Occasional volcano spawn.
if (Math.random() < dt * 1.2) {
const vx = boundaryX - 18 - Math.random() * 50;
volcanoes.push({ x: vx, height: 8 + Math.random() * 14, life: 1.0 });
}
// Boundary creeps toward the subducting side (continent overrides).
boundaryX += dt * 4;
if (boundaryX > W - 60) boundaryX = W - 60;
} else if (!Lcont && Rcont) {
// Left oceanic subducts beneath right continental (mirror).
subductSide = -1;
subductProgress = Math.min(1, subductProgress + dt * 0.45);
const reach = Math.round(Math.min(W * 0.10, 70));
for (let i = 0; i < reach; i++) {
const grow = (1 - i / reach) * conv * 55;
const ri = Math.min(W - 1, boundaryX + i + 8);
mountainsR[ri] += grow;
}
if (Math.random() < dt * 1.2) {
const vx = boundaryX + 18 + Math.random() * 50;
volcanoes.push({ x: vx, height: 8 + Math.random() * 14, life: 1.0 });
}
boundaryX -= dt * 4;
if (boundaryX < 60) boundaryX = 60;
} else {
// Oceanic-oceanic: older (left, by convention) subducts; small island arc.
subductSide = -1;
subductProgress = Math.min(1, subductProgress + dt * 0.35);
const reach = Math.round(Math.min(W * 0.06, 40));
for (let i = 0; i < reach; i++) {
const grow = (1 - i / reach) * conv * 35;
const ri = Math.min(W - 1, boundaryX + i + 10);
mountainsR[ri] += grow * 0.6;
}
if (Math.random() < dt * 0.8) {
const vx = boundaryX + 14 + Math.random() * 40;
volcanoes.push({ x: vx, height: 5 + Math.random() * 8, life: 1.0 });
}
}
// Compaction: mountains stop growing past a max.
const MAX_H = Math.max(40, surfaceY * 0.5);
for (let i = 0; i < W; i++) {
if (mountainsL[i] > MAX_H) mountainsL[i] = MAX_H;
if (mountainsR[i] > MAX_H) mountainsR[i] = MAX_H;
}
// Stress builds: occasional earthquake.
shearStress += dt * 0.7;
if (shearStress > 1.0 && Math.random() < dt * 1.2) {
triggerQuake(boundaryX, boundaryRuptureY(), 0.7 + Math.random() * 0.4);
shearStress = 0;
}
}
// ------ TRANSFORM ------
else if (mode === 'transform') {
// No net convergence/divergence: boundary stays put. Stress accumulates.
subductSide = 0;
subductProgress *= (1 - dt * 0.6);
shearStress += dt * 0.8;
// Slow erosion of existing mountains.
for (let i = 0; i < W; i++) {
mountainsL[i] *= (1 - dt * 0.02);
mountainsR[i] *= (1 - dt * 0.02);
}
if (shearStress > 1.4 && Math.random() < dt * 1.5) {
// Strike-slip earthquakes can be large.
triggerQuake(boundaryX, boundaryRuptureY(), 0.6 + Math.random() * 0.6);
shearStress = 0;
}
// Drift the rift center harmlessly with the slip — purely cosmetic.
}
// ------ DIVERGENT ------
else if (mode === 'diverge') {
subductSide = 0;
subductProgress *= (1 - dt * 0.5);
// Widen the rift; new oceanic crust appears in the middle.
riftWidth += dt * 18;
if (riftWidth > W * 0.85) riftWidth = W * 0.85;
// Erode existing mountains slightly (continents pulling apart get sea).
for (let i = 0; i < W; i++) {
mountainsL[i] *= (1 - dt * 0.06);
mountainsR[i] *= (1 - dt * 0.06);
}
shearStress += dt * 0.4;
if (shearStress > 1.0 && Math.random() < dt * 1.0) {
// Smaller earthquakes at a ridge.
triggerQuake(boundaryX, boundaryRuptureY(), 0.3 + Math.random() * 0.3);
shearStress = 0;
}
}
// Mode change cleanup.
if (mode !== lastMode) {
lastMode = mode;
// When switching away from diverge, lock the rift width in place
// (it becomes "new oceanic crust" between the plates).
}
if (mode !== 'diverge') {
// Rift slowly stops growing when mode changes; it doesn't shrink — that
// new crust is permanent.
}
// Update volcanoes (age out a bit).
for (let i = volcanoes.length - 1; i >= 0; i--) {
const v0 = volcanoes[i];
v0.life -= dt * 0.04;
if (v0.life <= 0.1) v0.life = 0.1;
}
// Update earthquake particles.
for (let i = quakeParts.length - 1; i >= 0; i--) {
const p = quakeParts[i];
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
// Gravity drag.
p.vy += 220 * dt;
p.vx *= (1 - dt * 0.8);
if (p.life <= 0) quakeParts.splice(i, 1);
}
}
function handleInput(input) {
if (!input) return;
// Update mode from current pointer X (whether dragging or hovering).
mode = modeFromMouse(input.mouseX);
// Clicks: if near the boundary or anywhere — inject an earthquake.
if (typeof input.consumeClicks === 'function') {
const clicks = input.consumeClicks();
for (const c of clicks) {
// If click is on a plate (well above mantle) and not too near boundary,
// flip that plate's type as a secondary interaction. Otherwise quake.
const onLeftPlate = c.x < boundaryX - 30;
const onRightPlate = c.x > boundaryX + 30;
const inCrust = c.y > surfaceY - 80 && c.y < mantleTop;
if (onLeftPlate && inCrust) {
leftPlate = makePlate(leftPlate.type === 'continental' ? 'oceanic' : 'continental');
// Reset mountains on that side when type flips.
for (let i = 0; i < boundaryX; i++) mountainsL[i] *= 0.4;
} else if (onRightPlate && inCrust) {
rightPlate = makePlate(rightPlate.type === 'continental' ? 'oceanic' : 'continental');
for (let i = boundaryX; i < W; i++) mountainsR[i] *= 0.4;
} else {
// Earthquake injection.
triggerQuake(boundaryX, boundaryRuptureY(), 0.8 + Math.random() * 0.5);
}
}
}
}
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) {
W = width; H = height;
layout();
if (!mountainsL || mountainsL.length !== W) {
const oldL = mountainsL, oldR = mountainsR;
mountainsL = new Float32Array(W);
mountainsR = new Float32Array(W);
if (oldL && oldR) {
const n = Math.min(oldL.length, W);
for (let i = 0; i < n; i++) { mountainsL[i] = oldL[i]; mountainsR[i] = oldR[i]; }
}
}
}
handleInput(input);
// Clamp dt to keep things sane on slow frames.
const h = Math.min(dt, 1 / 30);
stepPhysics(h);
drawAll(ctx);
}
// ============================================================================
// DRAW
// ============================================================================
function drawAll(ctx) {
// Sky / atmosphere.
const sky = ctx.createLinearGradient(0, 0, 0, surfaceY);
sky.addColorStop(0, '#0a1424');
sky.addColorStop(1, '#1a2a3e');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, surfaceY);
// Mantle band (below crust).
drawMantle(ctx);
// Crust slabs.
drawCrust(ctx);
// Subduction slab (if active).
if (subductSide !== 0 && subductProgress > 0.02) drawSubductingSlab(ctx);
// Mountains, volcanoes, rift fill.
drawTopography(ctx);
// Boundary indicator: a hairline at the active boundary.
drawBoundaryHint(ctx);
// Earthquake particles (over everything).
drawQuakeParticles(ctx);
// HUD overlay.
drawHUD(ctx);
}
function drawMantle(ctx) {
// Background fill below sky.
ctx.fillStyle = '#2a1208';
ctx.fillRect(0, surfaceY, W, H - surfaceY);
// Hot mantle band gradient.
const mg = ctx.createLinearGradient(0, mantleTop, 0, mantleBot);
mg.addColorStop(0, '#7a2a0c');
mg.addColorStop(0.5, '#c4561a');
mg.addColorStop(1, '#7a2a0c');
ctx.fillStyle = mg;
ctx.fillRect(0, mantleTop, W, mantleBot - mantleTop);
// Specks of convection.
if (mantleSpecks) {
for (let i = 0; i < mantleSpecks.length / 3; i++) {
const sx = mantleSpecks[i * 3] * W;
const sy = mantleTop + mantleSpecks[i * 3 + 1] * (mantleBot - mantleTop);
const b = mantleSpecks[i * 3 + 2];
ctx.fillStyle = `rgba(255,${Math.floor(140 + b * 80)},80,${(0.18 + b * 0.3).toFixed(3)})`;
ctx.fillRect(sx, sy, 1, 1);
}
}
// Deep mantle (below the hot band).
ctx.fillStyle = '#3a1408';
ctx.fillRect(0, mantleBot, W, H - mantleBot);
}
function plateColor(plate, alpha) {
if (plate.type === 'continental') {
// Warm tan/granite.
return `rgba(${Math.floor(170 * plate.tone)},${Math.floor(140 * plate.tone)},${Math.floor(95 * plate.tone)},${alpha})`;
}
// Oceanic = darker basalt with a hint of blue.
return `rgba(${Math.floor(60 * plate.tone)},${Math.floor(80 * plate.tone)},${Math.floor(110 * plate.tone)},${alpha})`;
}
// Cached gradients — rebuilt only when surfaceY changes (i.e. on resize).
let _cachedWaterGrad = null;
let _cachedMagmaGrad = null;
let _cachedGradY = -1;
function ensureGradients(ctx) {
if (_cachedGradY === surfaceY && _cachedWaterGrad && _cachedMagmaGrad) return;
_cachedWaterGrad = ctx.createLinearGradient(0, surfaceY - 14, 0, surfaceY);
_cachedWaterGrad.addColorStop(0, 'rgba(40,90,140,0.15)');
_cachedWaterGrad.addColorStop(1, 'rgba(50,120,180,0.55)');
_cachedMagmaGrad = ctx.createLinearGradient(0, surfaceY, 0, surfaceY + 50);
_cachedMagmaGrad.addColorStop(0, '#ff8a32');
_cachedMagmaGrad.addColorStop(0.6, '#c2480f');
_cachedMagmaGrad.addColorStop(1, '#5a1a05');
_cachedGradY = surfaceY;
}
function drawCrust(ctx) {
ensureGradients(ctx);
// Left plate spans [0, boundaryX - riftWidth/2 if diverge, else boundaryX]
// Right plate spans [boundaryX + riftWidth/2, W] for divergence.
const halfRift = riftWidth * 0.5; // rift persists
const leftEnd = Math.max(0, boundaryX - halfRift);
const rightStart = Math.min(W, boundaryX + halfRift);
// Left plate body.
ctx.fillStyle = plateColor(leftPlate, 1.0);
ctx.fillRect(0, surfaceY, leftEnd, leftPlate.thickness);
// Right plate body.
ctx.fillStyle = plateColor(rightPlate, 1.0);
ctx.fillRect(rightStart, surfaceY, W - rightStart, rightPlate.thickness);
// Highlight band at top (sea level / surface).
ctx.fillStyle = 'rgba(255,255,255,0.07)';
ctx.fillRect(0, surfaceY, leftEnd, 3);
ctx.fillRect(rightStart, surfaceY, W - rightStart, 3);
// Oceanic plates: draw a thin water layer above the crust.
if (leftPlate.type === 'oceanic' && leftEnd > 0) {
ctx.fillStyle = _cachedWaterGrad;
ctx.fillRect(0, surfaceY - 14, leftEnd, 14);
}
if (rightPlate.type === 'oceanic' && rightStart < W) {
ctx.fillStyle = _cachedWaterGrad;
ctx.fillRect(rightStart, surfaceY - 14, W - rightStart, 14);
}
// Rift fill: new oceanic crust + magma between the plates.
if (riftWidth > 1) {
const rx0 = leftEnd;
const rx1 = rightStart;
// Magma at depth.
ctx.fillStyle = _cachedMagmaGrad;
ctx.fillRect(rx0, surfaceY - 4, rx1 - rx0, 50);
// Thin new oceanic crust on top.
ctx.fillStyle = 'rgba(35,55,85,0.85)';
ctx.fillRect(rx0, surfaceY, rx1 - rx0, 6);
// Sea over rift.
ctx.fillStyle = _cachedWaterGrad;
ctx.fillRect(rx0, surfaceY - 14, rx1 - rx0, 14);
}
}
function drawSubductingSlab(ctx) {
// Slab dives from the boundary at the surface down into the mantle.
// subductSide: +1 means right plate dives (slab goes down-left), -1 means
// left plate dives (slab goes down-right). Visually a long parallelogram.
const startX = boundaryX;
const startY = surfaceY + (subductSide > 0 ? rightPlate.thickness * 0.4 : leftPlate.thickness * 0.4);
const depth = mantleBot - startY;
const dx = subductSide > 0 ? -depth * 0.6 : depth * 0.6;
const slabThickness = subductSide > 0 ? rightPlate.thickness : leftPlate.thickness;
const endX = startX + dx;
const endY = startY + depth * subductProgress;
// Slab polygon (dipping).
const plate = subductSide > 0 ? rightPlate : leftPlate;
ctx.save();
ctx.fillStyle = plateColor(plate, 0.92);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(startX + (subductSide > 0 ? -2 : 2), startY + slabThickness);
// dipping bottom edge
ctx.lineTo(endX + (subductSide > 0 ? -slabThickness * 0.6 : slabThickness * 0.6), endY);
ctx.lineTo(endX, endY - slabThickness * 0.4);
ctx.closePath();
ctx.fill();
// Trench gouge at the surface near boundary.
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath();
ctx.moveTo(startX - 6, surfaceY);
ctx.lineTo(startX, surfaceY + 12);
ctx.lineTo(startX + 6, surfaceY);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawTopography(ctx) {
// Draw mountains: at each column, draw a vertical line up to surfaceY -
// mountains[col] with a brown gradient.
// (We sample every 2px to keep it cheap.)
for (let i = 0; i < W; i += 2) {
const hL = mountainsL[i] | 0;
const hR = mountainsR[i] | 0;
if (hL > 1) {
ctx.fillStyle = `rgba(${120 + (hL & 31)},${85 + (hL & 15)},55,1)`;
ctx.fillRect(i, surfaceY - hL, 2, hL);
// Snowy peak hint.
if (hL > 28) {
ctx.fillStyle = 'rgba(240,245,250,0.85)';
ctx.fillRect(i, surfaceY - hL, 2, 2);
}
}
if (hR > 1) {
ctx.fillStyle = `rgba(${120 + (hR & 31)},${85 + (hR & 15)},55,1)`;
ctx.fillRect(i, surfaceY - hR, 2, hR);
if (hR > 28) {
ctx.fillStyle = 'rgba(240,245,250,0.85)';
ctx.fillRect(i, surfaceY - hR, 2, 2);
}
}
}
// Volcanoes (cones with glowing tips).
for (const v0 of volcanoes) {
if (v0.x < 0 || v0.x > W) continue;
const baseY = surfaceY;
const tipY = surfaceY - v0.height;
ctx.fillStyle = '#3a2418';
ctx.beginPath();
ctx.moveTo(v0.x - v0.height * 0.55, baseY);
ctx.lineTo(v0.x + v0.height * 0.55, baseY);
ctx.lineTo(v0.x, tipY);
ctx.closePath();
ctx.fill();
// Glow / smoke at tip.
ctx.fillStyle = `rgba(255,140,40,${(0.45 * v0.life).toFixed(2)})`;
ctx.beginPath();
ctx.arc(v0.x, tipY, 2 + v0.life * 2, 0, Math.PI * 2);
ctx.fill();
}
}
function drawBoundaryHint(ctx) {
// Subtle vertical guide at the boundary.
ctx.strokeStyle = mode === 'transform'
? 'rgba(255,230,120,0.35)'
: 'rgba(255,230,120,0.18)';
ctx.setLineDash(mode === 'transform' ? [3, 4] : []);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(boundaryX, surfaceY - 60);
ctx.lineTo(boundaryX, mantleTop);
ctx.stroke();
ctx.setLineDash([]);
// Arrows on each plate indicating velocity direction for current mode.
const v = platesVelocity(mode);
const ay = surfaceY + 14;
// Left plate arrow.
drawArrow(ctx, Math.max(40, boundaryX * 0.5), ay, Math.sign(v.vL));
// Right plate arrow.
drawArrow(ctx, Math.min(W - 40, boundaryX + (W - boundaryX) * 0.5), ay, Math.sign(v.vR));
}
function drawArrow(ctx, x, y, dir) {
if (dir === 0) return;
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1.5;
const len = 22;
ctx.beginPath();
ctx.moveTo(x - dir * len * 0.5, y);
ctx.lineTo(x + dir * len * 0.5, y);
ctx.stroke();
// Arrowhead.
ctx.beginPath();
ctx.moveTo(x + dir * len * 0.5, y);
ctx.lineTo(x + dir * (len * 0.5 - 6), y - 4);
ctx.lineTo(x + dir * (len * 0.5 - 6), y + 4);
ctx.closePath();
ctx.fill();
}
function drawQuakeParticles(ctx) {
for (const p of quakeParts) {
const a = Math.max(0, Math.min(1, p.life / p.max));
ctx.fillStyle = `rgba(255,${Math.floor(60 + 80 * a)},40,${a.toFixed(2)})`;
ctx.fillRect(p.x - 1, p.y - 1, 2, 2);
}
}
function drawHUD(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(8, 8, 210, 60);
ctx.fillStyle = 'rgba(230,235,245,0.95)';
ctx.font = '12px system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText(`time: ${timeMa.toFixed(1)} Ma`, 14, 12);
let modeLabel;
if (mode === 'converge') modeLabel = 'convergent (collision)';
else if (mode === 'diverge') modeLabel = 'divergent (rift)';
else modeLabel = 'transform (slip)';
ctx.fillStyle = mode === 'converge'
? 'rgba(255,160,120,0.95)'
: mode === 'diverge'
? 'rgba(120,200,255,0.95)'
: 'rgba(255,220,120,0.95)';
ctx.fillText(`mode: ${modeLabel}`, 14, 28);
ctx.fillStyle = 'rgba(200,210,230,0.85)';
ctx.fillText(
`${leftPlate.type[0].toUpperCase()}${leftPlate.type.slice(1)} ↔ ${rightPlate.type[0].toUpperCase()}${rightPlate.type.slice(1)}`,
14, 44
);
// Right-aligned hint.
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(200,210,230,0.55)';
ctx.fillText('drag to set motion · click for quake', W - 12, 12);
ctx.fillText('click plate to flip type', W - 12, 28);
ctx.textAlign = 'left';
}
Comments (0)
Log in to comment.