6

Stratigraphy: Drill a Core

drag Y for sea level · click x to drill

A toy basin recording its own history. Each Ma we deposit a thin sediment band across every column; the facies a column receives is set by whether its current deposition surface sits above or below sea level (set by mouse Y). Mathematically: at time , column has fill height and surface row where is accumulated tectonic offset. The facies stamped at is if , else , with a transitional band in between — a discrete Walther's law writ small. Every Ma a tectonic event applies either a linear tilt (rotating future deposition) or a vertical fault that shears the existing stack. After Ma of basin history you can click any to drill a core; the side panel logs the stratigraphy as it actually formed at that location — angular unconformities, fault offsets, marine-terrestrial transitions and all.

idle
351 lines · vanilla
view source
// Stratigraphy Builder — drill-a-core
//
// Time on the y-axis (millions of years, Ma), space horizontal. As simulated
// time advances, sediment layers deposit downward from the *top* of the basin
// (newest at top, older at bottom — geological "deeper = older" convention).
// We store sediments column-by-column so faults and tilts that happened *after*
// a layer formed actually shear the layer at every x.
//
// Sea level controls facies: when sea level (set by mouseY) is HIGH the column
// at every x that sits below sea level deposits marine sediments (blue-greys,
// shales, limestones). When sea level is LOW the column deposits terrestrial
// sediments (sandy yellows / browns).
//
// Tectonic events: roughly every 15 Ma we pick either a gentle TILT (rotates
// future deposition's "deposition surface") or a FAULT (a vertical offset
// across a random x split — everything currently in the basin shifts on one
// side). Both produce clear unconformities and offsets you can see when you
// drill a core.
//
// Drilling: after ~25 Ma of basin time, the user can click any x to log a
// vertical core at that x. The drilled core highlights and a side-panel
// shows the stratigraphic column at that location (most recent at top).

const COLS = 220;          // horizontal resolution of the basin
const ROWS = 320;          // vertical resolution (deep stack of layers)
const MA_PER_FRAME = 0.06; // simulated time advance per frame
const LAYER_EVERY_MA = 0.5; // a fresh sediment band every ~0.5 Ma
const DRILL_UNLOCK_MA = 25; // can't drill until enough history exists
const TECTONIC_EVERY_MA = 15;

// Each cell stores the deposited layer band. 0 = empty (no sediment yet).
// Positive integer = bandId; we also keep a parallel facies/color table.
let grid;            // Int16Array COLS*ROWS, layer band id (0 = empty)
let facies;          // Uint8Array — 1=marine, 2=terrestrial, 3=transitional, per band id
let bandColor;       // Float32Array — per-band color jitter (0..1)
let bandMa;          // Float32Array — Ma at which each band formed
let nextBand;        // next band id to allocate
// per-column "fill height" — how many rows from the bottom are filled.
// We fill UP from row ROWS-1 toward row 0. Row 0 is the surface.
let columnFill;      // Int16Array COLS — number of filled rows at each column
let deformOffset;    // Float32Array COLS — fractional row offset applied to future deposits (tilts)
let ageMa;           // simulated time elapsed
let lastLayerMa;     // when we last formed a layer
let lastTectonicMa;  // when we last did a tectonic event
let cores;           // array of { x, atMa }
let lastTectonicLabel;
let lastTectonicLabelT;
let drillUnlockedFlash;

// Sea level state. seaRow is the basin row that water surface sits at
// (smaller = higher sea level). Driven smoothly by mouseY input.
let seaRow;
let seaRowTarget;

function init() {
  grid = new Int16Array(COLS * ROWS);
  facies = new Uint8Array(2048);
  bandColor = new Float32Array(2048);
  bandMa = new Float32Array(2048);
  nextBand = 1;
  columnFill = new Int16Array(COLS);
  deformOffset = new Float32Array(COLS);
  ageMa = 0;
  lastLayerMa = -LAYER_EVERY_MA;
  lastTectonicMa = 0;
  cores = [];
  lastTectonicLabel = "";
  lastTectonicLabelT = 0;
  drillUnlockedFlash = 0;
  seaRow = ROWS * 0.5;
  seaRowTarget = ROWS * 0.5;
}

function allocBand(faciesKind) {
  if (nextBand >= facies.length) {
    // grow tables
    const grow = facies.length * 2;
    const f = new Uint8Array(grow); f.set(facies); facies = f;
    const c = new Float32Array(grow); c.set(bandColor); bandColor = c;
    const m = new Float32Array(grow); m.set(bandMa); bandMa = m;
  }
  const id = nextBand++;
  facies[id] = faciesKind;
  bandColor[id] = Math.random();
  bandMa[id] = ageMa;
  return id;
}

