Club Gas Prices
  • Map
  • Compare
  • Trends
  • Changes
  • Station
  • About

Station

LITRES_PER_GALLON = 3.785411784

COUNTRY_ORDER = ["US", "CA", "MX", "GB", "AU", "JP", "TW"]

// `X = ({...})` silently breaks Quarto's OJS cell splitter; use a block.
COUNTRY_NAMES = {
  return {
    US: "United States",
    CA: "Canada",
    MX: "Mexico",
    GB: "United Kingdom",
    AU: "Australia",
    JP: "Japan",
    TW: "Taiwan"
  };
}

// Okabe-Ito for color-vision accessibility; brown replaces yellow for TW
// (yellow is unreadable on a light background).
COUNTRY_COLORS = {
  return {
    US: "#0072B2",
    CA: "#E69F00",
    MX: "#009E73",
    GB: "#CC79A7",
    AU: "#56B4E9",
    JP: "#D55E00",
    TW: "#8C6D31"
  };
}

GRADE_LABELS = {
  return {regular: "Regular", premium: "Premium", diesel: "Diesel"};
}

meta = FileAttachment("data/meta.json").json()

latestAll = FileAttachment("data/latest.json").json()

stationsAll = FileAttachment("data/stations.json").json()

// All brands in the data; the Chain control appears only when there are two.
allBrands = [...new Set(latestAll.map((s) => s.brand).filter(Boolean))].sort(chainOrder)

// Filtered views driven by the Chain control.
filteredLatest = chain === "all" ? latestAll : latestAll.filter((s) => s.brand === chain)
filteredStations = chain === "all" ? stationsAll : stationsAll.filter((s) => s.brand === chain)

chainClause = chain === "all" ? "" : ` AND brand = ${sqlText(chain)}`
theme = Generators.observe((notify) => {
  const body = document.body;
  const read = () => (body.classList.contains("quarto-dark") ? "dark" : "light");
  let current = read();
  notify(current);
  // Only emit on a real change; Leaflet transiently rewrites body's classes
  // during clicks and would trigger false marker rebuilds.
  const observer = new MutationObserver(() => {
    const next = read();
    if (next === current) return;
    current = next;
    notify(next);
  });
  observer.observe(body, {attributes: true, attributeFilter: ["class"]});
  return () => observer.disconnect();
})

palette = {
  theme; // re-read the tokens whenever the theme flips
  const style = getComputedStyle(document.documentElement);
  const read = (name, fallback) => {
    const value = style.getPropertyValue(name);
    return value && value.trim() ? value.trim() : fallback;
  };
  return {
    ramp: [
      read("--cgp-ramp-1", "#2a6f97"),
      read("--cgp-ramp-2", "#61a5c2"),
      read("--cgp-ramp-3", "#e9c46a"),
      read("--cgp-ramp-4", "#e76f51"),
      read("--cgp-ramp-5", "#b3261e")
    ],
    // Falls to rises about a neutral, for which way a price moved.
    diverging: [
      read("--cgp-fall-2", "#2a6f97"),
      read("--cgp-fall-1", "#61a5c2"),
      read("--cgp-flat", "#dfe3e6"),
      read("--cgp-rise-1", "#e76f51"),
      read("--cgp-rise-2", "#b3261e")
    ],
    missing: read("--cgp-missing", "#9aa0a6"),
    stroke: read("--cgp-marker-stroke", "#ffffff"),
    muted: read("--cgp-muted", "#5b6770"),
    grid: read("--cgp-grid", "rgba(27,31,35,0.12)"),
    text: read("--bs-body-color", "#1b1f23"),
    // The tip's own background, so it stays readable in either theme.
    surface: read("--cgp-control-bg", "#ffffff")
  };
}

// Pages that don't query skip DuckDB-WASM entirely.
//
// Parquet is loaded as bytes, not a URL: GitHub Pages gzips files and ranges
// over the compressed copy, which breaks DuckDB-WASM's range-based reader.
// A blob: URL from a whole-file fetch sidesteps this.
//
// FileAttachment paths must be literals (Quarto resolves them at render).
// history.parquet grows without bound, so only pages that query it download it.
db = {
  if (!pageWantsDb) return null;
  const buffered = async (file, name) => {
    const bytes = await file.arrayBuffer();
    const url = URL.createObjectURL(new Blob([bytes], {type: "application/octet-stream"}));
    return {
      file: {
        // parquet_scan is pointed at this name, so it keeps the extension the
        // stdlib switches on.
        name,
        mimeType: "application/octet-stream",
        url: () => url,
        arrayBuffer: async () => bytes
      }
    };
  };
  const [summary_daily, history] = await Promise.all([
    buffered(FileAttachment("data/summary_daily.parquet"), "summary_daily.parquet"),
    pageWantsHistory
      ? buffered(FileAttachment("data/history.parquet"), "history.parquet")
      : null
  ]);
  return DuckDBClient.of({
    summary_daily,
    ...(history ? {history} : {}),
    // Reuse the already-fetched array; registering the FileAttachment would
    // download stations.json a second time.
    stations: stationsAll.map((s) => ({
      station_key: s.station_key,
      country: s.country,
      region: s.region
    }))
  });
}

nowTick = Generators.observe((notify) => {
  notify(new Date());
  const timer = setInterval(() => notify(new Date()), 60000);
  return () => clearInterval(timer);
})
CONTROL_PARAMS = {
  // Radios listed here round-trip through the query string.
  return {
    grade: "grade",
    currency: "cur",
    volume: "vol",
    chain: "chain",
    colormode: "color",
    trendmode: "view",
    changemode: "show"
  };
}

function controlFromUrl(name, fallback, allowed) {
  const key = CONTROL_PARAMS[name];
  const value = key && new URLSearchParams(location.search).get(key);
  return value && allowed.includes(value) ? value : fallback;
}

// Append the current control values to a same-site link.
function withControls(href) {
  const url = new URL(href, location.href);
  const current = new URLSearchParams(location.search);
  for (const key of Object.values(CONTROL_PARAMS)) {
    const value = current.get(key);
    if (value != null && !url.searchParams.has(key)) url.searchParams.set(key, value);
  }
  return url.pathname.split("/").pop() + url.search + url.hash;
}

// Plot ticks a short series at sub-day intervals; this forces ticks on whole
// days only, since every series here holds one value per UTC day.
function dayTicks(rows, room = chartWidth) {
  const days = new Set(
    (rows || []).map((row) => {
      // Callers pass whatever they already have: a row, a Date, or the
      // timestamps a chart already reduced its days to.
      if (typeof row === "number") return row;
      const d = row instanceof Date ? row : row && row.day;
      return d instanceof Date ? d.getTime() : d;
    })
  );
  const times = [...days].filter(Number.isFinite).sort((a, b) => a - b);
  const most = Math.max(2, Math.min(14, Math.floor(room / 80)));
  if (!times.length) return most;
  const span = Math.round((times[times.length - 1] - times[0]) / 864e5);
  if (span >= most) return most;
  return Array.from({length: span + 1}, (_, i) => new Date(times[0] + i * 864e5));
}

// OJS `width` reads <main>, which a custom layout lacks — it falls back to the
// full window, and charts shrink to fit. This measures the actual content box.
contentWidth = Generators.observe((notify) => {
  const el = document.getElementById("quarto-content") || document.body;
  const read = () => {
    const style = getComputedStyle(el);
    return Math.floor(
      el.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
    );
  };
  let current = read();
  notify(current);
  const observer = new ResizeObserver(() => {
    const next = read();
    if (next === current) return;
    current = next;
    notify(next);
  });
  observer.observe(el);
  return () => observer.disconnect();
})

