jemonjam
engineer + builder + leader
CA Reservoirs Water-Model — Implementation Plan

CA Reservoirs Water-Model — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build an interactive, data-grounded California water-balance model (lab/water-viz/) and an accompanying blog post that lets readers drag levers (population, per-capita use, new reservoirs, climate) and watch statewide reservoir levels — and low-reservoir events avoided — recompute over 1950–2024.

Architecture: A pure model.js water-balance function fed by real historical series baked into data.js. Two HTML front-ends share the model + chart code: a compact embed.html (iframed into the post) and a full-page index.html. Vanilla JS ES modules, SVG charts, no framework — mirrors the existing lab/comp-viz/.

Tech Stack: Vanilla JS (ES modules), SVG, Jekyll static serving, node --test for unit tests (Node 24, zero deps).

Reference material:

Conventions:


Task 1: Scaffold directory + data.js with real series

Files:

lab/water-viz/src/data.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { YEARS, RUNOFF, POP, GPCD, AG, CAP, PROJECTS, CONST } from './data.js';

test('series are annual 1950-2024 and aligned', () => {
  assert.equal(YEARS[0], 1950);
  assert.equal(YEARS.at(-1), 2024);
  assert.equal(YEARS.length, 75);
  for (const s of [RUNOFF, POP, GPCD, AG, CAP]) assert.equal(s.length, 75);
});

test('runoff anchors match CDEC WSIHIST', () => {
  const i = (y) => YEARS.indexOf(y);
  assert.equal(RUNOFF[i(1977)], 5.12);  // driest on record
  assert.equal(RUNOFF[i(2017)], 37.82); // wettest
});

test('series are in plausible physical ranges', () => {
  assert.ok(Math.min(...POP) >= 10 && Math.max(...POP) <= 40);   // millions
  assert.ok(Math.min(...CAP) >= 9 && Math.max(...CAP) <= 45);    // MAF
  assert.ok(Math.min(...AG) >= 18 && Math.max(...AG) <= 42);     // MAF
  assert.ok(CAP.every((c, i) => i === 0 || c >= CAP[i - 1] - 0.01)); // capacity non-decreasing
});

test('projects have required fields', () => {
  assert.ok(PROJECTS.length >= 6);
  for (const p of PROJECTS) {
    assert.ok(typeof p.id === 'string' && p.id.length);
    assert.ok(typeof p.name === 'string');
    assert.ok(typeof p.capacity === 'number' && p.capacity > 0); // MAF
  }
  assert.ok(PROJECTS.some((p) => p.id === 'sites'));
  assert.ok(PROJECTS.some((p) => p.id === 'auburn'));
});

test('constants present', () => {
  for (const k of ['K_INFLOW', 'DEADPOOL', 'INIT_STORAGE', 'LOW_FRAC', 'GAL_PER_MAF'])
    assert.ok(typeof CONST[k] === 'number');
});

Run: node --test lab/water-viz/src/data.test.mjs Expected: FAIL — Cannot find module './data.js'.

lab/water-viz/src/data.js:

// Real historical series for the CA water-balance model. All volumes in million
// acre-feet (MAF), water years. Sources cited per series; see the design spec.

// Sacramento 4-river runoff ("SacWYsum", MAF). Source: CDEC WSIHIST
// https://cdec.water.ca.gov/reportapp/javareports?name=WSIHIST (true annual values).
const RUNOFF_BY_YEAR = {
  1950:14.44,1951:22.95,1952:28.60,1953:20.09,1954:17.43,1955:10.98,1956:29.89,1957:14.89,
  1958:29.71,1959:12.05,1960:13.06,1961:11.97,1962:15.11,1963:22.99,1964:10.92,1965:25.64,
  1966:12.95,1967:24.06,1968:13.64,1969:26.98,1970:24.06,1971:22.57,1972:13.43,1973:20.05,
  1974:32.50,1975:19.23,1976:8.20,1977:5.12,1978:23.92,1979:12.41,1980:22.33,1981:11.10,
  1982:33.41,1983:37.68,1984:22.35,1985:11.04,1986:25.83,1987:9.27,1988:9.23,1989:14.82,
  1990:9.26,1991:8.44,1992:8.87,1993:22.21,1994:7.81,1995:34.55,1996:22.29,1997:25.42,
  1998:31.40,1999:21.19,2000:18.90,2001:9.81,2002:14.60,2003:19.31,2004:16.04,2005:18.55,
  2006:32.09,2007:10.28,2008:10.28,2009:13.02,2010:16.01,2011:25.21,2012:11.84,2013:12.19,
  2014:7.46,2015:9.23,2016:17.48,2017:37.82,2018:12.86,2019:24.77,2020:9.71,2021:6.37,
  2022:10.79,2023:24.11,2024:17.47,
};