function depositLayer() {
  // For each column, decide facies based on whether that column's *current
  // deposition surface* sits below the sea level. seaRow uses basin-row
  // coordinates where 0 is "high in the air" and ROWS is the basin floor.
  // A column's deposition surface = (ROWS - columnFill[x]) + deformOffset[x].
  // If surface > seaRow it is submerged (marine); else terrestrial.
  // We allocate up to 3 bands for this Ma slice (marine / terrestrial /
  // transitional) and pick one per column so the layer reads as a single
  // time-band but with facies variation across space.
  const marineId = allocBand(1);
  const terrId   = allocBand(2);
  const transId  = allocBand(3);
  // randomize thickness 1..3 rows
  const thickness = 1 + ((Math.random() * 2.7) | 0);
  for (let x = 0; x < COLS; x++) {
    const surfaceRow = (ROWS - columnFill[x]) + deformOffset[x];
    let id;
    if (surfaceRow > seaRow + 6) id = marineId;
    else if (surfaceRow > seaRow - 6) id = transId;
    else id = terrId;
    // Fill `thickness` cells upward from current fill top.
    for (let t = 0; t < thickness; t++) {
      const fill = columnFill[x];
      if (fill >= ROWS) break;
      const row = ROWS - 1 - fill;
      grid[row * COLS + x] = id;
      columnFill[x] = fill + 1;
    }
  }
}

function applyTilt() {
  // Rotate future deposition surface across the basin. We just bias
  // deformOffset linearly; positive on one side means "this side is being
  // pushed down so accumulates more thickness".
  const slope = (Math.random() - 0.5) * 0.06; // rows per column
  const mid = COLS * 0.5;
  for (let x = 0; x < COLS; x++) deformOffset[x] += (x - mid) * slope;
  lastTectonicLabel = `tilt ${slope > 0 ? "→ down-right" : "→ down-left"}`;
  lastTectonicLabelT = 240;
}

function applyFault() {
  // Vertical offset across a random x. Shift all deposited cells on the
  // hangingwall side up or down by `offset` rows. We also shift columnFill.
  const fx = (COLS * (0.2 + Math.random() * 0.6)) | 0;
  const offset = (1 + ((Math.random() * 6) | 0)) * (Math.random() < 0.5 ? -1 : 1);
  const side = Math.random() < 0.5 ? "L" : "R";
  for (let x = 0; x < COLS; x++) {
    const onSide = side === "L" ? x < fx : x > fx;
    if (!onSide) continue;
    // shift this column's filled cells by `offset` rows (down = +offset)
    if (offset > 0) {
      // shift downward: rows near the top move toward bottom — but we only
      // have ROWS rows, so cells falling off the bottom are lost.
      for (let r = ROWS - 1; r >= 0; r--) {
        const src = r - offset;
        grid[r * COLS + x] = src >= 0 ? grid[src * COLS + x] : 0;
      }
      columnFill[x] = Math.min(ROWS, columnFill[x] + offset);
    } else {
      const off = -offset;
      // shift upward: rows shift toward 0; cells exposed at the bottom become 0
      for (let r = 0; r < ROWS; r++) {
        const src = r + off;
        grid[r * COLS + x] = src < ROWS ? grid[src * COLS + x] : 0;
      }
      columnFill[x] = Math.max(0, columnFill[x] - off);
    }
  }
  lastTectonicLabel = `fault @ x≈${(fx / COLS * 100) | 0}%  (${side}-side ${offset > 0 ? "down" : "up"} ${Math.abs(offset)})`;
  lastTectonicLabelT = 240;
}

function maybeTectonic() {
  if (ageMa - lastTectonicMa < TECTONIC_EVERY_MA) return;
  lastTectonicMa = ageMa;
  if (Math.random() < 0.5) applyTilt(); else applyFault();
}