chartWidth = Math.max(320, Math.min(contentWidth, 1600))

function radioControl(name, entries, value, label) {
  const keys = entries.map((entry) => entry[1]);
  const start = CONTROL_PARAMS[name] ? controlFromUrl(name, value, keys) : value;
  const input = Inputs.radio(new Map(entries), {label, value: start});
  input.setAttribute("data-control", name);
  input.classList.add("cgp-field");
  // Stable hooks for the smoke test.
  input.querySelectorAll("input[type=radio]").forEach((el, i) => {
    el.setAttribute("data-value", keys[i]);
  });
  if (CONTROL_PARAMS[name]) {
    input.addEventListener("input", () => {
      const url = new URL(location.href);
      url.searchParams.set(CONTROL_PARAMS[name], input.value);
      history.replaceState({}, "", url);
    });
  }
  return input;
}

// Like radioControl, but for a select.
function selectControl(name, values, options) {
  const input = Inputs.select(values, options);
  input.setAttribute("data-control", name);
  input.querySelectorAll("option").forEach((el, i) => {
    el.setAttribute("data-value", values[i]);
  });
  return input;
}

// Carry controls forward on in-site links so navigation preserves state.
controlLinkCarrier = {
  const onClick = (event) => {
    const link = event.target.closest && event.target.closest("a[href]");
    if (!link || event.defaultPrevented || event.metaKey || event.ctrlKey) return;
    const url = new URL(link.getAttribute("href"), location.href);
    if (url.origin !== location.origin || !url.pathname.endsWith(".html")) return;
    link.setAttribute("href", withControls(link.getAttribute("href")));
  };
  document.addEventListener("click", onClick, true);
  invalidation.then(() => document.removeEventListener("click", onClick, true));
  return true;
}

// Restyle Observable Inputs as stacked groups.
function asField(input, {inline = false} = {}) {
  input.classList.add("cgp-field");
  if (inline) input.classList.add("cgp-field--inline");
  return input;
}

function escapeHtml(value) {
  return String(value == null ? "" : value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function perVolume(valuePerLitre, vol) {
  if (valuePerLitre == null || !isFinite(valuePerLitre)) return null;
  return vol === "gal" ? valuePerLitre * LITRES_PER_GALLON : valuePerLitre;
}

// Plot's styled tip, in place of the browser's slow native tooltip.
function tipStyle() {
  return {
    fill: palette.surface,
    stroke: palette.grid,
    fontSize: 12,
    lineHeight: 1.3
  };
}

// Leaflet.markercluster patches the global L. Falls back gracefully: if the
// CDN is unreachable the map draws without grouping.
clusterL = {
  const CDN = "https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/";
  if (!L.markerClusterGroup) {
    for (const file of ["MarkerCluster.css", "MarkerCluster.Default.css"]) {
      const link = document.createElement("link");
      link.rel = "stylesheet";
      link.href = CDN + file;
      document.head.appendChild(link);
    }
    // Shadow define/module/exports to force the UMD's browser branch (OJS
    // provides AMD `define`, which would intercept the module).
    try {
      const response = await fetch(CDN + "leaflet.markercluster.js");
      if (!response.ok) throw new Error(`markercluster: HTTP ${response.status}`);
      const source = await response.text();
      new Function("define", "module", "exports", source)(undefined, undefined, undefined);
      if (!L.markerClusterGroup) throw new Error("markercluster did not attach to L");
    } catch (error) {
      console.warn("[cgp]", error.message, "- the map will not group stations");
    }
  }
  return L;
}

function fmtNumber(value, digits) {
  return value == null || !isFinite(value) ? "—" : value.toFixed(digits);
}

function gradeEntry(station, key) {
  return station && station.grades ? station.grades[key] || null : null;
}

function stationPrice(station, key, cur, vol) {
  const entry = gradeEntry(station, key);
  if (!entry) return null;
  const perLitre = cur === "USD" ? entry.price_usd_per_litre : entry.price_local_per_litre;
  return perVolume(perLitre, vol);
}

function quantileSorted(sorted, p) {
  if (!sorted.length) return null;
  if (sorted.length === 1) return sorted[0];
  const h = (sorted.length - 1) * p;
  const lo = Math.floor(h);
  const hi = Math.ceil(h);
  return sorted[lo] + (h - lo) * (sorted[hi] - sorted[lo]);
}

function colorForFraction(t, pal) {
  if (t == null || !isFinite(t)) return pal.missing;
  const index = Math.min(pal.ramp.length - 1, Math.max(0, Math.floor(t * pal.ramp.length)));
  return pal.ramp[index];
}

function hoursAgo(iso, now) {
  if (!iso) return null;
  const then = new Date(iso);
  if (isNaN(then)) return null;
  return (now.getTime() - then.getTime()) / 3600000;
}

function captureIdToDate(captureId) {
  const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})Z$/.exec(captureId || "");
  if (!m) return null;
  return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]));
}

function toDay(value) {
  return new Date(value + "T00:00:00Z");
}