export const YEARS = Object.keys(RUNOFF_BY_YEAR).map(Number).sort((a, b) => a - b);
export const RUNOFF = YEARS.map((y) => RUNOFF_BY_YEAR[y]);

// Linear interpolation from benchmark {year: value} to a per-YEAR array.
// Benchmark sources: see comments on each call. Interpolated years are estimates.
function interpToYears(benchmarks) {
  const xs = Object.keys(benchmarks).map(Number).sort((a, b) => a - b);
  const at = (year) => {
    if (year <= xs[0]) return benchmarks[xs[0]];
    if (year >= xs.at(-1)) return benchmarks[xs.at(-1)];
    for (let i = 0; i < xs.length - 1; i++) {
      if (year >= xs[i] && year <= xs[i + 1]) {
        const t = (year - xs[i]) / (xs[i + 1] - xs[i]);
        return benchmarks[xs[i]] + t * (benchmarks[xs[i + 1]] - benchmarks[xs[i]]);
      }
    }
  };
  return YEARS.map((y) => +at(y).toFixed(4));
}

// CA population (millions). US Census decennial + CA DOF estimates.
export const POP = interpToYears({
  1950: 10.586, 1960: 15.717, 1970: 19.953, 1980: 23.668, 1990: 29.760,
  2000: 33.872, 2010: 37.254, 2020: 39.538, 2024: 39.43,
});

// Urban per-capita use (GPCD). PPIC / Pacific Institute.
export const GPCD = interpToYears({
  1950: 170, 1960: 177, 1990: 231, 2007: 231, 2010: 180, 2015: 146, 2024: 155,
});

// Agricultural applied water (MAF). Pacific Institute (2020) / DWR Bulletin 160.
// Rose to a ~1980 high, flat-to-declining since.
export const AG = interpToYears({
  1950: 22, 1960: 25.5, 1970: 30, 1980: 37, 1990: 31, 1995: 30,
  2000: 34, 2008: 37, 2015: 32.4, 2020: 32, 2024: 31,
});

// Total surface reservoir capacity (MAF). Big-reservoir buildout (CDEC/USBR/Wikipedia
// completion years) scaled so the stepped statewide total reaches ~43 MAF.
export const CAP = interpToYears({
  1950: 10.3, 1960: 15.2, 1970: 33.3, 1980: 41.6, 1990: 42.2, 2000: 43.0, 2024: 43.5,
});

// Buildable / proposed reservoirs. capacity = new storage added (MAF). Sources:
// California Water Commission / WSIP, project authorities, PPIC. Off-stream projects
// fill only from wet-year high flows.
export const PROJECTS = [
  { id: 'sites',       name: 'Sites Reservoir',         capacity: 1.5,  type: 'off-stream', status: 'in development' },
  { id: 'temperance',  name: 'Temperance Flat',         capacity: 1.26, type: 'on-stream',  status: 'shelved' },
  { id: 'shasta',      name: 'Shasta Dam raise',        capacity: 0.63, type: 'on-stream',  status: 'contested' },
  { id: 'sisk',        name: 'B.F. Sisk raise',         capacity: 0.13, type: 'off-stream', status: 'funded' },
  { id: 'losvaqueros', name: 'Los Vaqueros expansion',  capacity: 0.115, type: 'off-stream', status: 'shelved' },
  { id: 'pacheco',     name: 'Pacheco expansion',       capacity: 0.13, type: 'off-stream', status: 'suspended' },
  { id: 'delpuerto',   name: 'Del Puerto Canyon',       capacity: 0.082, type: 'off-stream', status: 'proposed' },
  { id: 'auburn',      name: 'Auburn Dam (never built)', capacity: 2.3, type: 'on-stream',  status: 'never built' },
];

export const CONST = {
  K_INFLOW: 2.2,       // scales Sac 4-river runoff to a statewide surface-inflow proxy
  DEADPOOL: 4.0,       // MAF below which no water can be delivered
  INIT_STORAGE: 20.0,  // MAF in 1950
  LOW_FRAC: 0.33,      // low-reservoir event = storage < this * physical capacity
  GAL_PER_MAF: 325851e6,
};

Run: node --test lab/water-viz/src/data.test.mjs Expected: PASS (5 tests).

git add lab/water-viz/src/data.js lab/water-viz/src/data.test.mjs
git commit -m "Add CA water-model data series with integrity tests"