// Color a band cell. Returns an "rgb(...)" string. We mix the band's facies
// base palette with the per-band jitter so each layer is recognizable.
function colorFor(bandId) {
  if (!bandId) return "#0a0e14";
  const f = facies[bandId];
  const j = bandColor[bandId];
  if (f === 1) {
    // marine — blue-grey to dark slate
    const r = 50 + (j * 30) | 0;
    const g = 70 + (j * 35) | 0;
    const b = 100 + (j * 55) | 0;
    return `rgb(${r},${g},${b})`;
  } else if (f === 2) {
    // terrestrial — sandy yellow-brown
    const r = 150 + (j * 70) | 0;
    const g = 110 + (j * 55) | 0;
    const b = 65 + (j * 40) | 0;
    return `rgb(${r},${g},${b})`;
  } else {
    // transitional — greenish-grey (coastal, shale-mudstone)
    const r = 90 + (j * 30) | 0;
    const g = 105 + (j * 35) | 0;
    const b = 85 + (j * 25) | 0;
    return `rgb(${r},${g},${b})`;
  }
}

function tick({ ctx, width: W, height: H, input }) {
  // ----- input -----
  // mouseY in the basin region drives target sea level. We map y across the
  // basin draw region (computed below). For now, latch on input.mouseY:
  // a low mouseY (near top) = high sea level (seaRow small), and vice versa.
  const my = input && typeof input.mouseY === "number" ? input.mouseY : -1;
  if (my >= 0 && my <= H) {
    const t = Math.max(0, Math.min(1, my / H));
    seaRowTarget = ROWS * (0.15 + t * 0.7); // mouse drives sea level
  }
  // smooth follow so the sea level glides
  seaRow += (seaRowTarget - seaRow) * 0.05;

  // ----- simulate -----
  ageMa += MA_PER_FRAME;
  // deposit a layer band whenever enough simulated time has passed
  while (ageMa - lastLayerMa >= LAYER_EVERY_MA) {
    lastLayerMa += LAYER_EVERY_MA;
    depositLayer();
  }
  maybeTectonic();

  // unlock drill flash
  if (ageMa >= DRILL_UNLOCK_MA && drillUnlockedFlash === 0 && cores.length === 0) {
    drillUnlockedFlash = 180;
  }
  if (drillUnlockedFlash > 0) drillUnlockedFlash--;

  // ----- layout -----
  const pad = 10;
  const headerH = 28;
  const panelW = Math.min(170, Math.max(110, (W * 0.22) | 0));
  const basinX = pad;
  const basinY = pad + headerH;
  const basinW = W - panelW - pad * 3;
  const basinH = H - basinY - pad;
  const cellW = basinW / COLS;
  const cellH = basinH / ROWS;

  // handle clicks → drill. Map click x to column.
  let clickedCore = -1;
  if (input && typeof input.consumeClicks === "function") {
    const clicks = input.consumeClicks();
    if (clicks.length > 0 && ageMa >= DRILL_UNLOCK_MA) {
      for (let ci = 0; ci < clicks.length; ci++) {
        const cx0 = clicks[ci].x;
        const cy0 = clicks[ci].y;
        if (cx0 >= basinX && cx0 <= basinX + basinW && cy0 >= basinY && cy0 <= basinY + basinH) {
          const col = Math.max(0, Math.min(COLS - 1, ((cx0 - basinX) / cellW) | 0));
          // dedupe: replace nearest if within a few columns
          let replaced = false;
          for (let i = 0; i < cores.length; i++) {
            if (Math.abs(cores[i].x - col) < 4) { cores[i] = { x: col, atMa: ageMa }; replaced = true; clickedCore = i; break; }
          }
          if (!replaced) { cores.push({ x: col, atMa: ageMa }); clickedCore = cores.length - 1; }
          // cap to last 6 cores
          if (cores.length > 6) cores.shift();
        }
      }
    }
  }

  // ----- draw background -----
  ctx.fillStyle = "#0a0e14";
  ctx.fillRect(0, 0, W, H);

  // basin area background (atmosphere / future deposition zone)
  ctx.fillStyle = "#0d1218";
  ctx.fillRect(basinX, basinY, basinW, basinH);

  // ----- draw deposited cells as horizontal runs per row -----
  // For each row, scan columns and coalesce same-bandId runs to reduce fillRect calls.
  for (let r = 0; r < ROWS; r++) {
    let runStart = -1;
    let runBand = 0;
    const rowOff = r * COLS;
    for (let x = 0; x < COLS; x++) {
      const b = grid[rowOff + x];
      if (b !== runBand) {
        if (runBand !== 0 && runStart >= 0) {
          ctx.fillStyle = colorFor(runBand);
          ctx.fillRect(basinX + runStart * cellW, basinY + r * cellH, (x - runStart) * cellW + 0.5, cellH + 0.5);
        }
        runBand = b;
        runStart = x;
      }
    }
    if (runBand !== 0 && runStart >= 0) {
      ctx.fillStyle = colorFor(runBand);
      ctx.fillRect(basinX + runStart * cellW, basinY + r * cellH, (COLS - runStart) * cellW + 0.5, cellH + 0.5);
    }
  }

  // ----- sea level line -----
  const seaY = basinY + (seaRow / ROWS) * basinH;
  // water tint above the current basin surface but below sea level
  ctx.fillStyle = "rgba(70,120,180,0.10)";
  ctx.fillRect(basinX, basinY, basinW, Math.max(0, seaY - basinY));
  // animated sea line
  ctx.strokeStyle = "rgba(120,200,255,0.85)";
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  for (let x = 0; x <= basinW; x += 6) {
    const wy = seaY + Math.sin((x * 0.05) + ageMa * 1.5) * 1.6;
    if (x === 0) ctx.moveTo(basinX + x, wy); else ctx.lineTo(basinX + x, wy);
  }
  ctx.stroke();
  ctx.fillStyle = "rgba(120,200,255,0.9)";
  ctx.font = "10px monospace";
  ctx.textAlign = "right";
  ctx.fillText("sea level", basinX + basinW - 6, seaY - 4);

  // ----- drill highlights -----
  for (let i = 0; i < cores.length; i++) {
    const c = cores[i];
    const cx = basinX + c.x * cellW + cellW * 0.5;
    // glow
    ctx.fillStyle = "rgba(255,220,140,0.13)";
    ctx.fillRect(cx - 3, basinY, 6, basinH);
    // bright center line
    ctx.strokeStyle = i === clickedCore ? "#fff7c0" : "rgba(255,220,140,0.85)";
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(cx, basinY); ctx.lineTo(cx, basinY + basinH); ctx.stroke();
    // label dot at top
    ctx.fillStyle = "#ffd86b";
    ctx.beginPath(); ctx.arc(cx, basinY - 2, 3, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "#0a0e14";
    ctx.font = "9px monospace";
    ctx.textAlign = "center";
    ctx.fillText(String(i + 1), cx, basinY + 1);
  }

  // basin frame
  ctx.strokeStyle = "#333";
  ctx.lineWidth = 1;
  ctx.strokeRect(basinX - 0.5, basinY - 0.5, basinW + 1, basinH + 1);

  // ----- header / HUD -----
  ctx.textAlign = "left";
  ctx.fillStyle = "#dde";
  ctx.font = "13px monospace";
  ctx.fillText("Stratigraphy: Drill a Core", pad, pad + 14);

  ctx.font = "11px monospace";
  // Layout the header strip left-to-right with measured widths so it doesn't
  // collide on narrow canvases.
  let hx = pad + ctx.measureText("Stratigraphy: Drill a Core").width + 16;
  ctx.fillStyle = "#9af";
  const ageStr = `${ageMa.toFixed(1)} Ma`;
  ctx.fillText(ageStr, hx, pad + 14);
  hx += ctx.measureText(ageStr).width + 16;

  // current facies indicator (what is depositing RIGHT NOW at the basin center)
  const midX = (COLS / 2) | 0;
  const curSurface = (ROWS - columnFill[midX]) + deformOffset[midX];
  let curFaciesLbl, curFaciesCol;
  if (curSurface > seaRow + 6) { curFaciesLbl = "marine"; curFaciesCol = "#7cf"; }
  else if (curSurface > seaRow - 6) { curFaciesLbl = "coastal"; curFaciesCol = "#9c9"; }
  else { curFaciesLbl = "terrestrial"; curFaciesCol = "#dba"; }
  const headerRightLimit = W - panelW - pad * 2 - 4;
  if (hx + 110 < headerRightLimit) {
    ctx.fillStyle = "#888";
    ctx.fillText("depositing:", hx, pad + 14);
    hx += ctx.measureText("depositing:").width + 4;
    ctx.fillStyle = curFaciesCol;
    ctx.fillText(curFaciesLbl, hx, pad + 14);
    hx += ctx.measureText(curFaciesLbl).width + 16;
  }
  if (hx + 60 < headerRightLimit) {
    ctx.fillStyle = "#888";
    ctx.fillText(`cores: ${cores.length}`, hx, pad + 14);
  }

  // hint line just below the header
  ctx.font = "10px monospace";
  if (ageMa < DRILL_UNLOCK_MA) {
    ctx.fillStyle = "#665";
    ctx.fillText(`drill unlocks at ${DRILL_UNLOCK_MA} Ma · move mouse Y to set sea level`,
      pad, pad + 26);
  } else {
    ctx.fillStyle = drillUnlockedFlash > 0
      ? `rgba(255,220,140,${0.4 + (drillUnlockedFlash / 180) * 0.6})`
      : "#776";
    ctx.fillText("click in the basin to drill a core · mouse Y sets sea level",
      pad, pad + 26);
  }

  // tectonic event toast
  if (lastTectonicLabelT > 0) {
    lastTectonicLabelT--;
    const alpha = Math.min(1, lastTectonicLabelT / 60);
    ctx.fillStyle = `rgba(255,160,90,${alpha})`;
    ctx.font = "11px monospace";
    ctx.textAlign = "right";
    ctx.fillText("⚡ " + lastTectonicLabel, W - panelW - pad * 2 - 4, pad + 26);
    ctx.textAlign = "left";
  }

  // ----- side panel: core logs -----
  const px0 = W - panelW - pad;
  const py0 = basinY;
  const pw = panelW;
  const ph = basinH;
  ctx.fillStyle = "#0c1118";
  ctx.fillRect(px0, py0, pw, ph);
  ctx.strokeStyle = "#222";
  ctx.strokeRect(px0 + 0.5, py0 + 0.5, pw - 1, ph - 1);
  ctx.fillStyle = "#ccd";
  ctx.font = "11px monospace";
  ctx.fillText("core logs", px0 + 8, py0 + 14);

  if (cores.length === 0) {
    ctx.fillStyle = "#555";
    ctx.font = "10px monospace";
    ctx.fillText(ageMa < DRILL_UNLOCK_MA
      ? `(wait ${(DRILL_UNLOCK_MA - ageMa).toFixed(1)} Ma)`
      : "click basin to drill", px0 + 8, py0 + 30);
  } else {
    // Render up to 4 most-recent cores side by side as vertical strips
    const show = cores.slice(-4);
    const stripW = (pw - 16) / show.length;
    const stripTop = py0 + 24;
    const stripBot = py0 + ph - 10;
    const stripH = stripBot - stripTop;
    for (let i = 0; i < show.length; i++) {
      const c = show[i];
      const sx = px0 + 8 + i * stripW;
      // Render the column at c.x: row 0..ROWS-1 mapped to stripTop..stripBot
      // Use a single-pixel-wide vertical scan, coalescing runs of same band.
      let runStart = -1;
      let runBand = 0;
      for (let r = 0; r < ROWS; r++) {
        const b = grid[r * COLS + c.x];
        if (b !== runBand) {
          if (runBand !== 0 && runStart >= 0) {
            const y0 = stripTop + (runStart / ROWS) * stripH;
            const y1 = stripTop + (r / ROWS) * stripH;
            ctx.fillStyle = colorFor(runBand);
            ctx.fillRect(sx, y0, stripW - 4, y1 - y0 + 0.5);
          }
          runBand = b;
          runStart = r;
        }
      }
      if (runBand !== 0 && runStart >= 0) {
        const y0 = stripTop + (runStart / ROWS) * stripH;
        const y1 = stripTop + stripH;
        ctx.fillStyle = colorFor(runBand);
        ctx.fillRect(sx, y0, stripW - 4, y1 - y0 + 0.5);
      }
      // strip frame + label
      ctx.strokeStyle = "#333";
      ctx.strokeRect(sx - 0.5, stripTop - 0.5, stripW - 4 + 1, stripH + 1);
      ctx.fillStyle = "#bbb";
      ctx.font = "9px monospace";
      ctx.textAlign = "center";
      const idxInArr = cores.length - show.length + i;
      ctx.fillText(`#${idxInArr + 1}`, sx + (stripW - 4) / 2, stripTop - 2);
      ctx.font = "8px monospace";
      ctx.fillStyle = "#666";
      ctx.fillText(`${c.atMa.toFixed(0)}Ma`, sx + (stripW - 4) / 2, stripBot + 8);
      ctx.textAlign = "left";
    }
  }

  // ----- sea level indicator gutter on the left edge of basin -----
  // small triangle at sea level on the y-axis to make the control obvious
  ctx.fillStyle = "rgba(120,200,255,0.9)";
  ctx.beginPath();
  ctx.moveTo(basinX - 1, seaY);
  ctx.lineTo(basinX - 8, seaY - 4);
  ctx.lineTo(basinX - 8, seaY + 4);
  ctx.closePath();
  ctx.fill();
}

Comments (0)

Log in to comment.