13

Plate Tectonics Sandbox

drag to set plate motion · click to trigger earthquake

A cartoon Earth in cross-section: two crustal slabs (continental or oceanic) drift on a glowing mantle. **Where you point sets the plate motion.** Move your pointer to the **left** of the boundary and the plates *converge* — continent-on-continent buckles into mountains (think Himalayas), continent-on-ocean sends the oceanic slab diving below the continent into a subduction trench with a chain of arc volcanoes (the Andes, Cascades). Move **right** and the plates *diverge*: a rift opens, hot magma upwells, and new oceanic crust is laid down between them (the Mid-Atlantic Ridge). Stay **in the middle** and they slide past in a *transform* fault — no land is created or destroyed, but elastic strain accumulates and releases as strike-slip earthquakes (San Andreas). The HUD shows a Ma (millions of years) counter; in this sandbox 1 real second ≈ 0.5 Ma. **Click anywhere** to inject an earthquake rupture at the boundary — red ejecta scatters from the epicentre. **Click on a plate** (well away from the boundary) to flip its type between continental and oceanic, then drag back to convergent mode to see a different style of collision unfold. The same physics, applied for ~3.5 billion years, gave us every mountain, ocean, and fault line on the planet.

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.