Task 2: model.js — pure water-balance function

Files:

lab/water-viz/src/model.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runModel } from './model.js';
import { YEARS, CONST } from './data.js';

const iy = (y) => YEARS.indexOf(y);

test('baseline output shape', () => {
  const r = runModel({});
  assert.equal(r.years.length, 75);
  assert.equal(r.storage.length, 75);
  assert.equal(r.lowLine.length, 75);
  assert.ok(r.storage.every((s) => s >= CONST.DEADPOOL - 0.001));
});

test('baseline reproduces historical drought lows', () => {
  const r = runModel({});
  // storage drains to deadpool in the real severe droughts
  for (const y of [1977, 1991, 1992, 2014, 2015, 2022]) {
    assert.ok(r.storage[iy(y)] <= 5, `expected ${y} near deadpool`);
  }
  // and refills in the real wet years
  for (const y of [1983, 1998, 2017, 2019]) {
    assert.ok(r.storage[iy(y)] >= 35, `expected ${y} near full`);
  }
});

test('baseline low-event count locks prototype behavior', () => {
  assert.equal(runModel({}).metrics.lowEvents, 25);
});

test('building ALL proposed reservoirs avoids ~0 low events', () => {
  const base = runModel({}).metrics.lowEvents;
  const built = runModel({ addedCapacity: 3.3 }).metrics.lowEvents;
  assert.equal(base - built, 0);
});

test('population restraint avoids more low events than realistic reservoirs', () => {
  const base = runModel({}).metrics.lowEvents;
  const pop1970 = base - runModel({ popOverride: 19.953 }).metrics.lowEvents;
  const resv = base - runModel({ addedCapacity: 3.3 }).metrics.lowEvents;
  assert.ok(pop1970 > resv, `pop lever (${pop1970}) should beat reservoirs (${resv})`);
});

test('drier climate increases the supply gap', () => {
  const base = runModel({}).metrics.totalShort;
  const drier = runModel({ climateMult: 0.85 }).metrics.totalShort;
  assert.ok(drier > base);
});

test('levers default to actual when omitted (idempotent baseline)', () => {
  assert.deepEqual(runModel({}).storage, runModel({ popOverride: null, gpcdCap: null }).storage);
});

Run: node --test lab/water-viz/src/model.test.mjs Expected: FAIL — Cannot find module './model.js'.

lab/water-viz/src/model.js:

import { YEARS, RUNOFF, POP, GPCD, AG, CAP, CONST } from './data.js';

const { K_INFLOW, DEADPOOL, INIT_STORAGE, LOW_FRAC, GAL_PER_MAF } = CONST;

function urbanMAF(popMillions, gpcd) {
  return (popMillions * 1e6 * gpcd * 365) / GAL_PER_MAF;
}

// Run the annual water balance under a set of levers.
// levers: { popOverride?, gpcdCap?, addedCapacity?, climateMult? }
// Returns per-year arrays plus summary metrics.
export function runModel(levers = {}) {
  const {
    popOverride = null,   // freeze CA population (millions)
    gpcdCap = null,       // cap per-capita use (GPCD)
    addedCapacity = 0,    // extra reservoir capacity (MAF)
    climateMult = 1.0,    // multiply inflow (drier future < 1)
  } = levers;

  let storage = INIT_STORAGE;
  const out = {
    years: YEARS.slice(),
    storage: [], capacity: CAP.slice(), lowLine: CAP.map((c) => +(LOW_FRAC * c).toFixed(3)),
    shortfall: [], demand: [], spill: [], low: [],
  };
  let lowEvents = 0, shortYears = 0, totalShort = 0, totalSpill = 0, sumStorage = 0;

  for (let i = 0; i < YEARS.length; i++) {
    const pop = popOverride != null ? popOverride : POP[i];
    const g = gpcdCap != null ? Math.min(GPCD[i], gpcdCap) : GPCD[i];
    const demand = AG[i] + urbanMAF(pop, g);

    const inflow = RUNOFF[i] * K_INFLOW * climateMult;
    const capacity = CAP[i] + addedCapacity;

    let avail = storage + inflow;
    const delivered = Math.min(demand, Math.max(0, avail - DEADPOOL));
    const shortfall = demand - delivered;
    avail -= delivered;
    let spill = 0;
    if (avail > capacity) { spill = avail - capacity; avail = capacity; }
    storage = avail;

    const isLow = storage < LOW_FRAC * CAP[i]; // vs PHYSICAL capacity
    if (isLow) lowEvents++;
    if (shortfall > 0.05) { shortYears++; totalShort += shortfall; }
    totalSpill += spill;
    sumStorage += storage;

    out.storage.push(+storage.toFixed(2));
    out.shortfall.push(+shortfall.toFixed(2));
    out.demand.push(+demand.toFixed(2));
    out.spill.push(+spill.toFixed(2));
    out.low.push(isLow);
  }

  out.metrics = {
    lowEvents,
    shortYears,
    totalShort: +totalShort.toFixed(1),
    totalSpill: +totalSpill.toFixed(0),
    avgStorage: +(sumStorage / YEARS.length).toFixed(1),
    pctDemandMet: +(100 * (1 - totalShort / out.demand.reduce((a, b) => a + b, 0))).toFixed(1),
  };
  return out;
}

