Skip to content

Commit 3baec71

Browse files
bbestclaude
andcommitted
feat: climatological anomalies, a comparable x-axis, and a methods panel
Three additions, plus the fixes they surfaced. ANOMALIES build_sections.sql now builds a monthly climatology — a plain mean per (station, 5 m depth bin, calendar month) over 1993-2013 — and differences each section against it. The window is stated once at the top of that file and carried through to the app's methods panel, because an anomaly whose baseline is not on screen is not interpretable. 1993-2013 is available for the first time in this release: the Wilkinson archive backfills 1993-08 through 2002, where the published record jumped 1998 -> 2003. Ask for it a month ago and it would have quietly meant "1998 plus 2003-2013". 21 years averages over the 1997-99 El Nino/La Nina and ends before the 2014-16 heatwave, so the heatwave and what has followed read as departures rather than being folded into the normal. The anomaly join is INNER, so a cell with no baseline is absent, never zero — an unsampled baseline is not a zero anomaly. 89.8% of section values have one; each section reports its own share in the note under the plot rather than leaving the reader to estimate blank area by eye. Rudnick et al. (2017) fit harmonics for the CUGN glider climatology, which suits near-continuous glider sampling. CalCOFI's is episodic and unevenly spaced, so a monthly mean is used instead — defensible, and legible enough that a reader can say what the departure is from. The same definition is available in R as calcofi4r::cc_climatology(); the two are separate implementations of one definition because this repo's CI has no R. COMPARABLE X-AXIS (default off) The x-axis defaults to distance between the stations the cruise actually occupied, so the section fills the plot. The new toggle switches to distance along the full line, with the axis range fixed to the LINE's extent rather than the cruise's — an axis that resizes with the selection is not a comparison. Line 93.3 has not been sampled past station 90 since 2025-01, though 113 of the 130 cruises before it reached station 120. Under the default ruler a recent section is drawn the same width as a historical one covering 40% more ocean. MAP: THREE CLASSES, NOT TWO A station absent from the section is absent for two unrelated reasons — it is on another line, or this cruise did not reach it. Drawn identically, a shortened line 93.3 looks like a line that simply ends at station 90. Stations on this line but unoccupied are now hollow rings in the transect's own hue, and the panel states the count. METHODS PANEL A closed-by-default accordion covering all of the above, with links to both build scripts and to calcofi4r's transect.R. Fixes found while checking it in a browser: * The y-axis zeroline draws a dark rule along depth 0. Invisible under a full-width section; under the comparable ruler it runs on past the last station occupied and reads as data. * The seafloor is warped onto each cruise's occupied x, so under the along-line ruler it placed the shelf break tens of km from where it is. The line's own profile now ships once per line in index.json. * connectgaps must stay ON in the anomaly view. `obs` carries the THINNED CTD series, so most holes are depths with no scan rather than missing baseline — and tracing a contour over a matrix that sparse locked the page. * Plotly measures its container before the map and notes settle the grid, so the first paint overflowed its panel and put the colorbar on the map until the user resized. Re-measured on the next frame. Data shards are deliberately NOT in this commit: the current release carries a CTD temperature defect being re-ingested now, and publishing anomalies computed against it would ship known-wrong numbers. refresh.yml rebuilds them from the corrected release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDEjUhDHbK9RUfqkfkDMjr
1 parent 610295a commit 3baec71

5 files changed

Lines changed: 598 additions & 31 deletions

File tree

public/app.js