function sqlList(values) {
  return values.map((v) => "'" + String(v).replace(/'/g, "''") + "'").join(", ");
}

function sqlText(value) {
  return "'" + String(value).replace(/'/g, "''") + "'";
}

function haversineKm(aLat, aLon, bLat, bLon) {
  const R = 6371;
  const toRad = (d) => (d * Math.PI) / 180;
  const dLat = toRad(bLat - aLat);
  const dLon = toRad(bLon - aLon);
  const s =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(s));
}
volumeLabel = volume === "gal" ? "per US gallon" : "per litre"

volumeShort = volume === "gal" ? "/gal" : "/L"

priceDigits = volume === "gal" ? 3 : 4

currencyOf = {
  const map = new Map();
  for (const station of filteredLatest) {
    const entry =
      gradeEntry(station, "regular") ||
      gradeEntry(station, "premium") ||
      gradeEntry(station, "diesel");
    if (entry && !map.has(station.country)) map.set(station.country, entry.currency);
  }
  return map;
}

// Staleness window from meta.json; 12h fallback.
staleAfterHours = {
  const value = Number(meta.stale_after_hours);
  return Number.isFinite(value) && value > 0 ? value : 12;
}

// Per-feed freshness where meta.json lists feeds (naming the chain where a
// country has two); falls back to the country block otherwise. Feeds of chains
// with no stations in the data are omitted.
freshnessSources = {
  const feeds = Object.values(meta.feeds || {});
  const shown = new Set(filteredStations.map((station) => station.brand).filter(Boolean));
  const kept = feeds.filter((feed) => shown.has(feed.brand));
  const chains = chainsByCountry(kept);
  const sources = [];
  for (const code of COUNTRY_ORDER) {
    const mine = kept
      .filter((feed) => feed.country === code)
      .sort((a, b) => chainOrder(a.brand, b.brand));
    for (const feed of mine) {
      sources.push({
        label: seriesLabel(code, feed.brand, chains),
        captureId: feed.last_success_capture_id
      });
    }
    if (mine.length) continue;
    const entry = meta.countries ? meta.countries[code] : null;
    if (!entry) continue;
    sources.push({label: COUNTRY_NAMES[code], captureId: entry.last_success_capture_id});
  }
  return sources;
}
countryBounds = {
  const box = new Map();
  const all = [Infinity, Infinity, -Infinity, -Infinity];
  for (const station of latestAll) {
    if (station.lat == null || station.lon == null) continue;
    const b = box.get(station.country) || [Infinity, Infinity, -Infinity, -Infinity];
    b[0] = Math.min(b[0], station.lat);
    b[1] = Math.min(b[1], station.lon);
    b[2] = Math.max(b[2], station.lat);
    b[3] = Math.max(b[3], station.lon);
    box.set(station.country, b);
    all[0] = Math.min(all[0], station.lat);
    all[1] = Math.min(all[1], station.lon);
    all[2] = Math.max(all[2], station.lat);
    all[3] = Math.max(all[3], station.lon);
  }
  const out = new Map();
  for (const [code, b] of box) out.set(code, [[b[0], b[1]], [b[2], b[3]]]);
  if (isFinite(all[0])) out.set("__all__", [[all[0], all[1]], [all[2], all[3]]]);
  return out;
}

// A shared price axis needs one currency; local prices work only inside a
// single country (whose regions all share a currency).
function gradeRowHtml(label, entry, vol) {
  const digits = vol === "gal" ? 3 : 4;
  const unit = vol === "gal" ? "/gal" : "/L";
  const localPerVolume = perVolume(entry.price_local_per_litre, vol);
  const usdPerVolume = perVolume(entry.price_usd_per_litre, vol);
  const usdCell =
    usdPerVolume == null
      ? '<td class="cgp-popup-foot">no USD value</td>'
      : `<td>${usdPerVolume.toFixed(digits)} USD${unit}</td>`;
  return (
    `<tr><td>${escapeHtml(label)}</td>` +
    `<td>${escapeHtml(entry.price_raw)} ${escapeHtml(entry.price_unit)}</td>` +
    `<td>${localPerVolume == null ? "—" : localPerVolume.toFixed(digits)} ` +
    `${escapeHtml(entry.currency)}${unit}</td>` +
    usdCell +
    "</tr>"
  );
}

function popupHtml(station, vol) {
  const parts = [];
  parts.push(
    `<div class="cgp-popup-title">${escapeHtml(station.name)}` +
      (station.name_local
        ? ` <span class="cgp-local">${escapeHtml(station.name_local)}</span>`
        : "") +
      "</div>"
  );
  const place = [station.city, station.region].filter((v) => v).join(", ");
  parts.push(
    `<div class="cgp-popup-place"><span class="cgp-flag">${countryFlag(station.country)}</span> ` +
      `${escapeHtml(place)}${place ? " · " : ""}` +
      `${escapeHtml(COUNTRY_NAMES[station.country] || station.country)}</div>`
  );
  const rows = [];
  for (const key of ["regular", "premium", "diesel"]) {
    const entry = gradeEntry(station, key);
    if (entry) rows.push(gradeRowHtml(GRADE_LABELS[key], entry, vol));
  }
  for (const [raw, entry] of Object.entries(station.other || {})) {
    rows.push(gradeRowHtml(raw, entry, vol));
  }
  parts.push(`<table>${rows.join("")}</table>`);
  const any =
    gradeEntry(station, "regular") ||
    gradeEntry(station, "premium") ||
    gradeEntry(station, "diesel");
  if (any && any.currency !== "USD" && any.fx_usd_per_unit != null) {
    parts.push(
      `<div class="cgp-popup-foot">1 ${escapeHtml(any.currency)} = ` +
        `${any.fx_usd_per_unit.toPrecision(6)} USD as of ${escapeHtml(any.fx_rate_date)} ` +
        `(${escapeHtml(any.fx_source)})</div>`
    );
  }
  const age = hoursAgo(station.captured_at_utc, new Date());
  if (age != null) {
    parts.push(
      `<div class="cgp-popup-foot">updated ${Math.max(0, Math.round(age))} h ago</div>`
    );
  }
  parts.push(
    `<div class="cgp-popup-foot"><a href="${stationHref(station.station_key)}" ` +
      `data-station-link="${escapeHtml(station.station_key)}">Station history</a></div>`
  );
  return `<div class="cgp-popup">${parts.join("")}</div>`;
}

// Label goes on whichever nearby station sits highest at the series end.
function topNeighbour(rows) {
  if (!rows.length) return [];
  const lastDay = Math.max(...rows.map((row) => row.day.getTime()));
  const atEnd = rows.filter((row) => row.day.getTime() === lastDay);
  if (!atEnd.length) return [];
  return [atEnd.reduce((best, row) => (row.value > best.value ? row : best))];
}

// HTML legend for marks (dot and bar), currency-agnostic.
function spreadKeyEl(dotWords, barWords, note) {
  return html`<div class="cgp-legend">
    <span class="cgp-swatch cgp-swatch-dot" style=${"background:" + palette.muted}></span>
    <span>${dotWords}</span>
    <span class="cgp-swatch" style=${"background:" + palette.muted + ";opacity:0.32"}></span>
    <span>${barWords}</span>
    <span class="cgp-legend-note">${note}</span>
  </div>`;
}

// The latest of every series' first day — so the shared "change since" axis
// has one date all lines actually cover.
function commonBaseDay(seriesList) {
  const firsts = seriesList
    .map((rows) => (rows.length ? rows[0].day.getTime() : null))
    .filter((t) => t != null);
  return firsts.length ? new Date(Math.max(...firsts)) : null;
}

// The reading a series is measured from: base day, or its first day after it.
function anchorRow(rows, baseDay) {
  if (!baseDay) return null;
  return rows.find((row) => row.day >= baseDay && row.value != null) || null;
}

// Same-currency ratio; the unit cancels.
function pctFrom(value, base) {
  return value == null || !(base > 0) ? null : (value / base - 1) * 100;
}

function fmtPct(value) {
  if (value == null || !isFinite(value)) return "—";
  return `${value > 0 ? "+" : ""}${value.toFixed(1)}%`;
}

// Unsigned share, not a signed change.
function fmtShare(value) {
  if (value == null || !isFinite(value)) return "—";
  return `${value.toFixed(1)}%`;
}

// tableau10 has ten colors; beyond that Plot silently cycles them.
PALETTE_SERIES_CAP = 10;

// Dots add value when sparse; past ~1 per 4px they become noise.
function showDots(pointsPerSeries, plotWidth = 790) {
  return pointsPerSeries <= plotWidth / 4;
}

// A series is a chain in a country (summary_daily is keyed on both). The
// station count adds series up — taking the max alone would undercount.
function seriesOf(row) {
  return row.brand ? `${row.country}-${row.brand}` : row.country;
}

function latestStationCount(rows, series) {
  const scoped = series == null ? rows : rows.filter((row) => seriesOf(row) === series);
  let newest = "";
  for (const row of scoped) if (row.d > newest) newest = row.d;
  const bySeries = new Map();
  for (const row of scoped) {
    if (row.d !== newest) continue;
    // One row per series per day by construction. A second one would be the
    // same stations reported twice, so it is never added to the first.
    const key = seriesOf(row);
    bySeries.set(key, Math.max(bySeries.get(key) || 0, row.n_stations || 0));
  }
  let total = 0;
  for (const count of bySeries.values()) total += count;
  return total;
}

// Right margin sized to the longest end label (~5.2px/char of Plot's 10px
// type), capped at a quarter of the chart width. Long labels split on " · ".
function endLabelSpace(labels) {
  const room = Math.floor(chartWidth / 4);
  const width = (texts) => Math.ceil(14 + 5.2 * Math.max(0, ...texts.map((s) => s.length)));
  const oneLine = width(labels);
  const split = oneLine > room;
  const needed = split ? width(labels.flatMap((s) => s.split(" · "))) : oneLine;
  const marginRight = Math.max(60, Math.min(room, needed));
  return {
    marginRight,
    lineWidth: (marginRight - 8) / 10,
    text: (label) => (split ? label.split(" · ").join("\n") : label)
  };
}

// Plot doesn't move overlapping end labels apart, so we do it post-render.
// Labels marked with `ariaDescription: END_LABELS` are spaced vertically to
// clear each other, staying within the plot's y range.
END_LABELS = "line-end labels"

function spreadEndLabels(figure) {
  const texts = [...figure.querySelectorAll(`g[aria-description="${END_LABELS}"] text`)];
  if (!texts.length) return figure;
  const size = Number(texts[0].closest("svg").getAttribute("font-size")) || 10;
  const range = figure.scale("y").range;
  const top = Math.min(...range);
  const bottom = Math.max(...range);
  const labels = texts
    .map((text) => {
      const [, x, y, rest] = /^translate\(([^,]+),([^)]+)\)(.*)$/.exec(
        text.getAttribute("transform")
      );
      // Plot puts a mark's dx and dy on its group, as a translate of its own.
      const group = /translate\([^,]+,([^)]+)\)/.exec(text.parentNode.getAttribute("transform"));
      const shift = group ? Number(group[1]) : 0;
      const lines = Math.max(1, text.querySelectorAll("tspan").length);
      return {text, x, rest, shift, want: shift + Number(y), height: (lines + 0.4) * size};
    })
    .sort((a, b) => a.want - b.want);
  // Share the shortfall when labels can't all fit.
  const total = labels.reduce((sum, label) => sum + label.height, 0);
  const squeeze = Math.min(1, (bottom - top) / total);
  for (const label of labels) label.height *= squeeze;
  // Colliding labels form a block centered on the mean of their ideal y
  // positions; blocks that still touch are merged.
  const place = (members) => {
    let height = 0;
    let ideal = 0;
    for (const label of members) {
      ideal += label.want - height - label.height / 2;
      height += label.height;
    }
    const start = Math.max(top, Math.min(bottom - height, ideal / members.length));
    return {members, height, start};
  };
  const blocks = [];
  for (const label of labels) {
    blocks.push(place([label]));
    while (blocks.length > 1) {
      const [above, below] = blocks.slice(-2);
      if (above.start + above.height <= below.start) break;
      blocks.splice(-2, 2, place([...above.members, ...below.members]));
    }
  }
  for (const block of blocks) {
    let y = block.start;
    for (const label of block.members) {
      const center = y + label.height / 2 - label.shift;
      label.text.setAttribute("transform", `translate(${label.x},${center})${label.rest}`);
      y += label.height;
    }
  }
  return figure;
}