Run: node --test lab/water-viz/src/model.test.mjs Expected: PASS (7 tests). If lowEvents !== 25, the data series in Task 1 diverged from the prototype — diff against prototype.mjs before changing the assertion.

git add lab/water-viz/src/model.js lab/water-viz/src/model.test.mjs
git commit -m "Add pure water-balance model with behavior tests"

Task 3: charts.js — pure chart helpers + renderers

Files:

The scale math is unit-tested; the DOM rendering is verified visually in Task 7.

lab/water-viz/src/charts.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeScales, polylinePoints } from './charts.js';

test('makeScales maps domain edges to plot box', () => {
  const s = makeScales({ n: 75, yMax: 45, W: 900, H: 380, L: 42, R: 12, T: 18, B: 28 });
  assert.equal(s.x(0), 42);
  assert.equal(s.x(74), 888);          // W - R
  assert.equal(s.y(0), 352);           // H - B
  assert.equal(s.y(45), 18);           // T
});

test('polylinePoints builds an SVG points string', () => {
  const s = makeScales({ n: 3, yMax: 10, W: 100, H: 100, L: 0, R: 0, T: 0, B: 0 });
  const pts = polylinePoints([0, 5, 10], s);
  assert.equal(pts.split(' ').length, 3);
  assert.match(pts, /^0,100/);         // first point: x=0, y=100 (value 0 at bottom)
});

Run: node --test lab/water-viz/src/charts.test.mjs Expected: FAIL — Cannot find module './charts.js'.

lab/water-viz/src/charts.js:

const NS = 'http://www.w3.org/2000/svg';

// Pure: build x/y scale fns mapping data index/value to SVG coordinates.
export function makeScales({ n, yMax, W, H, L, R, T, B }) {
  const pW = W - L - R, pH = H - T - B;
  return {
    W, H, L, R, T, B, pW, pH, yMax, n,
    x: (i) => L + (n <= 1 ? 0 : (i / (n - 1)) * pW),
    y: (v) => T + (1 - v / yMax) * pH,
  };
}

// Pure: SVG points attribute for a polyline over a value array.
export function polylinePoints(values, s) {
  return values.map((v, i) => `${+s.x(i).toFixed(1)},${+s.y(v).toFixed(1)}`).join(' ');
}

function el(tag, attrs) {
  const e = document.createElementNS(NS, tag);
  for (const k in attrs) e.setAttribute(k, attrs[k]);
  return e;
}

// Render the storage-over-time chart into `mount`.
// data: { years, capacity, lowLine, baseline, scenario, lowFlags }
export function renderStorageChart(mount, data, opts = {}) {
  const W = opts.W || 900, H = opts.H || 360;
  const s = makeScales({ n: data.years.length, yMax: 45, W, H, L: 42, R: 12, T: 16, B: 26 });
  const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%' });

  for (let v = 0; v <= 40; v += 10) {
    svg.appendChild(el('line', { x1: s.L, y1: s.y(v), x2: W - s.R, y2: s.y(v), stroke: 'rgba(255,255,255,.07)' }));
    const t = el('text', { x: s.L - 6, y: s.y(v) + 3, fill: '#64748b', 'font-size': 10, 'text-anchor': 'end' });
    t.textContent = v; svg.appendChild(t);
  }
  data.years.forEach((yr, i) => {
    if (yr % 10 === 0) {
      const t = el('text', { x: s.x(i), y: H - 8, fill: '#64748b', 'font-size': 10, 'text-anchor': 'middle' });
      t.textContent = yr; svg.appendChild(t);
    }
  });

  // severe-drought shading
  [[1976,1977],[1987,1992],[2007,2010],[2012,2015],[2020,2022]].forEach(([a, b]) => {
    const ia = data.years.indexOf(a), ib = data.years.indexOf(b);
    if (ia < 0 || ib < 0) return;
    svg.appendChild(el('rect', { x: s.x(ia), y: s.T, width: s.x(ib) - s.x(ia) + s.pW / (s.n - 1), height: s.pH, fill: '#7f1d1d', 'fill-opacity': .18 }));
  });

  svg.appendChild(el('polyline', { points: polylinePoints(data.capacity, s), fill: 'none', stroke: '#64748b', 'stroke-width': 1.3, 'stroke-dasharray': '2 3' }));
  svg.appendChild(el('polyline', { points: polylinePoints(data.lowLine, s), fill: 'none', stroke: '#f59e0b', 'stroke-width': 1.5, 'stroke-dasharray': '5 4', 'stroke-opacity': .8 }));
  svg.appendChild(el('polyline', { points: polylinePoints(data.scenario, s), fill: 'none', stroke: '#22c55e', 'stroke-width': 2 }));
  svg.appendChild(el('polyline', { points: polylinePoints(data.baseline, s), fill: 'none', stroke: '#3b82f6', 'stroke-width': 2, 'stroke-opacity': .55 }));

  data.scenario.forEach((v, i) => {
    if (v < data.lowLine[i]) svg.appendChild(el('circle', { cx: s.x(i), cy: s.y(v), r: 2.6, fill: '#22c55e' }));
  });

  mount.replaceChildren(svg);
}