Lines changed: 205 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,20 @@ const RAMP_DIV_DARK = [
7474

7575
const DIVERGING = new Set(["temperature_ave"]);
7676

77+
/* An ANOMALY is polarity, not magnitude — above or below normal — so it always
78+
* gets the diverging ramp with the neutral pinned to zero, whatever the variable.
79+
* Pinning matters: left to autoscale, a section that happens to be warm
80+
* throughout would put the neutral at its own mean and paint the coolest part of
81+
* a uniformly-warm cruise blue. `zmid` does the pinning; the ramp is the same
82+
* one temperature uses so the two views read consistently. */
83+
7784
const darkMode = () =>
7885
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
7986

80-
function scaleFor(varName) {
81-
if (!DIVERGING.has(varName)) return RAMP_SEQ;
82-
return darkMode() ? RAMP_DIV_DARK : RAMP_DIV_LIGHT;
87+
function scaleFor(varName, mode) {
88+
if (mode === "anomaly" || DIVERGING.has(varName))
89+
return darkMode() ? RAMP_DIV_DARK : RAMP_DIV_LIGHT;
90+
return RAMP_SEQ;
8391
}
8492

8593
/* Dark mode is selected, not flipped: the ramps keep their poles and only the
@@ -128,12 +136,20 @@ function varLabel(v) {
128136

129137
function readURL() {
130138
const p = new URLSearchParams(location.search);
131-
return { line: p.get("line"), cruise: p.get("cruise"), var: p.get("var") };
139+
return {
140+
line: p.get("line"), cruise: p.get("cruise"), var: p.get("var"),
141+
mode: p.get("mode"),
142+
// absent means the default, which is the OCCUPIED ruler: show the data as
143+
// large as it can be drawn, and let comparison be the thing you opt into
144+
ruler: p.get("ruler") === "line" ? "line" : "occupied",
145+
};
132146
}
133147

134148
function writeURL(sel) {
135149
const p = new URLSearchParams(
136150
{ line: sel.line, cruise: sel.cruise, var: sel.var });
151+
if (sel.mode === "anomaly") p.set("mode", "anomaly");
152+
if (sel.ruler === "line") p.set("ruler", "line");
137153
history.replaceState(null, "", `${location.pathname}?${p}`);
138154
}
139155

@@ -211,35 +227,70 @@ function resolve(sel) {
211227
const alt = state.index.variables.find((x) => x.prefer === sel.var);
212228
v = (alt && vars.find((x) => x.var === alt.var)) || vars[0];
213229
}
214-
return { line: line.line, cruise: cruise.cruise_key, var: v ? v.var : null };
230+
// mode and ruler are carried through untouched: they are view state, valid for
231+
// any selection, and dropping them here silently reverted the URL to the
232+
// defaults on every render
233+
return { line: line.line, cruise: cruise.cruise_key, var: v ? v.var : null,
234+
mode: sel.mode === "anomaly" ? "anomaly" : "value",
235+
ruler: sel.ruler === "line" ? "line" : "occupied" };
215236
}
216237

217238
/* ── the section plot ────────────────────────────────────────────────────── */
218239

219-
function drawSection(shard, varName, maxDepth) {
240+
function drawSection(shard, varName, maxDepth, mode, ruler) {
220241
const meta = state.index.variables.find((v) => v.var === varName);
221-
const x = shard.stations.map((s) => s.dist_km);
242+
const anom = mode === "anomaly";
243+
244+
/* Which ruler. `line_dist_km` is each station's distance along the full line;
245+
* it is null where the bathymetry build has no entry for a station, and a
246+
* partly-null axis is worse than the wrong one, so fall back wholesale. */
247+
const haveLine = shard.stations.every((s) => s.line_dist_km != null);
248+
const useLine = ruler === "line" && haveLine;
249+
const x = shard.stations.map((s) => (useLine ? s.line_dist_km : s.dist_km));
250+
222251
const keep = shard.depths.map((d, i) => [d, i]).filter(([d]) => d <= maxDepth);
223252
const y = keep.map(([d]) => d);
224-
const z = keep.map(([, i]) => shard.vars[varName][i]);
253+
const grid = anom ? (shard.anom || {})[varName] : shard.vars[varName];
254+
const z = grid ? keep.map(([, i]) => grid[i]) : keep.map(() => x.map(() => null));
225255

226256
const t = theme();
257+
const units = meta.units || "";
258+
const zlabel = anom ? `${meta.label} anomaly` : meta.label;
227259

228260
const traces = [{
229261
type: "heatmap",
230262
x, y, z,
231263
// zsmooth is what replaces an interpolation step: the renderer resamples the
232264
// station x depth matrix into the smooth field an ODV-style section wants
233265
zsmooth: "best",
234-
colorscale: scaleFor(varName),
266+
colorscale: scaleFor(varName, mode),
267+
// zero is NORMAL, and it must sit on the neutral wherever the data lands —
268+
// otherwise a uniformly warm cruise paints its least-warm part blue
269+
...(anom ? { zmid: 0 } : {}),
270+
/* connectgaps bridges a station that missed a depth bin, and it must stay ON
271+
* in both views.
272+
*
273+
* The tempting reading — "a gap in the anomaly means no baseline, so show
274+
* it" — is wrong twice. First, `obs` carries the THINNED CTD series (a 10 m
275+
* grid plus RDP inflection points plus bottle depths), so most holes in the
276+
* matrix are depths this cast simply has no scan at, in the value view
277+
* exactly as much as in the anomaly view; leaving them open reads as missing
278+
* baseline when it is missing sampling. Second, it is pathological: the
279+
* contour trace below tracing a matrix that sparse locked the page for tens
280+
* of seconds and had to be killed.
281+
*
282+
* How much of the section actually HAS a baseline is reported as a number in
283+
* the note under the plot instead, which says it precisely rather than
284+
* leaving the reader to estimate blank area by eye. */
235285
connectgaps: true,
236286
hovertemplate:
237287
"%{customdata}<br>Depth: %{y} m<br>" +
238-
`${meta.label}: %{z}${meta.units ? " " + meta.units : ""}<extra></extra>`,
288+
`${zlabel}: %{z}${units ? " " + units : ""}<extra></extra>`,
239289
customdata: z.map((row) =>
240290
row.map((_, j) => `Station ${shard.stations[j].sta} · ${x[j].toFixed(0)} km`)),
241291
colorbar: {
242-
title: { text: meta.units || "", side: "right" },
292+
title: { text: anom ? (units ? "\u0394 " + units : "\u0394") : units,
293+
side: "right" },
243294
thickness: 12, outlinewidth: 0, tickfont: { color: t.ink, size: 11 },
244295
titlefont: { color: t.ink, size: 11 },
245296
},
@@ -264,7 +315,11 @@ function drawSection(shard, varName, maxDepth) {
264315
* a single triangle 74 km wide and 1.5 km tall, because its neighbours are
265316
* 37 km away in deep water — terrain that does not exist, sitting right where
266317
* the thermocline is read. */
267-
const fl = shard.floor;
318+
/* Under the comparable ruler the x-axis IS along-line distance, so the floor
319+
* must come from the line's own profile (carried once in index.json) rather
320+
* than from the copy warped onto this cruise's occupied stations — that copy
321+
* would put the shelf break tens of km from where it is. */
322+
const fl = useLine ? (lineByName(shard.line) || {}).floor : shard.floor;
268323
if (fl && fl.dist_km.length > 1) {
269324
const n = fl.dist_km.length;
270325
traces.push({
@@ -298,18 +353,30 @@ function drawSection(shard, varName, maxDepth) {
298353
// panel and lands on top of the map
299354
margin: { l: 58, r: 86, t: 44, b: 52 },
300355
title: {
301-
text: `Line ${shard.line} · ${shard.cruise_key.slice(0, 7)} · ${meta.label}`,
356+
text: `Line ${shard.line} · ${shard.cruise_key.slice(0, 7)} · ${zlabel}`,
302357
font: { size: 15, color: t.ink },
303358
},
304359
font: { color: t.ink },
305360
xaxis: {
306-
title: { text: "Distance offshore (km)", font: { size: 12 } },
361+
title: {
362+
text: useLine ? "Distance along line (km)" : "Distance offshore (km)",
363+
font: { size: 12 },
364+
},
307365
zeroline: false, gridcolor: t.grid,
308-
range: [Math.min(...x), Math.max(...x)],
366+
// On the shared ruler the range is the LINE's full extent, not this
367+
// cruise's — an axis that resizes with the cruise is not a comparison.
368+
range: useLine
369+
? [0, lineExtent(shard.line) ?? Math.max(...x)]
370+
: [Math.min(...x), Math.max(...x)],
309371
},
310372
yaxis: {
311373
title: { text: "Depth (m)", font: { size: 12 } },
312374
autorange: "reversed", range: [maxDepth, 0], gridcolor: t.grid,
375+
// depth 0 IS the y-origin, so Plotly's zeroline draws a dark rule right
376+
// along the sea surface. It is invisible under a full-width section and
377+
// very visible under the comparable ruler, where it runs on alone past the
378+
// last station the cruise occupied and reads as data.
379+
zeroline: false,
313380
},
314381
plot_bgcolor: t.panel,
315382
paper_bgcolor: "rgba(0,0,0,0)",
@@ -320,14 +387,29 @@ function drawSection(shard, varName, maxDepth) {
320387
{ responsive: true, displaylogo: false });
321388
}
322389

390+
function lineExtent(name) {
391+
const l = lineByName(name);
392+
return l && l.extent_km != null ? l.extent_km : null;
393+
}
394+
323395
/* ── the map ─────────────────────────────────────────────────────────────── */
324396

325397
/* Plotly's built-in geo layer draws Natural Earth coastlines locally, so the map
326398
* needs no tile server and no second mapping library. */
399+
/* Three classes, because two conflate the question a reader actually has.
400+
*
401+
* A station missing from the section is missing for one of two entirely
402+
* different reasons: it is on ANOTHER line (irrelevant), or it is on THIS line
403+
* and this cruise did not reach it (a coverage gap, and the reason the section
404+
* stops where it does). Drawn identically, the shortened line 93.3 transects
405+
* since 2025-01 look like the line simply ends at station 90. */
327406
function drawMap(shard) {
328407
const dark = darkMode();
329408
const on = new Set(shard.stations.map((s) => s.grid_key));
330-
const off = state.stations.filter((s) => !on.has(s.grid_key));
409+
const onLine = state.stations.filter(
410+
(s) => s.line === shard.line && !on.has(s.grid_key));
411+
const off = state.stations.filter(
412+
(s) => s.line !== shard.line && !on.has(s.grid_key));
331413

332414
const traces = [{
333415
type: "scattergeo",
@@ -336,6 +418,17 @@ function drawMap(shard) {
336418
marker: { size: 3, color: dark ? "rgba(150,160,172,0.5)"
337419
: "rgba(110,122,134,0.55)" },
338420
hoverinfo: "skip", showlegend: false,
421+
}, {
422+
// on this line but not occupied: hollow, in the transect's own hue, so it
423+
// reads as "part of this transect, absent" rather than as another line
424+
type: "scattergeo",
425+
lon: onLine.map((s) => s.lon), lat: onLine.map((s) => s.lat),
426+
mode: "markers",
427+
marker: { size: 7, color: "rgba(0,0,0,0)",
428+
line: { color: "#e34948", width: 1.5 } },
429+
text: onLine.map((s) => `Station ${s.sta} · not occupied on this cruise`),
430+
hovertemplate: "%{text}<extra></extra>",
431+
showlegend: false,
339432
}, {
340433
// the transect, drawn in the warm pole of the diverging ramp so it reads as
341434
// "the selected thing" against the grey grid without introducing a new hue
@@ -351,6 +444,12 @@ function drawMap(shard) {
351444
showlegend: false,
352445
}];
353446

447+
const nMissed = onLine.length;
448+
$("map-note").textContent = nMissed
449+
? `This cruise occupied ${shard.stations.length} of the ` +
450+
`${shard.stations.length + nMissed} stations on line ${shard.line}.`
451+
: `This cruise occupied every station on line ${shard.line}.`;
452+
354453
Plotly.react($("map"), traces, {
355454
margin: { l: 0, r: 0, t: 0, b: 0 },
356455
geo: {
@@ -373,7 +472,6 @@ function drawMap(shard) {
373472
async function render(sel) {
374473
sel = resolve(sel);
375474
const { cruise } = syncControls(sel);
376-
writeURL(sel);
377475

378476
const shard = await getJSON("data/" + cruise.file);
379477
state.shard = shard;
@@ -388,16 +486,83 @@ async function render(sel) {
388486
if (STAGE_NOTE[stage]) { note.textContent = STAGE_NOTE[stage]; note.hidden = false; }
389487
else note.hidden = true;
390488

489+
// Offer the anomaly view only where a baseline exists for this variable. A
490+
// mode picker that silently draws an empty panel is worse than one that says
491+
// the anomaly is unavailable.
492+
const zv = shard.vars[sel.var];
493+
const za = shard.anom && shard.anom[sel.var];
494+
let nVal = 0, nAnom = 0;
495+
if (zv) {
496+
for (let i = 0; i < zv.length; i++)
497+
for (let j = 0; j < zv[i].length; j++)
498+
if (zv[i][j] != null) {
499+
nVal++;
500+
if (za && za[i] && za[i][j] != null) nAnom++;
501+
}
502+
}
503+
const hasAnom = nAnom > 0;
504+
const pctAnom = nVal ? Math.round((100 * nAnom) / nVal) : 0;
505+
const modeEl = $("sel-mode");
506+
modeEl.options[1].disabled = !hasAnom;
507+
const mode = hasAnom && sel.mode === "anomaly" ? "anomaly" : "value";
508+
modeEl.value = mode;
509+
510+
const anomNote = $("anom-note");
511+
if (mode === "anomaly") {
512+
const b = state.index.baseline;
513+
anomNote.textContent =
514+
`Departure from the ${b.yr_min}${b.yr_max} mean for this station, depth ` +
515+
`and calendar month (${b.n_cruises} cruises, minimum ${b.min_n} ` +
516+
`observations per cell). ${pctAnom}% of this section's measurements have ` +
517+
`such a baseline; the rest are drawn from neighbouring values and should ` +
518+
`not be read closely. See Methods below.`;
519+
anomNote.hidden = false;
520+
} else if (!hasAnom && sel.mode === "anomaly") {
521+
anomNote.textContent =
522+
"No climatological baseline covers this variable, so the anomaly view is " +
523+
"unavailable; showing measured values.";
524+
anomNote.hidden = false;
525+
} else {
526+
anomNote.hidden = true;
527+
}
528+
529+
// The comparable ruler needs a full-line distance on every station
530+
const haveLine = shard.stations.every((s) => s.line_dist_km != null);
531+
$("sel-ruler").disabled = !haveLine;
532+
$("sel-ruler").checked = haveLine && sel.ruler === "line";
533+
$("ctl-ruler").title = haveLine ? "" :
534+
"No along-line distances for this line, so the comparable axis is unavailable.";
535+
536+
// written now, not before the fetch: `mode` may have been downgraded to
537+
// "value" above, and a URL promising an anomaly that is not on screen is a
538+
// link that does not reproduce what the sender saw
539+
writeURL({ ...sel, mode, ruler: $("sel-ruler").checked ? "line" : "occupied" });
540+
391541
const maxDepth = Number($("sel-depth").value);
392-
drawSection(shard, sel.var, maxDepth);
542+
drawSection(shard, sel.var, maxDepth, mode,
543+
$("sel-ruler").checked ? "line" : "occupied");
393544
drawMap(shard);
545+
546+
/* Plotly measures its container at draw time, and on the FIRST render that is
547+
* before the map, the notes and the badge have settled the grid — so the plot
548+
* was laid out a little wider than its panel and the colorbar landed on top of
549+
* the map until the user happened to resize the window. Re-measure once the
550+
* browser has finished this frame. */
551+
requestAnimationFrame(() => {
552+
for (const id of ["plot", "map"]) {
553+
const el = $(id);
554+
if (el && el.offsetWidth) Plotly.Plots.resize(el);
555+
}
556+
});
394557
}
395558

396559
function currentSel() {
397560
return {
398561
line: $("sel-line").value,
399562
cruise: $("sel-cruise").value,
400563
var: $("sel-var").value,
564+
mode: $("sel-mode").value,
565+
ruler: $("sel-ruler").checked ? "line" : "occupied",
401566
};
402567
}
403568

@@ -419,15 +584,34 @@ async function init() {
419584
line: url.line || state.index.default.line,
420585
cruise: url.cruise || state.index.default.cruise_key,
421586
var: url.var || state.index.default.var,
587+
mode: url.mode || "value",
588+
ruler: url.ruler,
422589
};
423590

424-
for (const id of ["sel-line", "sel-cruise", "sel-var"]) {
591+
const b = state.index.baseline;
592+
if (b) {
593+
$("baseline-text").innerHTML =
594+
`Anomalies on this page are differences from a <strong>${b.yr_min}–` +
595+
`${b.yr_max}</strong> baseline, built from ${b.n_cruises} cruises and ` +
596+
`${b.n_cells.toLocaleString()} station × depth × month cells, each ` +
597+
`requiring at least ${b.min_n} observations. That window is long enough ` +
598+
`to average over the 1997–99 El Niño and La Niña, and it ends before the ` +
599+
`2014–16 marine heatwave, so the heatwave and everything after it read ` +
600+
`as departures rather than being folded into the normal. It is not a ` +
601+
`30-year WMO normal: the 1 m-binned CTD record does not reach back far ` +
602+
`enough for one.`;
603+
}
604+
605+
for (const id of ["sel-line", "sel-cruise", "sel-var", "sel-mode"]) {
425606
$(id).addEventListener("change", () => render(currentSel()));
426607
}
608+
$("sel-ruler").addEventListener("change", () => render(currentSel()));
427609
$("sel-depth").addEventListener("input", (e) => {
428610
$("out-depth").textContent = e.target.value;
429-
if (state.shard) drawSection(state.shard, $("sel-var").value,
430-
Number(e.target.value));
611+
if (state.shard) {
612+
const c = currentSel();
613+
drawSection(state.shard, c.var, Number(e.target.value), c.mode, c.ruler);
614+
}
431615
});
432616

433617
await render(sel);

0 commit comments

Comments
 (0)