// Chart span and station count, stated once.
function coverageNote(days, from, to, stations) {
  const span = `${days} day${days === 1 ? "" : "s"}, ${from} to ${to}`;
  return stations ? `${span} · ${stations} stations on the latest day.` : `${span}.`;
}

function dayText(date) {
  return date.toISOString().slice(0, 10);
}

// Inline links continuing the station's name line.
function stationLinksEl(meta) {
  const directions = directionsUrl(meta);
  const warehouse = warehouseUrl(meta);
  if (!directions && !warehouse) return "";
  const links = [];
  if (directions) {
    links.push(
      html`<a href=${directions} target="_blank" rel="noopener">Directions on Google Maps</a>`
    );
  }
  if (warehouse) {
    links.push(
      html`<a href=${warehouse} target="_blank" rel="noopener">${brandName(meta)} page</a>`
    );
  }
  // The separator is an element with margins, not a space in the template:
  // htl trims whitespace at a template's edges, which ate the leading one and
  // ran the first link into the station id.
  return html`<span class="cgp-station-links">${links.map(
    (link) => html`<span class="cgp-station-sep">·</span>${link}`
  )}</span>`;
}

// Only storefronts that actually respond at the bare path get a link.
// MX, AU, JP return 200; GB, TW return 404; US and CA carry no store URL.
WAREHOUSE_PAGE = {
  return {
    MX: {base: "https://www.costco.com.mx/store/", field: "alt_id"},
    AU: {base: "https://www.costco.com.au/store/", field: "alt_id"},
    JP: {base: "https://www.costco.co.jp/store/", field: "source_station_id"}
  };
}

function warehouseUrl(meta) {
  const spec = meta && WAREHOUSE_PAGE[meta.country];
  const name = spec && meta[spec.field];
  return name ? spec.base + encodeURIComponent(name) : null;
}