// Render the unmet-demand bar chart into `mount`.
export function renderGapChart(mount, data, opts = {}) {
  const W = opts.W || 900, H = opts.H || 90;
  const yMax = Math.max(2, ...data.shortfall);
  const s = makeScales({ n: data.years.length, yMax, W, H, L: 42, R: 12, T: 14, B: 14 });
  const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%' });
  const bw = Math.max(2, s.pW / s.n - 1);
  data.shortfall.forEach((v, i) => {
    if (v <= 0.05) return;
    svg.appendChild(el('rect', { x: s.x(i) - bw / 2, y: s.y(v), width: bw, height: s.y(0) - s.y(v), fill: '#ef4444', 'fill-opacity': .8 }));
  });
  const t = el('text', { x: s.L, y: 11, fill: '#94a3b8', 'font-size': 10 });
  t.textContent = 'unmet demand / yr (MAF)'; svg.appendChild(t);
  mount.replaceChildren(svg);
}

Run: node --test lab/water-viz/src/charts.test.mjs Expected: PASS (2 tests).

git add lab/water-viz/src/charts.js lab/water-viz/src/charts.test.mjs
git commit -m "Add SVG chart renderers with pure-scale unit tests"

Task 4: Embed front-end (Layout A) — controls on top, charts stacked

Files:

lab/water-viz/embed.html (NO Jekyll front matter):

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>California reservoirs: a water-balance model</title>
    <link rel="stylesheet" href="./embed.css" />
  </head>
  <body>
    <div class="app">
      <div class="metrics">
        <span class="pill" id="pill-low">Low-reservoir years: -</span>
        <span class="pill" id="pill-avoided">Avoided vs actual: -</span>
        <span class="pill" id="pill-met">Demand met: -</span>
        <span class="pill" id="pill-spill">Spilled: -</span>
      </div>

      <div class="controls">
        <div class="knob">
          <label>Population <output id="v-pop">actual</output></label>
          <input id="s-pop" type="range" min="10.6" max="39.5" step="0.1" value="39.5" />
        </div>
        <div class="knob">
          <label>Per-capita use <output id="v-gpcd">actual</output></label>
          <input id="s-gpcd" type="range" min="100" max="231" step="1" value="231" />
        </div>
        <div class="knob">
          <label>Drier future <output id="v-climate">0%</output></label>
          <input id="s-climate" type="range" min="0" max="30" step="1" value="0" />
        </div>
        <div class="knob">
          <label>Go further (extra capacity) <output id="v-extra">0 MAF</output></label>
          <input id="s-extra" type="range" min="0" max="25" step="0.5" value="0" />
        </div>
        <div class="projects" id="projects"></div>
        <button id="btn-reset" class="btn">Reset</button>
      </div>

      <div class="chart" id="chart-storage"></div>
      <div class="legend" id="legend"></div>
      <div class="chart" id="chart-gap"></div>
    </div>
    <script type="module" src="./src/embed-main.js"></script>
  </body>
</html>

lab/water-viz/embed.css:

:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0b1220; color: #e2e8f0;
  font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.app { padding: 12px; }
