Live at https://calcofi.io/explore/.
One web app for looking at the integrated CalCOFI database through six lenses — stations,
hexagons, contours, cruises, regions and sections — for one organism (a taxon, from the net tows
and censuses) or one ocean variable (from the bottle, CTD, carbonate and weather series) at a time.
Everything runs in your browser: the SQL executes in DuckDB-WASM against the frozen release, so there is
no server between you and the data, and the same bytes calcofi4r / calcofi4py read.
The design history lives in two planning documents in the workflows repo
(.claude/plans/2026-08-28 CalCOFI Explorer … and 2026-08-29 CalCOFI Explorer UI …); this README
says what the app does and how to work on it without needing them.
- The map is the page, and everything else floats over it. The title sentence at the top says what the map shows in plain words — "Pacific sardine (pilchard) larva, the mean per 10 m² of sea surface at each station, all years · all seasons." — with the colour scale and the observation count beside it. Its ▾ opens the sentence as the controls: every part becomes a chip whose popover is the same picker the Controls panel holds, so the two cannot disagree; opening one folds the other. The Controls panel (top-left) has three tabs — Select (Biology or Environment, the organism or variable, View as the six lenses (the active one full size, the others as icon slivers), and More options for the summary statistic, how counts are standardized, whether zeros are counted, the dataset pills and the sources — a disclosure that remembers its state), Refine (years, season, depth band, datasets) and Share. Time floats along the bottom and Depth starts folded to a pill on the right edge; both are brushes — drag on them to filter the map to a span of years or a depth band. Every panel moves (drag its bar; double-click sends it back to its place), collapses to a labelled pill on the nearest edge, expands to fill the map (Esc restores) and resizes from its edges or corner. Under 900 px (a phone) the Controls panel is a bottom sheet and the strips are pills on the map's edge.
- Depth signals; it never opens itself. The pill is quiet when a pick has no depth axis (a
depth-integrated net tow), lights up the moment a pick sampled at depth arrives — the band in its label, a
sparkline of the mean profile, one pulse — and carries the band with a reset once you brush one. The
panel opens only when you click it, or when a shared link names a band (
depth=). - Picking an organism or variable. The picker opens as a tree folded by category — each category one row with its icon, item count, year span and a log-scale bar of how much data it holds — with the current pick shown under its own category and a "… N more" row for the rest. Click a category to open it in full; type to search within the tree; the Search tab is the flat A–Z list with sort and grouping for when you know the name.
- Nothing is averaged across things that should not be averaged. Biology views are one taxon × one
life stage × one denominator: per 10 m² of sea surface (count × standard haul factor ÷ proportion
sorted — the CalCOFI larvae-per-10 m² standard, for oblique and vertical tows), per 1000 m³ strained
(count ÷ proportion sorted ÷ volume strained × 1000 — for manta tows and any tow with a flowmeter) or
the raw count (not comparable across gear or datasets). The denominator line under the picker says
which is in force, for which datasets, and how many observations it excludes; open it for the formulas.
One pill per dataset × stage; a ⚠ pill is a raw count with no effort in the release. The default stage
and denominator follow the same rule as
calcofi4r::cc_default_stage()/cc_default_denominator(). - A contour is computed in the browser, and it shows its own error. The Contours lens interpolates a point
summary under the same filters as every other lens — by default every site (each cast, tow or site at its own
position to 0.01°, repeat occupations pooled;
sql/contour_cast.sql), or the station grid (one point pergrid_keycell;grain=station) — into a surface in a Web Worker (src/contour.worker.ts, plain typed arrays, no library): IDW (power 1.3, what the superseded Contour Explorer drew withterra::interpIDW), ordinary kriging (an exponential variogram fitted by weighted least squares) or a thin-plate spline (mgcv'ss(lon, lat)basis, the smoothing picked by GCV — the GAMcalcofi4r::pts_to_contours_gam()fits; station grid only). At the site grain kriging and IDW take the 24 nearest points within 180 km per cell (one small solve each, so the error comes free; ≈ 1 s for 12,000 sites); at the station grid every point is in one system (≈ 0.3 s, the error surface ≈ 3 s more). The surface menu draws the statistic itself, its error (the kriging standard deviation, or the spline's standard error — IDW has none, it is a weighted average, not a model), the observation density, the first / last year sampled, the 5th / 95th percentiles or their spread — each interpolated the same way from the station table (sql/station.sqlnow carriesp05/p95). The isolines take a darkened version of the ramp colour at their level, so they stand out of the surface without fighting it. Contour labels (on by default;labels=off, a checkbox in the options and a row in the Layers card) write the level along each isoline in white on a dark halo, rotated to follow it, one per ~260 px of line and never within 70 px of another, so their density follows the zoom. The inputs (the sites or stations the surface was fitted to) are a layer of their own — show the inputs in the options and a row under Data in the Layers card,inputs=on|off; on by default for the 218 station dots, off for thousands of sites. The fit line under the method reports the leave-one-out RMSE, the variogram (nugget · sill · range) or the effective degrees of freedom, and the time it took. A cell farther than 60 km from any point is blank, the edge fades over the last 15 km, and land is clipped (Natural Earth 10 m land, bundled aspublic/land.geojson, rasterised onto the grid) — the map never extrapolates, and never over land.interp=,grain=andsurface=are in the URL; the map's CSV is the point table the surface interpolates. One algorithm, three runtimes:calcofi4r::cc_interpolate()andcalcofi4py.interpolate()are the same code by hand, pinned byscripts/parity/contour_fixture.json— written by the worker itself (node scripts/parity/contour_fixture.mjs) and copied byte-for-byte into both packages' test fixtures — so a surface made in R or Python matches the map cell for cell. - The data layer has its own rows in the Layers card: on/off (
data=offleaves the sea floor and boundaries alone), opacity (datao=), the colour ramp (ramp=thermal,_rreverses): cmocean's 22 ramps (Thyng et al. 2016), viridis and oce's GEBCO ramp, insrc/ramps.ts; with noramp=the variable picks its cmocean convention (thermal for temperature, haline for salinity, algae for chlorophyll, dense for density, tempo for nutrients, ice for oxygen; viridis for biology) — and its place among the boundary layers: the Data row under On the map drags like any boundary, andlayers=noaa_onms_sanctuaries,datadraws the sanctuaries over the data. deck.gl runs interleaved inside MapLibre's own layer stack for this (D36), which also puts the data under the basemap's labels. - One lens at a time in the Controls panel. View as shows the active lens full size with a line under it and the other five as icon slivers; click any of them to open the six as rows with their help text, pick one, and it closes. Switching between Stations and Hexagons still travels the dots (the one morph that shows pooling); every other switch is a short cross-fade.
- A section's y-axis is the realm. In Environment the Sections lens draws a depth section — the stations along one CalCOFI line across the x-axis, depth down the y-axis, for one cruise (optionally as the anomaly against the 1993–2013 climatology), with the 3-D curtain beside it. In Biology the tows are depth-integrated, so there is no depth axis: the lens draws a station-by-year section — the same stations across the x-axis, years down the y-axis, across all cruises. The title sentence says which it is ("as a depth section along line 90 on cruise …" · "as a station-by-year section along line 90 across all cruises"), and so do the card title and the figure title.
- A section is laid out like the map, and carries both rulers. Sections draws offshore on the left,
the coast on the right — a CalCOFI line runs west-south-west off the coast — and labels the x-axis
station number above, distance offshore below. The two are one ruler:
+proj=calcofiis equidistant along a line at 7.386 km = 3.99 nmi (4 nautical miles) per station unit, measured constant to 0.02 % over the 665 km of line 90, so the axis is linear in both at once. It is linear rather than categorical for that reason: spacing stations evenly by index drew 80 → 90 (74 km) as wide as 30 → 35 (37 km). ctd-transects draws the same section the same way. - A section's anomaly is a departure from one fixed baseline. In the Sections lens, anomaly vs
1993–2013 monthly climatology subtracts the release's own
climatologytable (calcofi4db::build_climatology()): the mean for that station, the cast's own calendar month and its 10 m depth bin over 1993–2013, kept only where at least 3 cruises contribute — the same table ctd-transects subtracts, so the two products cannot disagree. The year, season and depth filters do not touch the baseline (that is what makes anomalies comparable across cruises); the datasets in view are pooled weighted by their observation counts. Red is above normal, blue below, the scale symmetric about zero; a cell with no baseline is blank, never zero. - The URL is the whole view. Lens, organism or variable, stage, denominator, years, season, depth,
dataset filter, region, line, cruise, summary statistic, the contour method and surface (
interp=,surface=), the data layer (data=,datao=,ramp=), theme, the sea floor (bathy=,bathyo=), which panels are folded or maximized (hide=·show=·max=), the years strip's mode (strip=), the map extent (map=lon,lat,zoom) and, in the 3-D section, the camera (cam=lon,lat,zoom,pitch,bearing, written once you move it) are all in it — so Share → Copy link, a bookmark and a feedback report all reopen at exactly the same place.?tour=offsuppresses the welcome card and tour (and opens no modal at all);?modal=sourcesopens Data Sources & Attribution, the one modal the URL carries, so an attribution link is shareable;?theme=dark|lightsets the theme. The sea floor under every lens is GEBCO 2025 (shaded relief + depth colour + isobaths), drawn from terrain-RGB PMTiles atstorage.calcofi.io/calcofi-db/bathymetry/(override withVITE_BATHY_URL); the layers button on the map toggles its parts and opacity,?bathy=offreproduces the plain basemap,bathy=relief,contourskeeps a subset,bathyo=0.5sets its opacity. The style is COMPOSED (CARTO ⊕ sea floor, one object,setStyle(diff)), so a theme flip can never drop the layers — see the 2026-08-31 map-layers plan in CalCOFI/workflows for how the tiles are built. The coast is a land mask (plan 2026-09-09, D47): CARTO's water fill sinks to the bottom of the style, the sea floor, the boundaries under the Data row and the data draw over it, then OpenStreetMap's land polygons (_spatial/osm_land.pmtiles, the coastline CARTO's own water derives from) in the background colour, a copy of CARTO's water without the ocean (lakes), and every CARTO land layer where it was — so nothing spills onto land and roads, county / state / country lines and labels stay on top.land=offrestores the old stack (a diagnostic only: the card has no switch for it);basemap=nolabelshides CARTO's own text (place names, road names, points of interest, water names) for a data-centric view — the isobath labels and the registry's label layers stay. CARTO's text is always toned down (one mid grey atbasemapo=opacity, 0.6 dark / 0.7 light by default, Regular weight, a thinner halo, 1 px smaller: its near-white Montserrat Medium out-shouted the data), and gains the island names CARTO's tiles carry but its styles never drew (place_islandfrom z8,place_island_minorfrom z10); the names layer draws at 12/10/9 px and its row's opacity (layers=gebco_gazetteer::0.45). Reference layers ride the boundary registry withrole = reference:gebco_gazetteer(undersea feature names from the IHO-IOC GEBCO Gazetteer, alabellayer — symbols by rank,layers=gebco_gazetteer[:colour]) andesri_ocean_reference(Esri's World Ocean Reference as arasterrow, for comparison); the mask itself is never an On the map row, and a label row's opacity slider sits under the row. A link withoutlayers=shows the registry's default-visible reference layers (the names, above the data, not in the legend);layers=offshows none; any explicit list is exactly that list. - Share (the Controls panel's third tab): Download data (zip) hands over the bytes shown, the exact
SQL against the release's content-addressed object URLs, per-dataset citations and
reproduce.R/reproduce.pythat run the same query; Copy code gives that SQL, or R or Python; Cite this data copies the citations; Copy link copies the view, and Copy image / Download PNG the whole view as a figure with the selection, release and URL stamped in a footer; Register a product, Send feedback and SQL & timing live there too. Every panel's bar has its own ⬇ (PNG · SVG · CSV), and the map's ⬇ in its top-right row exports the map with its title (PNG) or the table the lens draws (CSV) — the map is WebGL, so it has no SVG. - Nothing lingers after a lens change. Switching from Stations to Hexagons travels the station dots to their hexagon centres and fades them out as they arrive (the pooling in one move); every other lens change is a short cross-fade in place, so a lens never carries dots it does not explain — Sections keeps only its line's stations, Contours only the optional inputs, Cruises only its own sampled dots and track.
- Feedback (the speech bubble in the header, immediately left of the theme toggle — the shape calcofi.io and the docs book wear) captures the view, lets you mark it up (arrow, circle, rectangle, pen, text; yellow, blue or hot pink) and sends it with your note, the view URL, release, viewport and theme to the team — by mail with the screenshot inline, to a Sheet, and as a public issue in this repo without your email. Without a configured endpoint the dialog offers a prefilled GitHub issue instead.
- The header is the release picker (the word release links to the schema and release notes, the bold value is a list of the releases), Help ▾ — the tour, the Explorer guide (calcofi.io/docs/explore.html), Start here (the welcome), About, Data Sources & Attribution, Register a product and Keyboard — then the feedback bubble and the theme toggle, in that order on every CalCOFI product. The map's own buttons — zoom, layers, its ⬇ — sit in one row at the map's top right.
- Keyboard: in the 3-D section, once the scene has been clicked, arrows pan,
shift+ arrows rotate and tilt,+/−zoom (the top-right row has the same moves as buttons, and a compass that resets the framing) ·?replays the tour ·Esccloses a dialog, the welcome or a chip's popover, or restores an expanded panel ·↑ ↓ Enterin the lists ·A–Zstrip to jump in the flat list.
These are sixteen datasets that people collected, curated and depend on being cited for, and a view usually pools several of them — the statistic is averaged across the datasets that share the chosen life stage and denominator (never across denominators or life stages). Pooling is exactly why each of those datasets has to be named, so the app names them everywhere a number can leave it:
- The welcome states the norm and opens the doors. A card floating over the live map — no dim — asks
What would you like to explore?: two doors (An organism / An ocean variable open the Controls panel
on that picker), four real questions (each a view the app already understands, as its URL query — see
QUESTIONSinsrc/help.tsx), Start exploring and Take the tour. The citation norm is one sentence under them — "These data are free to use and are cited when used: every view names its datasets, and Share → Cite writes the citation for you" — accepted by continuing: every way in storesexplore_cite_ackbesideexplore_welcome. It is not a gate — Esc and × enter too.?tour=onshows the card again,?tour=offnever shows it (the brand contract's deterministic screenshot); Help → Start here brings it back. - The Sources line sits in the Controls panel's More options, under the dataset pills, and the panel's
footer counts the sources in view: one chip per dataset the view pools —
provider · dataset · licence— that opens that dataset's citation with a copy button. - Every figure footer carries three lines: the selection,
CalCOFI Explorer · release · the view URL, andData: <dataset_key, …> · cite: calcofi.io/explore → Cite this data. PNG (1× and 2×), SVG and the whole-view capture all share it (src/export.tsstampLines()). - Every panel CSV carries a
dataset_keycolumn. A row that already has one keeps it; a pooled row gains one holding the datasets it pools,;-separated. A column, never a leading#comment line — a comment breaksread.csv,pandas.read_csvand DuckDB'sread_csv_autoalike. - Cite this data (EXPORT group, and the ⋯ menu) copies the release citation plus every dataset in view
as text or BibTeX (
@miscbuilt from the release's own fields — the app never fetches to cite). - Data Sources & Attribution (
?modal=sources, the ❞ button in the header, the ⋯ menu on a phone, and from About): one row per dataset — never per taxon or per variable, so the phytoplankton dataset is one row and not 393 — with its citation, licence, DOI, PIs, acknowledgement, contact and links, the rows in view first, and the integrated database's own citation plus one CalCOFI front door in the footer. - Register a product ("I used CalCOFI data in …") is the feedback dialog's second kind: title, link or
DOI, the datasets in view prefilled, through the same Apps Script → Sheet → mail → public issue pipeline,
labelled
derived-product. The Apps Script must readlabelfrom the payload for the issue to be filed under it; the zero-backend fallback link already carrieslabels=derived-product.
All of it reads the release's own dataset rows and catalog.json through src/cite.ts — one set of
builders, so the bundle's CITATION.md, the modal and the clipboard cannot drift. Nothing is fetched.
What degrades, and how. The dataset columns doi, license_url, acknowledgement, contact and
source_accessed, the provider table (for provider_short) and catalog.citation all arrive with the
attribution contract (calcofi4db 3.30.0, workflows WS-A0/A1) in the next release; until then the app runs on
the dev catalog, which has none of them. Each is optional:
| absent | what the app does instead |
|---|---|
dataset.doi |
no DOI link; BibTeX omits the doi field |
dataset.license_url |
the licence chip is plain text, unless the SPDX id is one cite.ts knows a URL for |
dataset.acknowledgement |
the line is omitted (it is not invented from citation_others) |
dataset.contact |
no contact link; the modal's front door is the only route |
dataset.source_accessed |
no "Accessed:" line in the copied citation or the BibTeX note |
dataset.citation_main |
"no citation in this release — the dataset's provider question is open", in italics; the copy button disappears |
dataset.license |
no licence chip at all (an absent licence is a fact, not a label) |
a provider table |
provider_short falls back to cite.ts's PROVIDER_SHORT map, then to the slug |
catalog.citation |
the same wording is built locally from version + release_date (+ catalog.doi when minted), so it matches calcofi4db::release_citation() |
dataset.parquet not yet loaded |
the Sources line still names the pooled datasets by key |
npm installThe app needs a release catalog to read. The deployed site reads the real release
(https://storage.googleapis.com/calcofi-db/ + ducklake/releases, the defaults) since v2026.09.04,
the first release to ship the browser-shaped objects. For development you can still use the
catalog-shaped local copy of the v2026.08.25 objects (built once by
~/_big/calcofi/explore-spike/data2/build_dev_catalog.R; the same content is on GCS under
explore-dev, which the site read before the flip):
ln -s ~/_big/calcofi/explore-spike/data2 public/data2 # once
VITE_DATA_URL=data2/ VITE_RELEASE_PREFIX=explore-dev/releases npm run devthen open http://localhost:5178/. This is Vite's dev server with hot reload: every saved edit shows in the browser without a rebuild, so leave it running while you work. Ctrl-C stops it.
For a production-shaped check — the real bundle, same data — build it and serve the build:
VITE_DATA_URL=data2/ VITE_RELEASE_PREFIX=explore-dev/releases npm run build
npx vite preview --host --port 5179 # http://localhost:5179/ (--host also exposes it to a phone on the same Wi-Fi)VITE_DATA_URL is the bucket (or folder) root and VITE_RELEASE_PREFIX the releases prefix under it;
unset, they default to the real release on https://storage.googleapis.com/calcofi-db/ +
ducklake/releases, which works too once a release carries the browser-shaped objects.
node scripts/verify.mjs http://localhost:5178/ shots/dev --only=<regex> # drive the app through its states
node scripts/verify.mjs http://localhost:5179/ shots/prod --timing # + cold/warm lens timings
node scripts/bundle_check.mjs http://localhost:5178/ shots/bundle # download two bundles and list them
node scripts/card_shots.mjs ~/Github/CalCOFI/CalCOFI.github.io/images # the two themed card screenshots
node scripts/tour_shots.mjs http://localhost:5179/ shots/tour # the docs/deck pictures: a lens each + the UI close-upstour_shots.mjs writes the pictures the docs book's Explorer guide
and the update deck use — one per lens (both section shapes, and the 3-D curtain), the two product-card
shots at calcofi.io's own 1200 × 750, and the UI close-ups (the sentence's chips, the three Controls tabs,
the Layers card, a pane's own bar, the Help menu, the Time strip, the Depth profile). Each is named
entirely by URL parameters, so a re-shoot after a UI change is one command; shots/tour/README.md lists
every file with its URL.
verify.mjs opens the installed Chrome (headed, fresh profile) at 1280 × 800 and 390 × 844, walks every
named state, screenshots each, asserts no horizontal overflow and every control in view, and writes
results.json. It is the only reliable way to see the app under automation; --only picks states by
regex. npm run build also type-checks (tsc --noEmit).
.github/workflows/pages.yml builds with VITE_DATA_URL + VITE_RELEASE_PREFIX + VITE_BASE=/explore/
(and VITE_FEEDBACK_URL, see below) and publishes dist/ to GitHub Pages on every push to main. The
page reads {prefix}/latest.txt → {prefix}/{version}/catalog.json (plus the coverage.json,
coverage_stations.json, grid.geojson and spatial.geojson sidecars) and every object by its catalog
path — never a hand-built releases/{v}/parquet/ path.
The dialog posts to a Google Apps Script that calcofi4r::cc_feedback_script() generates. Setup: a
Sheet with a feedback tab (header = calcofi4r::cc_feedback_header()) and a recipients tab (one
email per row — edit a cell to add someone, no redeploy); paste cat(cc_feedback_script()) as the
Sheet's script and deploy it as a web app ("execute as me", "anyone"); add GITHUB_TOKEN (contents +
issues on this repo) as a script property for the public issue; put the /exec URL in the repository
variable VITE_FEEDBACK_URL. Re-paste the script after a calcofi4r change (1.14.1 added the inline
screenshot in the mail). Usage analytics go through the fleet's GA4 snippet in index.html
(calcofi4r::cc_ga_html("public/ga.html", "explore")); an automated browser is never counted.
- Stack: Vite + React 18 + TypeScript · MapLibre GL (keyless CARTO basemap, swapped on the brand's
cc:themeevent) · deck.glMapboxOverlay(ScatterplotLayercarries the station dots between lenses,H3HexagonLayer,GeoJsonLayer,TripsLayer,BitmapLayerfor the contoured surface) · DuckDB-WASM self-hosted in a Web Worker, no extensions, objects fetched whole and registered as buffers · Plotly for the depth strip, year strip, section and cruise series ·h3-js· the brand fromcalcofi.io/brand/<VITE_BRAND>/— v2, the SIO look (light default, Source Sans 3, the lockup at 28 px,data-cc-scale="app"), since the flip on 2026-09-04;VITE_BRAND=v1still builds the superseded dark-default look.vite.config.tsinjectsbrand/<v>.head.html;src/brand.tscarries the same choice into the header and the capture (v2 embeds the woff2 files so the feedback PNG is set in Source Sans 3).verify.mjs'sv2_*states check the default build and skip on a v1 one. - Data: the release's browser-shaped objects —
obs_bio, oneobs_envpartition per variable,sample_root,sample_spatial,taxon,dataset,measurement_type,cruise— cut bycalcofi4db::build_*at release time, resolved through the catalog bysrc/release.ts(a port ofcalcofi4r::cc_release_sources()). The category tree comes fromcoverage.json(taxa[]andvariables[].category, calcofi4db ≥ 3.25.0;src/categories.tskeeps a keyword fallback for an older release); the categories themselves areworkflows/metadata/category.csvand the iconscalcofi.io/brand/v2/icons/(scripts/build_icons.mjsregenerates that sprite fromsrc/icon-paths.ts, and the app renders the same paths inline). - Code map:
sql/*.sqlare the lens queries the browser runs ({{named}}params, the shared filter in_filters.sql;density.sqlis the denominator fixture shared with calcofi4r / calcofi4py) ·src/engine.tsrenders and times them ·src/state.tsis the URL selection model (fromUrl/toUrl), the stage/denominator defaults and the denominator formulas ·src/App.tsxthe shell ·src/map.tsxthe layers and the lens-to-lens morph ·src/contour.ts+src/contour.worker.tsthe Contours lens's interpolators, isolines and bitmap ·src/ramps.tsthe colour ramps ·src/lenspicker.tsxthe lens picker ·src/charts.tsxthe Plotly panels ·src/picker.tsxthe organism / variable / cruise picker (tree + flat list) ·src/panels.tsxthe rails, floating cards and phone sheet ·src/export.tsper-panel PNG/SVG/CSV and the footer stamp ·src/capture.tsthe whole-view figure (onehtml-to-imagecomposite; MapLibre runs withpreserveDrawingBufferso its canvas can be read) ·src/annotate.tsx+src/feedback.tsxthe feedback dialog (and Register a product, its second kind) ·src/bundle.tsthe download ·src/cite.tsthe citation builders every attribution surface shares ·src/sources.tsxthe Sources line and the Data Sources & Attribution modal ·src/tour.tsthe guided tour overdata-touranchors ·src/help.tsxthe welcome (with the agreement) and About dialogs. - Layout rule: nothing re-lays out on a selection change — only a fold, maximize, drag, lens change
or breakpoint moves panels. Card positions and the rail width live in
localStorage; folds and maximize live in the URL. - Escape hatches:
?native=1swaps the pickers for plain<select>s; SQL & timing (in Export) opens a card with every timing mark and the last SQL (window.__marksis the same list).