// Coordinates first (unambiguous across seven countries); address as fallback.
function directionsUrl(meta) {
  if (!meta) return null;
  if (meta.lat != null && meta.lon != null) {
    return `https://www.google.com/maps/dir/?api=1&destination=${meta.lat},${meta.lon}`;
  }
  const parts = [meta.address, meta.city, meta.region, meta.postcode, COUNTRY_NAMES[meta.country]]
    .filter((v) => v)
    .join(", ");
  return parts
    ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(parts)}`
    : null;
}

// Consecutive price changes, newest first, with the from-price included.
function priceChanges(rows, limit = 30) {
  const byGrade = new Map();
  for (const row of rows) {
    if (row.price_local_per_litre == null) continue;
    if (!byGrade.has(row.grade)) byGrade.set(row.grade, []);
    byGrade.get(row.grade).push(row);
  }
  const out = [];
  for (const [gradeKey, series] of byGrade) {
    series.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : 0));
    for (let i = 1; i < series.length; i += 1) {
      if (!series[i].changed) continue;
      out.push({
        grade: gradeKey,
        d: series[i].d,
        from: series[i - 1].price_local_per_litre,
        to: series[i].price_local_per_litre,
        fromUsd: series[i - 1].price_usd_per_litre,
        toUsd: series[i].price_usd_per_litre,
        currency: series[i].currency
      });
    }
  }
  out.sort((a, b) => (a.d < b.d ? 1 : a.d > b.d ? -1 : 0));
  return out.slice(0, limit);
}

// The primary chain gets solid lines; the secondary one gets dashed.
PRIMARY_BRAND = "COSTCO"

// Chains per country, so labels name a chain only where a country has two.
function chainsByCountry(rows) {
  const out = new Map();
  for (const row of rows) {
    if (!row.brand) continue;
    if (!out.has(row.country)) out.set(row.country, new Set());
    out.get(row.country).add(row.brand);
  }
  return out;
}

// The chain name where disambiguation is needed, "" otherwise. Both chains are
// named in a two-chain country to avoid implying a pooled national figure.
function chainTag(country, brand, chains) {
  const shared = chains != null && chains.has(country) && chains.get(country).size > 1;
  if (!brand || (brand === PRIMARY_BRAND && !shared)) return "";
  return BRAND_NAMES[brand] || brand;
}

// Primary chain first, then alphabetical.
function chainOrder(a, b) {
  if (a === b) return 0;
  return a === PRIMARY_BRAND ? -1 : b === PRIMARY_BRAND ? 1 : a < b ? -1 : 1;
}

// Place name, with chain appended where chainTag says to.
function placeLabel(place, country, brand, chains) {
  const chain = chainTag(country, brand, chains);
  return chain ? `${place} · ${chain}` : place;
}

// A line's name.
function seriesLabel(country, brand, chains) {
  return placeLabel(COUNTRY_NAMES[country] || country, country, brand, chains);
}

// Solid/dashed line key for charts whose color legend is spent on regions.
function chainKeyEl(brands) {
  return html`<div class="cgp-legend">${[...brands].sort(chainOrder).map(
    (brand) => html`<svg width="22" height="12" aria-hidden="true"><line x1="0" x2="22" y1="6"
      y2="6" stroke=${palette.muted} stroke-width="2"
      stroke-dasharray=${brand === PRIMARY_BRAND ? null : "4 3"}></line></svg><span>${
      BRAND_NAMES[brand] || brand
    }</span>`
  )}</div>`;
}

BRAND_NAMES = {
  return {COSTCO: "Costco", SAMS: "Sam's Club"};
}

// Human-readable chain name; falls back to the raw code.
function brandName(meta) {
  return (meta && BRAND_NAMES[meta.brand]) || (meta && meta.brand) || "The chain";
}

// Human-readable status. Stations are never removed, only delisted.
function statusSentence(meta) {
  if (!meta) return "";
  if (meta.status === "closed") {
    return `${brandName(meta)} has not listed it in long enough that we call it closed.`;
  }
  if (meta.status === "missing") {
    return `${brandName(meta)} is not listing it right now — often a gap, not a closure.`;
  }
  return "Currently listed.";
}

// Exchange rate stored with the reading, so past USD values are authentic.
function rateNote(station) {
  const entry =
    station &&
    (gradeEntry(station, "regular") ||
      gradeEntry(station, "premium") ||
      gradeEntry(station, "diesel"));
  if (!entry) return "";
  if (entry.currency === "USD") {
    return html`<div class="cgp-notice">Published in USD, so no conversion is applied.</div>`;
  }
  if (entry.fx_usd_per_unit == null) {
    return html`<div class="cgp-notice">No exchange rate was available for this reading, so the
      USD column is empty.</div>`;
  }
  return html`<div class="cgp-notice">1 ${entry.currency} =
    ${entry.fx_usd_per_unit.toPrecision(6)} USD, the
    ${entry.fx_rate_date ? `${entry.fx_rate_date} ` : ""}reference rate stored with this reading
    (${entry.fx_source}).</div>`;
}

// Country flag from its ISO code via regional indicator symbols.
function countryFlag(code) {
  if (!code || code.length !== 2) return "";
  return String.fromCodePoint(
    ...[...code.toUpperCase()].map((letter) => 0x1f1e6 + letter.charCodeAt(0) - 65)
  );
}

// Quick-glance hover; the click popup has full detail.
function hoverHtml(station) {
  const entry =
    gradeEntry(station, "regular") ||
    gradeEntry(station, "premium") ||
    gradeEntry(station, "diesel");
  const code = currency === "USD" ? "USD" : entry ? entry.currency : "";
  const digits = volume === "gal" ? 2 : 3;
  const priced = ["regular", "premium", "diesel"]
    .map((key) => [GRADE_LABELS[key], stationPrice(station, key, currency, volume)])
    .filter(([, price]) => price != null)
    .map(([label, price]) => `${label} <b>${fmtNumber(price, digits)}</b>`);
  const place = [station.city, station.region].filter((v) => v).join(", ");
  // `new Date()`, not `nowTick` — subscribing to the 60s generator would
  // rebuild every marker on a timer, closing open popups.
  const age = hoursAgo(station.captured_at_utc, new Date());
  const parts = [
    `<div class="cgp-hover-name">${escapeHtml(station.name)}</div>`,
    `<div class="cgp-hover-place"><span class="cgp-flag">${countryFlag(
      station.country
    )}</span> ${escapeHtml(place)}${place ? " · " : ""}${escapeHtml(
      COUNTRY_NAMES[station.country] || station.country
    )}</div>`
  ];
  if (priced.length) {
    parts.push(
      `<div class="cgp-hover-prices">${priced.join(" · ")} ${escapeHtml(code)}${escapeHtml(
        volumeShort
      )}</div>`
    );
  } else {
    parts.push(`<div class="cgp-hover-prices">no price for this grade</div>`);
  }
  if (age != null) {
    parts.push(
      `<div class="cgp-hover-foot">read ${Math.max(0, Math.round(age))} h ago · click for detail</div>`
    );
  }
  return `<div class="cgp-hover">${parts.join("")}</div>`;
}

// Cluster color = median of its stations' fractions.
function clusterIcon(el, cluster) {
  const pal = el._pal || palette;
  const fractions = cluster
    .getAllChildMarkers()
    .map((marker) => marker._cgpFraction)
    .filter((f) => f != null)
    .sort((a, b) => a - b);
  // True median, not just the max.
  const mid = fractions.length / 2;
  const median = !fractions.length
    ? null
    : fractions.length % 2
      ? fractions[Math.floor(mid)]
      : (fractions[mid - 1] + fractions[mid]) / 2;
  const fill = median == null ? pal.missing : colorForFraction(median, pal);
  const count = cluster.getChildCount();
  const size = count < 10 ? 32 : count < 100 ? 40 : 48;
  return L.divIcon({
    className: "cgp-cluster",
    iconSize: [size, size],
    html:
      `<span style="background:${fill};border-color:${pal.stroke};color:${inkOn(fill)}">` +
      `${count}</span>`
  });
}

// Cluster count ink: whichever of these two meets WCAG AA (4.5:1) against
// the fill. Both themes' full ramp clears the threshold one way or the other.
CLUSTER_INKS = ["#ffffff", "#111418"]

// WCAG 2's relative luminance of a "#rgb", "#rrggbb" or "rgb(…)" color.
function relativeLuminance(color) {
  const text = String(color).trim();
  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text);
  const channels = hex
    ? (hex[1].length === 3 ? [...hex[1]].map((c) => c + c) : hex[1].match(/../g)).map((h) =>
        parseInt(h, 16)
      )
    : (text.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
  if (channels.length !== 3) return null;
  const [r, g, b] = channels.map((value) => {
    const c = value / 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrastRatio(a, b) {
  const [la, lb] = [relativeLuminance(a), relativeLuminance(b)];
  if (la == null || lb == null) return 1;
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}

function inkOn(fill) {
  return CLUSTER_INKS.reduce((best, ink) =>
    contrastRatio(ink, fill) > contrastRatio(best, fill) ? ink : best
  );
}

// Graduated symbol: costlier stations draw larger, all grow with zoom.
function markerRadius(zoom, fraction) {
  const base = Math.min(11.5, 3.4 + zoom * 0.7);
  const weight = fraction == null ? 0.7 : 0.62 + fraction * 0.7;
  // Floor at 4px — any smaller is hard to see and harder to tap.
  const r = Math.max(4, Math.min(14, base * weight));
  return Math.round(r * 10) / 10;
}

function usdFraction(station, grade, vol, domain) {
  if (!domain) return null;
  const usd = stationPrice(station, grade, "USD", vol);
  if (usd == null) return null;
  return Math.min(1, Math.max(0, (usd - domain[0]) / (domain[1] - domain[0])));
}

// Above this zoom, stations ungroup and stand on their own.
LABEL_ZOOM = 8;

function applyZoomStyling(el) {
  const map = el._map;
  if (!map) return;
  const zoom = map.getZoom();
  el.classList.toggle("cgp-map-zoomed", zoom >= LABEL_ZOOM);
  el._markers.eachLayer((marker) => {
    if (marker.setRadius) marker.setRadius(markerRadius(zoom, marker._cgpFraction));
  });
}
compareRows = db
  ? db.query(`
      SELECT country,
             brand,
             strftime(capture_date, '%Y-%m-%d') AS d,
             n_stations,
             n_stations_usd,
             median_local_per_litre,
             p25_local_per_litre,
             p75_local_per_litre,
             median_usd_per_litre,
             p25_usd_per_litre,
             p75_usd_per_litre
      FROM summary_daily
      WHERE level = 'country' AND grade = ${sqlText(grade)}${chainClause}
      ORDER BY country, brand, capture_date
    `)
  : []

// Newest day per series (chain in a place), not per place alone.
function newestByKey(rows, keyOf) {
  const newestFor = new Map();
  for (const row of rows) {
    if (!(row.n_stations > 0)) continue;
    const key = keyOf(row);
    const previous = newestFor.get(key);
    if (!previous || row.d > previous.d) newestFor.set(key, row);
  }
  let newest = "";
  for (const row of newestFor.values()) if (row.d > newest) newest = row.d;
  return {newestFor, newest};
}

// One row per chain in each country, the primary chain first. `chains` goes
// out with the rows so the region breakdown names chains by the same rule.
compareLatest = {
  const {newestFor, newest} = newestByKey(compareRows, seriesOf);
  const series = [...newestFor.values()];
  const chains = chainsByCountry(series);
  const rows = [];
  for (const code of COUNTRY_ORDER) {
    const mine = series
      .filter((row) => row.country === code)
      .sort((a, b) => chainOrder(a.brand, b.brand));
    if (!mine.length) {
      rows.push({country: code, label: COUNTRY_NAMES[code], sold: false, stale: false});
      continue;
    }
    for (const row of mine) {
      rows.push(
        Object.assign({}, row, {
          label: seriesLabel(row.country, row.brand, chains),
          sold: true,
          stale: row.d < newest
        })
      );
    }
  }
  return {rows, newest, chains};
}

function compareTableEl() {
  const body = compareLatest.rows.map((row) => {
    if (!row.sold) {
      return html`<tr><td>${COUNTRY_NAMES[row.country]}</td>
        <td colspan="5" class="cgp-stale">not sold</td></tr>`;
    }
    return html`<tr>
      <td>${row.label}${
        row.stale ? html` <span class="cgp-stale">stale</span>` : ""
      }</td>
      <td>${currencyOf.get(row.country) || ""}</td>
      <td class="cgp-num">${fmtNumber(perVolume(row.median_local_per_litre, volume), priceDigits)}</td>
      <td class="cgp-num">${fmtNumber(
        perVolume(row.p25_local_per_litre, volume),
        priceDigits
      )}–${fmtNumber(perVolume(row.p75_local_per_litre, volume), priceDigits)}</td>
      <td class="cgp-num">${row.n_stations}</td>
      <td>${row.d}</td>
    </tr>`;
  });
  return html`<table class="cgp-table">
    <thead><tr>
      <th>Country</th><th>Currency</th>
      <th class="cgp-num">Median ${volumeShort}</th>
      <th class="cgp-num">p25–p75</th>
      <th class="cgp-num">Stations</th>
      <th>Day</th>
    </tr></thead>
    <tbody>${body}</tbody>
  </table>`;
}
trendCountryRows = db
  ? db.query(`
      SELECT country,
             brand,
             strftime(capture_date, '%Y-%m-%d') AS d,
             n_stations,
             median_local_per_litre,
             p25_local_per_litre,
             p75_local_per_litre,
             median_usd_per_litre,
             p25_usd_per_litre,
             p75_usd_per_litre
      FROM summary_daily
      WHERE level = 'country' AND grade = ${sqlText(grade)}${chainClause}
      ORDER BY country, brand, capture_date
    `)
  : []
function onStationPage() {
  return /(^|\/)station(\.html)?$/.test(location.pathname.replace(/\/$/, "/index"));
}

// Single source of truth for station URLs.
function stationHref(key, {carry = true} = {}) {
  const base = "station.html?station=" + encodeURIComponent(key);
  return carry ? withControls(base) : base;
}

stationChannel = {
  const listeners = new Set();
  return {
    read: () => new URLSearchParams(location.search).get("station"),
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
    notify(key) {
      for (const fn of listeners) fn(key);
    },
    open(key) {
      const target = stationHref(key);
      // On the station page this is a selection; anywhere else it is a
      // navigation, because each page is its own document now.
      if (onStationPage()) {
        history.pushState({}, "", target);
        // pushState fires no event, so tell the generator directly.
        this.notify(key);
      } else {
        location.href = target;
      }
    }
  };
}

selectedStation = Generators.observe((notify) => {
  notify(stationChannel.read());
  const onPop = () => notify(stationChannel.read());
  window.addEventListener("popstate", onPop);
  const unsubscribe = stationChannel.subscribe((key) => notify(key));
  return () => {
    window.removeEventListener("popstate", onPop);
    unsubscribe();
  };
})

selectedMeta = selectedStation
  ? stationsAll.find((row) => row.station_key === selectedStation) || null
  : null

selectedLatest = selectedStation
  ? filteredLatest.find((row) => row.station_key === selectedStation) || null
  : null

nearestStations = {
  // Neighbours are useful even for stations that stopped posting.
  const origin = selectedLatest || selectedMeta;
  if (!origin || origin.lat == null) return [];
  return filteredLatest
    .filter(
      (row) =>
        row.station_key !== origin.station_key &&
        row.country === origin.country &&
        row.status === "active" &&
        row.lat != null &&
        row.lon != null
    )
    .map((row) => ({
      station_key: row.station_key,
      name: row.name,
      km: haversineKm(origin.lat, origin.lon, row.lat, row.lon)
    }))
    .sort((a, b) => a.km - b.km)
    .slice(0, 5);
}

// Guard: pages that didn't register history can't query it.
stationHistoryRows = {
  if (!db || !pageWantsHistory || !selectedMeta) return [];
  const keys = [selectedMeta.station_key, ...nearestStations.map((row) => row.station_key)];
  return db.query(`
    SELECT station_key,
           grade,
           strftime(capture_date, '%Y-%m-%d') AS d,
           price_local_per_litre,
           price_usd_per_litre,
           currency,
           changed,
           moved_intraday
    FROM history
    WHERE station_key IN (${sqlList(keys)})
    ORDER BY station_key, grade, capture_date
  `);
}

// Median for the station's own chain (not the other chain in the same region).
stationMedianRows = {
  if (!db || !selectedMeta) return [];
  const level = selectedMeta.region == null ? "country" : "region";
  const regionClause =
    selectedMeta.region == null ? "region IS NULL" : `region = ${sqlText(selectedMeta.region)}`;
  const brandClause = selectedMeta.brand ? `AND brand = ${sqlText(selectedMeta.brand)}` : "";
  return db.query(`
    SELECT strftime(capture_date, '%Y-%m-%d') AS d,
           median_local_per_litre,
           median_usd_per_litre
    FROM summary_daily
    WHERE level = '${level}'
      AND country = ${sqlText(selectedMeta.country)}
      AND ${regionClause}
      ${brandClause}
      AND grade = ${sqlText(grade)}
    ORDER BY capture_date
  `);
}
pageWantsDb = true
pageWantsHistory = true
// A phone folds the four groups below behind this one line, which says what
// they are set to and opens them on a tap. A wider screen never shows it.
controlSummary = {
  const parts = [GRADE_LABELS[grade], currency === "USD" ? "USD" : "Local", volumeLabel];
  // The Chain control only shows where the data has two.
  if (allBrands.length > 1) {
    parts.push(chain === "all" ? "Both chains" : BRAND_NAMES[chain] || chain);
  }
  // The cell runs again on every choice, so the button it replaces says
  // whether the controls are open.
  const bar = document.querySelector(".cgp-controlbar");
  const open = Boolean(bar && bar.classList.contains("is-open"));
  const button = html`<button type="button" class="cgp-controlbar-toggle"
    aria-controls="cgp-controls" aria-expanded=${String(open)}>
    <span class="cgp-controlbar-summary">${parts.join(" · ")}</span>
    <span class="cgp-controlbar-cue">${open ? "Done" : "Change"}</span>
  </button>`;
  button.addEventListener("click", () => {
    const now = button.closest(".cgp-controlbar").classList.toggle("is-open");
    button.setAttribute("aria-expanded", String(now));
    button.querySelector(".cgp-controlbar-cue").textContent = now ? "Done" : "Change";
  });
  return button;
}
viewof grade = radioControl(
  "grade",
  [["Regular", "regular"], ["Premium", "premium"], ["Diesel", "diesel"]],
  "regular",
  "Grade"
)
viewof currency = radioControl(
  "currency",
  [["USD", "USD"], ["Local", "Local"]],
  "USD",
  "Currency"
)
viewof volume = radioControl(
  "volume",
  [["per US gallon", "gal"], ["per litre", "litre"]],
  "gal",
  "Volume"
)
// Only shown when the data has more than one chain.
viewof chain = {
  const entries = [["Both", "all"], ...allBrands.map((b) => [BRAND_NAMES[b] || b, b])];
  const input = radioControl("chain", entries, "all", "Chain");
  if (allBrands.length < 2) input.style.display = "none";
  return input;
}
freshnessNotice = {
  const now = nowTick;
  const items = [];
  const builtAge = hoursAgo(meta.built_at_utc, now);
  if (builtAge != null && builtAge > staleAfterHours) {
    items.push(`This site has not updated since ${meta.built_at_utc}.`);
  }
  const stale = [];
  // Each chain's feed where meta.json lists them, each country where not.
  for (const {label, captureId} of freshnessSources) {
    const when = captureIdToDate(captureId);
    if (!when) {
      stale.push(`${label} (never)`);
      continue;
    }
    const age = (now.getTime() - when.getTime()) / 3600000;
    if (age > staleAfterHours) stale.push(`${label} (${Math.floor(age)} h)`);
  }
  if (stale.length) {
    items.push(
      `Last successful capture over ${staleAfterHours} h ago: ${stale.join(", ")}.`
    );
  }
  if (!items.length) {
    return html`<div class="cgp-notice">Every country was captured in the last
      ${staleAfterHours} hours.</div>`;
  }
  return html`<div class="cgp-alert">${items.map((text) => html`<div>${text}</div>`)}</div>`;
}

Find a station

viewof stationQuery = asField(
  Inputs.search(filteredStations, {
    placeholder: "Name, local name, city or region…",
    columns: ["name", "name_local", "city", "region"],
    label: "Search"
  }),
  {inline: true}
)
// One chunk so the search box and results share a card body. When a station
// is open and nothing is typed, the list is hidden.
stationResults = {
  const typed = (viewof stationQuery).querySelector("input").value.trim();
  if (selectedMeta && !typed) {
    return html`<div class="cgp-search">${viewof stationQuery}</div>`;
  }
  const buttons = stationQuery.slice(0, 40).map((row) => {
    const suffix =
      row.status === "active"
        ? ""
        : ` · ${row.status === "closed" ? "closed" : "not listed"}, last seen ${String(
            row.last_seen_utc || ""
          ).slice(0, 10)}`;
    const button = html`<button type="button" data-station-key=${row.station_key}>${row.name}${
      row.name_local ? ` (${row.name_local})` : ""
    } — ${[row.city, row.region, row.country].filter((v) => v).join(", ")}${suffix}</button>`;
    button.addEventListener("click", () => stationChannel.open(row.station_key));
    return button;
  });
  const body = buttons.length
    ? html`<div class="cgp-results">${buttons}</div>`
    : html`<div class="cgp-notice">No station matches.</div>`;
  return html`<div class="cgp-search">${viewof stationQuery}${body}</div>`;
}

Price history

stationChart = {
  if (!selectedStation) {
    return html`<div class="cgp-notice cgp-station-chart">Search for a station, or open one from
      the Map.</div>`;
  }
  if (!selectedMeta) {
    return html`<div class="cgp-alert cgp-station-chart">Station not found: ${selectedStation}</div>`;
  }
  if (!db) {
    return html`<div class="cgp-notice cgp-station-chart">Loading the price database…</div>`;
  }
  const pick = (row) =>
    perVolume(currency === "USD" ? row.price_usd_per_litre : row.price_local_per_litre, volume);
  const own = stationHistoryRows
    .filter((row) => row.station_key === selectedMeta.station_key)
    .map((row) => ({day: toDay(row.d), grade: row.grade, value: pick(row)}))
    .filter((row) => row.value != null);
  const neighbours = stationHistoryRows
    .filter((row) => row.station_key !== selectedMeta.station_key && row.grade === grade)
    .map((row) => ({day: toDay(row.d), key: row.station_key, value: pick(row)}))
    .filter((row) => row.value != null);
  const medians = stationMedianRows
    .map((row) => ({
      day: toDay(row.d),
      value: perVolume(
        currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
        volume
      )
    }))
    .filter((row) => row.value != null);
  if (!own.length && !medians.length) {
    return html`<div class="cgp-notice cgp-station-chart">No history yet for
      ${selectedMeta.name}.</div>`;
  }
  // Label names the chain where the country has two; wraps to fit the margin.
  const chain = chainTag(selectedMeta.country, selectedMeta.brand, chainsByCountry(filteredLatest));
  const medianLabel = `${chain ? `${chain}\n` : ""}${
    selectedMeta.region == null ? COUNTRY_NAMES[selectedMeta.country] : selectedMeta.region
  } median`;
  const entry = selectedLatest ? gradeEntry(selectedLatest, grade) : null;
  const unit = currency === "USD" ? "USD" : entry ? entry.currency : "local";
  const figure = Plot.plot({
    width: chartWidth,
    ariaLabel: `${selectedMeta ? selectedMeta.name : "Station"}: ${GRADE_LABELS[grade]} price over time`,
    height: 360,
    marginRight: 80,
    style: {background: "transparent", color: palette.text},
    color: {
      legend: true,
      domain: ["regular", "premium", "diesel"],
      range: [palette.ramp[0], palette.ramp[3], palette.ramp[2]]
    },
    // The neighbours are drawn too, so they are part of the span the ticks cover.
    x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(own.concat(medians, neighbours))},
    y: {label: `${unit} ${volumeLabel}`, grid: true, nice: true},
    marks: [
      Plot.line(neighbours, {
        x: "day",
        y: "value",
        z: "key",
        stroke: palette.muted,
        strokeOpacity: 0.35
      }),
      // One label for the set of faint neighbour lines, wrapping to fit.
      Plot.text(topNeighbour(neighbours), {
        x: "day",
        y: "value",
        text: () => {
          const n = new Set(neighbours.map((row) => row.key)).size;
          return n === 1 ? "nearest station" : `nearest ${n} stations`;
        },
        dx: 6,
        dy: -8,
        textAnchor: "start",
        lineWidth: 7,
        fill: palette.muted,
        ariaDescription: END_LABELS
      }),
      Plot.line(medians, {
        x: "day",
        y: "value",
        stroke: palette.muted,
        strokeDasharray: "4 3",
        strokeWidth: 2
      }),
      Plot.text(medians.slice(-1), {
        x: "day",
        y: "value",
        text: () => medianLabel,
        dx: 6,
        textAnchor: "start",
        lineWidth: 7,
        fill: palette.muted,
        ariaDescription: END_LABELS
      }),
      Plot.line(own, {x: "day", y: "value", z: "grade", stroke: "grade", strokeWidth: 2.5}),
      Plot.dot(own, {
        x: "day",
        y: "value",
        z: "grade",
        fill: "grade",
        r: 3
      }),
      // Last, so it paints over the median label rather than under it.
      Plot.tip(
        own,
        Plot.pointer({
          x: "day",
          y: "value",
          ...tipStyle(),
          title: (row) =>
            `${selectedMeta.name} · ${row.day.toISOString().slice(0, 10)}\n` +
            `${GRADE_LABELS[row.grade] || row.grade} ${fmtNumber(row.value, priceDigits)} ` +
            `${unit}${volumeShort}`
        })
      )
    ]
  });
  // Spread the two right-margin labels so they don't overlap.
  spreadEndLabels(figure);
  return html`<div class="cgp-station-chart">${figure}</div>`;
}

Station detail

stationDetail = {
  if (!selectedStation) return html`<div class="cgp-notice">Nothing selected.</div>`;
  if (!selectedMeta) {
    return html`<div class="cgp-alert">Station not found: ${selectedStation}</div>`;
  }
  const entries = selectedLatest
    ? [
        ...["regular", "premium", "diesel"]
          .filter((key) => gradeEntry(selectedLatest, key))
          .map((key) => [GRADE_LABELS[key], gradeEntry(selectedLatest, key)]),
        ...Object.entries(selectedLatest.other || {})
      ]
    : [];
  const rows = entries.map(
    ([label, entry]) => html`<tr>
      <td>${label}</td>
      <td>${entry.price_raw} ${entry.price_unit}</td>
      <td class="cgp-num">${fmtNumber(perVolume(entry.price_local_per_litre, volume), priceDigits)}
        ${entry.currency}${volumeShort}</td>
      <td class="cgp-num">${fmtNumber(perVolume(entry.price_usd_per_litre, volume), priceDigits)}
        USD${volumeShort}</td>
    </tr>`
  );
  const links = [];
  if (selectedMeta.superseded_by) {
    links.push(html`<div class="cgp-notice">Superseded by
      <a href=${stationHref(selectedMeta.superseded_by)}>${
        selectedMeta.superseded_by
      }</a>.</div>`);
  }
  for (const row of stationsAll.filter((s) => s.superseded_by === selectedMeta.station_key)) {
    links.push(html`<div class="cgp-notice">Supersedes
      <a href=${stationHref(row.station_key)}>${
        row.station_key
      }</a>.</div>`);
  }
  const neighbours = nearestStations.map(
    (row) => html`<li><a href=${stationHref(row.station_key)}>${
      row.name
    }</a> <span class="cgp-stale">${row.km.toFixed(0)} km</span></li>`
  );
  // Chain name, where disambiguation is needed.
  const chain = chainTag(selectedMeta.country, selectedMeta.brand, chainsByCountry(filteredLatest));
  return html`<div>
    <div class="cgp-popup-title">${selectedMeta.name}${
      selectedMeta.name_local
        ? html` <span class="cgp-local">${selectedMeta.name_local}</span>`
        : ""
    }</div>
    <div class="cgp-popup-place">${[
      selectedMeta.city,
      selectedMeta.region,
      COUNTRY_NAMES[selectedMeta.country]
    ]
      .filter((v) => v)
      .join(", ")}${chain ? ` · ${chain}` : ""} · ${selectedMeta.station_key}${
      stationLinksEl(selectedMeta)
    }</div>
    <table class="cgp-table">
      <thead><tr><th>Grade</th><th>As published</th>
        <th class="cgp-num">Local</th><th class="cgp-num">USD</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
    <div class="cgp-notice">First seen ${String(selectedMeta.first_seen_utc || "").slice(0, 10)},
      last seen ${String(selectedMeta.last_seen_utc || "").slice(0, 10)}.
      ${statusSentence(selectedMeta)}</div>
    ${rateNote(selectedLatest)}
    ${links}
    <div class="cgp-notice">Nearest active stations:</div>
    <ul class="cgp-notice">${neighbours}</ul>
  </div>`;
}

Recent price changes

stationChanges = {
  if (!selectedMeta) return html`<div class="cgp-notice">Nothing selected.</div>`;
  if (!db) return html`<div class="cgp-notice">Loading the price database…</div>`;
  const own = stationHistoryRows.filter((row) => row.station_key === selectedMeta.station_key);
  const changes = priceChanges(own, 30);
  if (!changes.length) {
    return html`<div class="cgp-notice">No price change recorded yet for
      ${selectedMeta.name}. Its prices have held for every day collected so far.</div>`;
  }
  const rows = changes.map((change) => {
    // Pick currency first, then derive the delta from that same pair.
    const from = currency === "USD" ? change.fromUsd : change.from;
    const to = currency === "USD" ? change.toUsd : change.to;
    const delta = from == null || to == null ? null : to - from;
    const pct = delta != null && from > 0 ? (delta / from) * 100 : null;
    // The arrow wears the color a fall or a rise has on Changes; the figure
    // beside it stays in the text's own.
    const arrow =
      delta == null
        ? "—"
        : html`<span class=${delta > 0 ? "cgp-up" : "cgp-down"}>${delta > 0 ? "▲" : "▼"}</span>`;
    return html`<tr>
      <td>${change.d}</td>
      <td>${GRADE_LABELS[change.grade] || change.grade}</td>
      <td class="cgp-num">${fmtNumber(perVolume(from, volume), priceDigits)}</td>
      <td class="cgp-num">${fmtNumber(perVolume(to, volume), priceDigits)}</td>
      <td class="cgp-num">${arrow} ${pct == null ? "" : fmtPct(pct)}</td>
    </tr>`;
  });
  // A station prices in one currency, so the unit is said once, above the
  // table: in every row it took a phone's rows onto a second line.
  const unit = currency === "USD" ? "USD" : changes[0].currency;
  return html`<div>
    <div class="cgp-notice">Prices in ${unit} ${volumeLabel}.</div>
    <div class="cgp-table-scroll">
      <table class="cgp-table cgp-changes-table">
        <thead><tr><th>First seen</th><th>Grade</th><th class="cgp-num">From</th>
          <th class="cgp-num">To</th><th class="cgp-num">Change</th></tr></thead>
        <tbody>${rows}</tbody>
      </table>
    </div>
  </div>`;
}
// Notice from meta.json, one clause per chain present. The email address is
// assembled client-side from parts so crawlers find nothing to harvest.
siteFooter = {
  const repo = (meta.releases && meta.releases.all
    ? meta.releases.all.replace(/\/releases.*$/, "")
    : "https://github.com/coatless-data/club-gas-prices");
  const address = ["support", ["caffeinatedmath", "com"].join(".")].join("@");
  const mail = html`<a href=${"mail" + "to:" + address}>${address}</a>`;
  return html`<div>
    ${(Array.isArray(meta.notice) ? meta.notice : [meta.notice])
      .map((line) => html`<p class="cgp-footer-notice">${line}</p>`)}
    <p class="cgp-footer-notice">Represent one of these chains and want something changed or
      removed? <a href=${repo + "/issues/new"}>Open an issue</a> on GitHub or email ${mail}.</p>
  </div>`;
}