.metrics { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.pill { background: #10203a; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.controls { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 16px; margin-bottom: 12px; }
.knob label { display: flex; justify-content: space-between; font-size: 11px;
  text-transform: uppercase; letter-spacing: .04em; opacity: .7; }
.knob output { opacity: 1; font-weight: 600; }
.knob input[type=range] { width: 100%; }
.projects { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font-size: 11px; padding: 3px 8px; border: 1px solid rgba(255,255,255,.18);
  border-radius: 999px; cursor: pointer; user-select: none; }
.chip.on { background: #1f6feb33; border-color: #1f6feb; }
.btn { grid-column: 1 / -1; justify-self: start; background: #1e293b; color: #e2e8f0;
  border: 1px solid rgba(255,255,255,.15); border-radius: 8px; padding: 5px 12px; cursor: pointer; }
.chart { background: #0b1220; border: 1px solid rgba(255,255,255,.1); border-radius: 8px; margin-bottom: 6px; }
.legend { font-size: 11px; opacity: .85; margin: 2px 0 8px; }
.legend span { margin-right: 12px; }
.sw { display: inline-block; width: 12px; height: 3px; vertical-align: middle; margin-right: 4px; }
@media (max-width: 520px) { .controls { grid-template-columns: 1fr; } }

lab/water-viz/src/embed-main.js:

import { runModel } from './model.js';
import { renderStorageChart, renderGapChart } from './charts.js';
import { PROJECTS } from './data.js';

function $(id) {
  const el = document.getElementById(id);
  if (!el) throw new Error(`Missing element #${id}`);
  return el;
}

const ui = {
  pop: $('s-pop'), gpcd: $('s-gpcd'), climate: $('s-climate'), extra: $('s-extra'),
  vPop: $('v-pop'), vGpcd: $('v-gpcd'), vClimate: $('v-climate'), vExtra: $('v-extra'),
  projects: $('projects'), reset: $('btn-reset'),
  chartStorage: $('chart-storage'), chartGap: $('chart-gap'), legend: $('legend'),
  low: $('pill-low'), avoided: $('pill-avoided'), met: $('pill-met'), spill: $('pill-spill'),
};

const POP_MAX = 39.5; // slider max = "actual" (no override)
const GPCD_MAX = 231; // slider max = "actual"
const selected = new Set();

PROJECTS.forEach((p) => {
  const chip = document.createElement('span');
  chip.className = 'chip';
  chip.textContent = `${p.name} +${p.capacity} MAF`;
  chip.title = `${p.type}, ${p.status}`;
  chip.onclick = () => {
    chip.classList.toggle('on');
    if (selected.has(p.id)) selected.delete(p.id); else selected.add(p.id);
    update();
  };
  chip.dataset.id = p.id;
  ui.projects.appendChild(chip);
});

ui.legend.innerHTML =
  '<span><span class="sw" style="background:#3b82f6;opacity:.55"></span>actual</span>' +
  '<span><span class="sw" style="background:#22c55e"></span>your scenario</span>' +
  '<span><span class="sw" style="background:#f59e0b"></span>low threshold</span>' +
  '<span><span class="sw" style="background:#64748b"></span>capacity</span>';

function leversFromUI() {
  const popVal = +ui.pop.value;
  const gpcdVal = +ui.gpcd.value;
  const projectCap = PROJECTS.filter((p) => selected.has(p.id)).reduce((a, p) => a + p.capacity, 0);
  return {
    popOverride: popVal >= POP_MAX ? null : popVal,
    gpcdCap: gpcdVal >= GPCD_MAX ? null : gpcdVal,
    climateMult: 1 - (+ui.climate.value) / 100,
    addedCapacity: projectCap + (+ui.extra.value),
  };
}

const baseline = runModel({});

function update() {
  const levers = leversFromUI();
  const scenario = runModel(levers);

  ui.vPop.textContent = levers.popOverride == null ? 'actual' : `${levers.popOverride.toFixed(1)}M`;
  ui.vGpcd.textContent = levers.gpcdCap == null ? 'actual' : `${levers.gpcdCap} GPCD`;
  ui.vClimate.textContent = `-${ui.climate.value}%`;
  ui.vExtra.textContent = `${(+ui.extra.value).toFixed(1)} MAF`;

  renderStorageChart(ui.chartStorage, {
    years: scenario.years, capacity: scenario.capacity, lowLine: scenario.lowLine,
    baseline: baseline.storage, scenario: scenario.storage,
  });
  renderGapChart(ui.chartGap, { years: scenario.years, shortfall: scenario.shortfall });

  const avoided = baseline.metrics.lowEvents - scenario.metrics.lowEvents;
  ui.low.textContent = `Low-reservoir years: ${scenario.metrics.lowEvents}`;
  ui.avoided.textContent = `Avoided vs actual: ${avoided >= 0 ? '+' : ''}${avoided}`;
  ui.met.textContent = `Demand met: ${scenario.metrics.pctDemandMet}%`;
  ui.spill.textContent = `Spilled: ${scenario.metrics.totalSpill} MAF`;
}

[ui.pop, ui.gpcd, ui.climate, ui.extra].forEach((s) => s.addEventListener('input', update));
ui.reset.onclick = () => {
  ui.pop.value = POP_MAX; ui.gpcd.value = GPCD_MAX; ui.climate.value = 0; ui.extra.value = 0;
  selected.clear();
  ui.projects.querySelectorAll('.chip').forEach((c) => c.classList.remove('on'));
  update();
};

update();

Run: bundle exec jekyll serve (in another terminal), then open http://localhost:4000/lab/water-viz/embed.html. Expected: controls render, charts draw, dragging Population/Per-capita/Drier/Go-further updates the green line and the metric pills; toggling project chips adds capacity. No console errors.

git add lab/water-viz/embed.html lab/water-viz/embed.css lab/water-viz/src/embed-main.js
git commit -m "Add embed front-end for water-viz (controls + charts)"

Task 5: Full-page front-end (Layout B) — controls in a left sidebar

Files:

lab/water-viz/index.html (NO Jekyll front matter):

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>California reservoirs, droughts & population - a water-balance model</title>
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <div class="page">
      <header>
        <h1>California reservoirs, droughts &amp; population</h1>
        <p class="sub">Drag the levers and watch statewide reservoir levels - and the
          droughts they fail to prevent - recompute over 1950-2024. A transparent toy model;
          the lever ranking is the point, not the exact figures.</p>
      </header>
      <div class="layout">
        <aside class="sidebar">
          <div class="group-title">Demand</div>
          <div class="knob"><label>Population <output id="v-pop">actual</output></label>
            <input id="s-pop" type="range" min="10.6" max="39.5" step="0.1" value="39.5" /></div>
          <div class="knob"><label>Per-capita use <output id="v-gpcd">actual</output></label>
            <input id="s-gpcd" type="range" min="100" max="231" step="1" value="231" /></div>
          <div class="group-title">Climate</div>
          <div class="knob"><label>Drier future <output id="v-climate">0%</output></label>
            <input id="s-climate" type="range" min="0" max="30" step="1" value="0" /></div>
          <div class="group-title">Build storage</div>
          <div class="projects" id="projects"></div>
          <div class="knob"><label>Go further <output id="v-extra">0 MAF</output></label>
            <input id="s-extra" type="range" min="0" max="25" step="0.5" value="0" /></div>
          <button id="btn-reset" class="btn">Reset to actual</button>
        </aside>
        <main class="main">
          <div class="metrics">
            <span class="pill" id="pill-low">Low-reservoir years: -</span>
            <span class="pill" id="pill-avoided">Avoided vs actual: -</span>
            <span class="pill" id="pill-met">Demand met: -</span>
            <span class="pill" id="pill-spill">Spilled: -</span>
          </div>
          <div class="chart" id="chart-storage"></div>
          <div class="legend" id="legend"></div>
          <div class="chart" id="chart-gap"></div>
        </main>
      </div>
    </div>
    <script type="module" src="./src/main.js"></script>
  </body>
</html>

lab/water-viz/styles.css:

:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #070d18; color: #e2e8f0;
  font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.page { max-width: 1100px; margin: 0 auto; padding: 24px 18px 60px; }
header h1 { margin: 0 0 6px; font-size: 26px; }
.sub { opacity: .75; max-width: 70ch; margin: 0 0 20px; }
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 20px; }
.sidebar { background: #0b1220; border: 1px solid rgba(255,255,255,.1); border-radius: 12px; padding: 14px; height: max-content; }
.group-title { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; opacity: .55; margin: 12px 0 4px; }
.group-title:first-child { margin-top: 0; }
.knob label { display: flex; justify-content: space-between; font-size: 12px; opacity: .8; }
.knob output { font-weight: 600; }
.knob input[type=range] { width: 100%; margin: 4px 0 8px; }
.projects { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font-size: 12px; padding: 4px 9px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; cursor: pointer; user-select: none; }
.chip.on { background: #1f6feb33; border-color: #1f6feb; }
.btn { margin-top: 12px; width: 100%; background: #1e293b; color: #e2e8f0; border: 1px solid rgba(255,255,255,.15); border-radius: 8px; padding: 7px; cursor: pointer; }
.metrics { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.pill { background: #10203a; border-radius: 999px; padding: 5px 12px; font-size: 13px; }
.chart { background: #0b1220; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; padding: 6px; margin-bottom: 6px; }
.legend { font-size: 12px; opacity: .85; margin: 4px 0 10px; }
.legend span { margin-right: 14px; }
.sw { display: inline-block; width: 13px; height: 3px; vertical-align: middle; margin-right: 4px; }
@media (max-width: 720px) { .layout { grid-template-columns: 1fr; } }

lab/water-viz/src/main.js:

// Full-page front-end. Shares model + charts with the embed; same control IDs,
// so the wiring is identical. Kept as its own entry to allow page-specific tweaks.
import './embed-main.js';

Note: index.html and embed.html use identical control/element IDs, so embed-main.js drives both. main.js re-exports it as the page’s entry point. If the full page later needs extra panels, give them new IDs and extend here.

Run: open http://localhost:4000/lab/water-viz/ . Expected: sidebar layout, same interactivity as the embed, responsive collapse under 720px.

git add lab/water-viz/index.html lab/water-viz/styles.css lab/water-viz/src/main.js
git commit -m "Add full-page front-end for water-viz"

Task 6: Draft the blog post

Files:

_drafts/california-reservoirs.md:

---
title: >
    The reservoir we can't fill
layout: post
group: blog
description: >
    Why building more dams is the weakest lever against California's droughts
---

# I.

[Open on the recurring image: California reservoirs low again, the cracked-mud
photographs, the reflexive call to build more dams. State the question the post
will actually answer: of the levers we argue about - build storage, slow growth,
use less - which one would have done the most?]

# II.

[What a reservoir actually does: it moves wet-year water into dry years. It does
not create water. Introduce the three forces - how much falls (climate), how much
we use (demand), how much we can hold (supply) - and note that storage only helps
to the extent there is surplus to store.]

# III.

To see how these forces interact, I built a small model of California's surface
reservoirs from 1950 to today. It runs a yearly water balance on real data - actual
runoff, the real dam-building record, population, farm and city demand - and lets
you change one thing at a time. Try the obvious fix first: switch on every reservoir
seriously proposed today.

<iframe src="/lab/water-viz/embed.html" style="width: 100%; height: 760px; border: 1px solid rgba(255,255,255,.12); border-radius: 14px; background: #0b1220;" frameborder="0" loading="lazy"></iframe>

<p style="text-align: center; font-size: 0.85em; color: #888; margin-top: 8px;">
<a href="/lab/water-viz/" target="_blank">Open the full version in a new tab</a> for the best experience.
</p>

[Reveal: building all of them avoids essentially zero low-reservoir events. Name why.]

# IV.

[The three dynamics worth watching, in the model's voice:
- You can't fill what won't fill: in the deep droughts the new reservoirs sit empty;
  spill barely moves as you add capacity.
- Population is a bigger lever than concrete - even though cities are ~20% of use.
- But the giant nobody toggles is agriculture: ~80% of use, and it decoupled from
  population decades ago as farms shifted to permanent crops that can't be fallowed.
- A modestly drier climate swamps every supply-side fix.]

# V.

[The uncomfortable conclusion: the popular answer is the least effective; the
effective answers are the politically hardest. Then the honest limitations - single
statewide bucket, groundwater out of model (the gap is really overdraft), snowpack
folded into runoff, absolute numbers depend on calibration. End on a sharp line, no
neat bow.]

Replace each [bracketed] block with finished prose. Follow agent-instructions/writing/writing_like_jacob.md: first person, measured claims, concrete to abstract, metaphors from the author’s own domains, no emdashes, cut aggressively, no neat bow. Pull concrete numbers from the spec’s findings section (events-avoided table, the ~2% new-supply figure, ag ~80%, population 3.7x). Score against agent-instructions/writing/writing_assessment_rubric.md (target LLM < 30, Jacob > 65) and revise until it passes.

Run: bundle exec jekyll serve --drafts, open the post URL. Expected: post renders, iframe loads the embed inline, “open full version” link works.

git add _drafts/california-reservoirs.md
git commit -m "Draft California reservoirs post with interactive embed"

Task 7: Browser verification + publish

Files:

Run: node --test lab/water-viz/src/ Expected: all tests pass (data, model, charts).

With bundle exec jekyll serve running, in a browser check both /lab/water-viz/embed.html and /lab/water-viz/:

git mv _drafts/california-reservoirs.md _posts/2026-05-28-california-reservoirs.md

Confirm the post appears in the blog index under bundle exec jekyll serve.

git add -A
git commit -m "Publish California reservoirs post"

Self-Review notes (for the planner)