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

Trends

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 = false
// 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>`;
}
mixedCurrency = currency === "Local" && trendCountry === "All countries"
effectiveTrendMode = mixedCurrency && trendMode === "price" ? "change" : trendMode
// On a percentage axis, per-gallon and per-litre draw the same lines.
// Dimmed rather than disabled (it still affects the tooltip).
volumeGuard = {
  const inert = effectiveTrendMode === "change";
  for (const input of (viewof volume).querySelectorAll("input")) {
    const label = input.closest("label");
    if (label) {
      label.title = inert ? "A percentage axis is the same per gallon and per litre" : "";
      label.classList.toggle("cgp-option-off", inert);
    }
  }
  return inert;
}

trendModeGuard = {
  const input = (viewof trendMode).querySelector('input[data-value="price"]');
  if (input) {
    input.disabled = mixedCurrency;
    const label = input.closest("label");
    if (label) {
      label.title = mixedCurrency ? "One price axis needs one currency" : "";
      label.classList.toggle("cgp-option-off", mixedCurrency);
    }
  }
  return mixedCurrency;
}

// The card body is laid out after the cell runs, and Leaflet needs a real size.
trendRegionRows =
  db && trendCountry !== "All countries"
    ? db.query(`
        SELECT region,
               brand,
               strftime(capture_date, '%Y-%m-%d') AS d,
               n_stations,
               median_local_per_litre,
               median_usd_per_litre
        FROM summary_daily
        WHERE level = 'region'
          AND country = ${sqlText(trendCountry)}
          AND grade = ${sqlText(grade)}${chainClause}
        ORDER BY region, brand, capture_date
      `)
    : []
// A region's count is each chain's largest day, added up. The larger chain
// alone labelled a state both chains serve with half of its stations.
trendRegionOptions = {
  const perChain = new Map();
  for (const row of trendRegionRows) {
    const key = `${row.region} :: ${row.brand}`;
    const seen = perChain.get(key);
    if (row.n_stations > (seen ? seen.n : 0)) {
      perChain.set(key, {region: row.region, n: row.n_stations});
    }
  }
  const counts = new Map();
  for (const {region, n} of perChain.values()) counts.set(region, (counts.get(region) || 0) + n);
  return [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
}

Breakdown

viewof trendCountry = asField(
  selectControl("breakdown", ["All countries", ...COUNTRY_ORDER], {
    label: "Breakdown",
    format: (code) => (code === "All countries" ? code : COUNTRY_NAMES[code])
  })
)
viewof showBand = asField(
  Inputs.toggle({label: "Band: middle half of stations", value: true}),
  {inline: true}
)
// Static labels — depending on reactive values would reset the reader's choice.
viewof trendMode = radioControl(
  "trendmode",
  [["Price", "price"], ["Change", "change"], ["Separate charts", "separate"]],
  "price",
  "View"
)
viewof trendRegions = {
  const options = trendRegionOptions;
  const input = Inputs.checkbox(
    options.map((entry) => entry[0]),
    {
      label: "Regions",
      value: options.slice(0, PALETTE_SERIES_CAP).map((entry) => entry[0]),
      format: (code) => {
        const found = options.find((entry) => entry[0] === code);
        return `${code} (${found ? found[1] : 0})`;
      }
    }
  );
  // Hidden when no regions exist (all-countries view).
  if (!options.length) input.style.display = "none";
  return asField(input);
}
// One chunk → one card body → controls share a single row.
html`<div class="cgp-controls-row">${[
  viewof trendCountry,
  viewof trendMode,
  viewof showBand,
  viewof trendRegions
]}</div>`

Price over time

function coverageStrip(rows, {label, chart}) {
  const svg = chart.tagName.toLowerCase() === "svg" ? chart : chart.querySelector(":scope > svg");
  const width = Number(svg.getAttribute("width"));
  const x = chart.scale("x");
  const [first, last] = x.domain;
  const byDay = new Map();
  for (const row of rows) {
    // Key on day + series so every chain in every country gets a point.
    const series = seriesOf(row);
    byDay.set(`${row.d} :: ${series}`, {
      day: toDay(row.d),
      brand: row.brand,
      series,
      country: row.country,
      n: Number(row.n_stations) || 0
    });
  }
  // A day the chart has no value for, such as one a currency had no rate on,
  // would be drawn out in the margins, off the shared axis.
  const data = [...byDay.values()].filter((row) => row.day >= first && row.day <= last);
  if (data.length < 2) return "";
  return Plot.plot({
    width,
    // "posted a price" not "open" — dips are usually collection failures.
    ariaLabel: `Stations that posted a ${label} price, by day`,
    // Two-line day ticks need more than Plot's default bottom margin.
    height: 82,
    marginLeft: x.range[0],
    marginRight: width - x.range[1],
    marginBottom: 34,
    style: {background: "transparent", color: palette.text},
    x: {label: null, grid: false, domain: x.domain, ticks: dayTicks(x.domain)},
    y: {label: "stations", grid: true, nice: true, ticks: 3},
    marks: [
      Plot.ruleY([0], {stroke: palette.text, strokeOpacity: 0.2}),
      Plot.line(data, {
        x: "day",
        y: "n",
        z: "series",
        curve: "step",
        stroke: (row) => COUNTRY_COLORS[row.country],
        strokeWidth: 1.5
      })
    ]
  });
}

// Charts are built in the card that shows them: OJS inserts a cell's node
// where it's defined, and a hidden chunk would produce a zero-width chart.
trendView = {
  // Which chains each country has, so every chart below names a chain by the
  // same rule, and only where a country has two.
  const chains = chainsByCountry(trendCountryRows);

  function allCountriesUsd() {
    const data = trendCountryRows
      .filter((row) => row.median_usd_per_litre != null)
      .map((row) => ({
        country: row.country,
        brand: row.brand,
        // The line's identity is the CHAIN in a country, not the country. Two
        // chains share a country, and a series keyed on country alone would
        // draw one line zig-zagging between two different companies' prices.
        series: row.brand ? `${row.country}-${row.brand}` : row.country,
        label: seriesLabel(row.country, row.brand, chains),
        day: toDay(row.d),
        median: perVolume(row.median_usd_per_litre, volume),
        p25: perVolume(row.p25_usd_per_litre, volume),
        p75: perVolume(row.p75_usd_per_litre, volume)
      }));
    if (!data.length) {
      return html`<div class="cgp-notice">No USD values for ${GRADE_LABELS[grade]}.</div>`;
    }
    const days = [...new Set(data.map((row) => row.day.getTime()))].sort();
    const ends = endLabelSpace(data.map((row) => row.label));
    const marks = [];
    if (showBand) {
      marks.push(
        Plot.areaY(data, {
          x: "day",
          y1: "p25",
          y2: "p75",
          z: "series",
          fill: (row) => COUNTRY_COLORS[row.country],
          fillOpacity: 0.14
        })
      );
    }
    marks.push(
      Plot.line(data.filter((row) => !row.brand || row.brand === PRIMARY_BRAND), {
        x: "day",
        y: "median",
        z: "series",
        stroke: (row) => COUNTRY_COLORS[row.country],
        strokeWidth: 2
      }),
      Plot.line(data.filter((row) => row.brand && row.brand !== PRIMARY_BRAND), {
        x: "day",
        y: "median",
        z: "series",
        stroke: (row) => COUNTRY_COLORS[row.country],
        strokeWidth: 2,
        strokeDasharray: "4 3"
      }),
      ...(showDots(days.length)
        ? [Plot.dot(data, {x: "day", y: "median", r: 2, fill: (row) => COUNTRY_COLORS[row.country]})]
        : []),
      // selectLast per series so a stalled feed keeps its label.
      Plot.text(
        data,
        Plot.selectLast({
          x: "day",
          y: "median",
          z: "series",
          text: (row) => ends.text(row.label),
          dx: 6,
          textAnchor: "start",
          lineWidth: ends.lineWidth,
          fill: (row) => COUNTRY_COLORS[row.country],
          ariaDescription: END_LABELS
        })
      ),
      // Last, so it paints over the country labels rather than under them.
      Plot.tip(
        data,
        Plot.pointer({
          x: "day",
          y: "median",
          ...tipStyle(),
          title: (row) =>
            `${row.label} · ${row.day.toISOString().slice(0, 10)}\n` +
            `median ${fmtNumber(row.median, priceDigits)} USD ${volumeLabel}\n` +
            `p25 ${fmtNumber(row.p25, priceDigits)} · p75 ${fmtNumber(row.p75, priceDigits)}`
        })
      )
    );
    const figure = Plot.plot({
      width: chartWidth,
      ariaLabel: `Median ${GRADE_LABELS[grade]} price over time, by country`,
      height: 420,
      marginRight: ends.marginRight,
      style: {background: "transparent", color: palette.text},
      x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(days)},
      y: {label: `Median ${GRADE_LABELS[grade]}, USD ${volumeLabel}`, grid: true, nice: true},
      marks
    });
    spreadEndLabels(figure);
    return html`<div>${figure}${coverageStrip(trendCountryRows, {
      label: GRADE_LABELS[grade],
      chart: figure
    })}<div class="cgp-notice">${coverageNote(
      days.length,
      dayText(new Date(days[0])),
      dayText(new Date(days[days.length - 1])),
      latestStationCount(trendCountryRows)
    )}</div></div>`;
  }

  // Change view: the unit cancels (same-currency ratio), so it works in
  // either currency.
  function allCountriesChange() {
    const byCountry = new Map();
    for (const row of trendCountryRows) {
      const value = perVolume(
        currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
        volume
      );
      if (value == null) continue;
      const seriesKey = row.brand ? `${row.country}-${row.brand}` : row.country;
      if (!byCountry.has(seriesKey)) byCountry.set(seriesKey, []);
      byCountry.get(seriesKey).push({
        country: row.country,
        brand: row.brand,
        day: toDay(row.d),
        value,
        p25: perVolume(
          currency === "USD" ? row.p25_usd_per_litre : row.p25_local_per_litre,
          volume
        ),
        p75: perVolume(
          currency === "USD" ? row.p75_usd_per_litre : row.p75_local_per_litre,
          volume
        )
      });
    }
    const series = [...byCountry.values()];
    const baseDay = commonBaseDay(series);
    if (!baseDay) {
      return html`<div class="cgp-notice">Not enough history yet for
        ${GRADE_LABELS[grade]}.</div>`;
    }
    const data = [];
    for (const [code, rows] of byCountry) {
      const anchor = anchorRow(rows, baseDay);
      if (!anchor) continue;
      const rebased = anchor.day.getTime() !== baseDay.getTime();
      for (const row of rows) {
        const pct = pctFrom(row.value, anchor.value);
        if (pct == null) continue;
        data.push({
          country: row.country,
          brand: row.brand,
          series: code,
          label: seriesLabel(row.country, row.brand, chains),
          day: row.day,
          pct,
          p25: pctFrom(row.p25, anchor.value),
          p75: pctFrom(row.p75, anchor.value),
          price: row.value,
          anchorPrice: anchor.value,
          anchorDay: dayText(anchor.day),
          // By the row's country: `code` is the series, which carries the
          // chain as well, and currencyOf is keyed on the country alone.
          unit: currency === "USD" ? "USD" : currencyOf.get(row.country) || "local",
          rebased
        });
      }
    }
    if (!data.length) {
      return html`<div class="cgp-notice">No values for ${GRADE_LABELS[grade]}.</div>`;
    }
    const baseText = dayText(baseDay);
    const days = [...new Set(data.map((row) => row.day.getTime()))].sort();
    const ends = endLabelSpace(data.map((row) => row.label));
    const marks = [Plot.ruleY([0], {stroke: palette.grid})];
    if (showBand) {
      marks.push(
        Plot.areaY(
          data.filter((row) => row.p25 != null && row.p75 != null),
          {
            x: "day",
            y1: "p25",
            y2: "p75",
            z: "series",
            fill: (row) => COUNTRY_COLORS[row.country],
            fillOpacity: 0.12
          }
        )
      );
    }
    marks.push(
      Plot.line(data.filter((row) => !row.brand || row.brand === PRIMARY_BRAND), {
        x: "day",
        y: "pct",
        z: "series",
        stroke: (row) => COUNTRY_COLORS[row.country],
        strokeWidth: 2
      }),
      Plot.line(data.filter((row) => row.brand && row.brand !== PRIMARY_BRAND), {
        x: "day",
        y: "pct",
        z: "series",
        stroke: (row) => COUNTRY_COLORS[row.country],
        strokeWidth: 2,
        strokeDasharray: "4 3"
      }),
      ...(showDots(days.length)
        ? [Plot.dot(data, {x: "day", y: "pct", r: 2, fill: (row) => COUNTRY_COLORS[row.country]})]
        : []),
      Plot.text(
        data,
        Plot.selectLast({
          x: "day",
          y: "pct",
          z: "series",
          text: (row) => ends.text(row.label),
          dx: 6,
          textAnchor: "start",
          lineWidth: ends.lineWidth,
          fill: (row) => COUNTRY_COLORS[row.country],
          ariaDescription: END_LABELS
        })
      ),
      Plot.tip(
        data,
        Plot.pointer({
          x: "day",
          y: "pct",
          ...tipStyle(),
          title: (row) =>
            `${row.label} · ${dayText(row.day)}\n` +
            `${fmtPct(row.pct)} since ${row.anchorDay}${
              row.rebased ? " (its own first day)" : ""
            }\n` +
            `${fmtNumber(row.price, priceDigits)} ${row.unit}${volumeShort} that day\n` +
            `${fmtNumber(row.anchorPrice, priceDigits)} ${row.unit}${volumeShort} on ${
              row.anchorDay
            }`
        })
      )
    );
    // Short label on narrow screens — the grade is already in the control bar.
    const label =
      currency === "USD"
        ? `Change in median ${GRADE_LABELS[grade]} since ${baseText}, in USD (%)`
        : chartWidth < 500
          ? `Change since ${baseText}, each in its own currency (%)`
          : `Change in median ${GRADE_LABELS[grade]} since ${baseText}, each country in its own currency (%)`;
    const figure = Plot.plot({
      width: chartWidth,
      ariaLabel: `Change in median ${GRADE_LABELS[grade]} since ${baseText}, by country`,
      height: 400,
      // "+20.0%" is wider than Plot's 40px default gutter, which cut the sign
      // off every tick above zero.
      marginLeft: 52,
      marginRight: ends.marginRight,
      style: {background: "transparent", color: palette.text},
      x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(days)},
      y: {label, grid: true, nice: true, tickFormat: fmtPct},
      marks
    });
    spreadEndLabels(figure);
    return html`<div>${figure}${coverageStrip(trendCountryRows, {
      label: GRADE_LABELS[grade],
      chart: figure
    })}<div class="cgp-notice">${coverageNote(
      days.length,
      dayText(new Date(days[0])),
      dayText(new Date(days[days.length - 1])),
      latestStationCount(trendCountryRows)
    )}</div></div>`;
  }

  function allCountriesLocal() {
    const byCountry = new Map();
    for (const row of trendCountryRows) {
      if (row.median_local_per_litre == null) continue;
      if (!byCountry.has(row.country)) byCountry.set(row.country, []);
      byCountry.get(row.country).push({
        country: row.country,
        brand: row.brand,
        series: row.brand ? `${row.country}-${row.brand}` : row.country,
        day: toDay(row.d),
        median: perVolume(row.median_local_per_litre, volume),
        p25: perVolume(row.p25_local_per_litre, volume),
        p75: perVolume(row.p75_local_per_litre, volume)
      });
    }
    const panels = COUNTRY_ORDER.filter((code) => byCountry.has(code)).map((code) => {
      const rows = byCountry.get(code);
      const marks = [];
      if (showBand) {
        marks.push(
          // One band per chain. Without `z` the two chains' rows became one
          // polygon that ran to the last day and doubled back over the first.
          Plot.areaY(rows, {
            x: "day",
            y1: "p25",
            y2: "p75",
            z: "series",
            fill: COUNTRY_COLORS[code],
            fillOpacity: 0.16
          })
        );
      }
      const unit = `${currencyOf.get(code) || ""} ${volumeShort}`;
      marks.push(
        Plot.line(rows.filter((r) => !r.brand || r.brand === PRIMARY_BRAND), {
          x: "day",
          y: "median",
          z: "series",
          stroke: COUNTRY_COLORS[code],
          strokeWidth: 2
        }),
        Plot.line(rows.filter((r) => r.brand && r.brand !== PRIMARY_BRAND), {
          x: "day",
          y: "median",
          z: "series",
          stroke: COUNTRY_COLORS[code],
          strokeWidth: 2,
          strokeDasharray: "4 3"
        }),
        Plot.dot(rows, {x: "day", y: "median", r: 2, fill: COUNTRY_COLORS[code]}),
        Plot.tip(
          rows,
          Plot.pointerX({
            x: "day",
            y: "median",
            ...tipStyle(),
            title: (row) =>
              `${seriesLabel(code, row.brand, chains)} · ${dayText(row.day)}\n` +
              `median ${fmtNumber(row.median, priceDigits)} ${unit}\n` +
              `p25 ${fmtNumber(row.p25, priceDigits)} · p75 ${fmtNumber(row.p75, priceDigits)}`
          })
        )
      );
      const figure = Plot.plot({
        // A panel, not the card: without an explicit width Quarto's autosizing
        // gives every one of these the full card width, and the
        // `.cgp-small-multiples` grid (columns of at least 240 px) collapses
        // to a single column. An explicit width wins because the injected one
        // is prepended to this object.
        width: 240,
        ariaLabel: `${COUNTRY_NAMES[code] || code}: median ${GRADE_LABELS[grade]} over time, ${unit}`,
        height: 190,
        marginLeft: 52,
        style: {background: "transparent", color: palette.text},
        x: {label: null, ticks: dayTicks(rows, 240), grid: true},
        y: {label: `${currencyOf.get(code) || ""} ${volumeShort}`, grid: true, nice: true},
        marks
      });
      return html`<div><div class="cgp-stale">${COUNTRY_NAMES[code]}</div>${figure}</div>`;
    });
    if (!panels.length) {
      return html`<div class="cgp-notice">No local values for ${GRADE_LABELS[grade]}.</div>`;
    }
    return html`<div class="cgp-small-multiples">${panels}</div>`;
  }

  function regionBreakdown() {
    const unit = currency === "USD" ? "USD" : currencyOf.get(trendCountry) || "local";
    if (!trendRegionOptions.length) {
      const rows = trendCountryRows
        .filter((row) => row.country === trendCountry)
        .map((row) => ({
          // Per chain, as on every other chart here, should a country without
          // regions ever have two.
          series: seriesOf(row),
          label: seriesLabel(row.country, row.brand, chains),
          day: toDay(row.d),
          median: perVolume(
            currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
            volume
          )
        }))
        .filter((row) => row.median != null);
      const figure = Plot.plot({
        width: chartWidth,
        ariaLabel: `${COUNTRY_NAMES[trendCountry] || trendCountry}: median ${GRADE_LABELS[grade]} over time`,
        height: 400,
        style: {background: "transparent", color: palette.text},
        x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(rows)},
        y: {label: `Median ${GRADE_LABELS[grade]}, ${unit} ${volumeLabel}`, grid: true, nice: true},
        marks: [
          Plot.line(rows, {
            x: "day",
            y: "median",
            z: "series",
            stroke: COUNTRY_COLORS[trendCountry],
            strokeWidth: 2
          }),
          Plot.dot(rows, {x: "day", y: "median", r: 2, fill: COUNTRY_COLORS[trendCountry]}),
          Plot.tip(
            rows,
            Plot.pointerX({
              x: "day",
              y: "median",
              ...tipStyle(),
              title: (row) =>
                `${row.label} · ${row.day.toISOString().slice(0, 10)}\n` +
                `median ${fmtNumber(row.median, priceDigits)} ${unit} ${volumeLabel}`
            })
          )
        ]
      });
      return html`<div>
        <div class="cgp-notice">No regional breakdown for ${COUNTRY_NAMES[trendCountry]};
          showing the national series.</div>
        ${figure}
      </div>`;
    }
    // tableau10 has ten colors. Asked for more series than that, Plot cycles
    // them silently and the legend then claims two regions are one -- five US
    // states would share every color. Cap the drawn set and say so, rather than
    // drawing a key that is not true.
    const asked = trendRegions;
    const drawn = asked.slice(0, PALETTE_SERIES_CAP);
    const chosen = new Set(drawn);
    // A region with two chains is two lines, the way a country is: keyed on the
    // region alone, one line zig-zagged between the two companies' prices. Both
    // take the region's color, and the second chain is dashed.
    const brands = chains.get(trendCountry) || new Set();
    const data = trendRegionRows
      .filter((row) => chosen.has(row.region))
      .map((row) => ({
        region: row.region,
        brand: row.brand,
        series: `${row.region} :: ${row.brand}`,
        label: placeLabel(row.region, trendCountry, row.brand, chains),
        day: toDay(row.d),
        median: perVolume(
          currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
          volume
        )
      }))
      .filter((row) => row.median != null);
    if (!data.length) return html`<div class="cgp-notice">Pick at least one region.</div>`;
    const figure = Plot.plot({
      width: chartWidth,
      ariaLabel: `${COUNTRY_NAMES[trendCountry] || trendCountry}: median ${GRADE_LABELS[grade]} by region`,
      height: 420,
      marginRight: 80,
      style: {background: "transparent", color: palette.text},
      // Quarto's OJS stdlib pins Plot 0.6.11, which has no `observable10`
      // ordinal scheme: naming it throws and turns the whole cell into an error.
      color: {legend: true, scheme: "tableau10"},
      x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(data)},
      y: {label: `Median ${GRADE_LABELS[grade]}, ${unit} ${volumeLabel}`, grid: true, nice: true},
      marks: [
        Plot.line(data.filter((row) => !row.brand || row.brand === PRIMARY_BRAND), {
          x: "day",
          y: "median",
          z: "series",
          stroke: "region",
          strokeWidth: 2
        }),
        Plot.line(data.filter((row) => row.brand && row.brand !== PRIMARY_BRAND), {
          x: "day",
          y: "median",
          z: "series",
          stroke: "region",
          strokeWidth: 2,
          strokeDasharray: "4 3"
        }),
        Plot.dot(data, {x: "day", y: "median", r: 2, fill: "region"}),
        Plot.tip(
          data,
          Plot.pointer({
            x: "day",
            y: "median",
            ...tipStyle(),
            title: (row) =>
              `${row.label} · ${row.day.toISOString().slice(0, 10)}\n` +
              `median ${fmtNumber(row.median, priceDigits)} ${unit} ${volumeLabel}`
          })
        )
      ]
    });
    // The color legend is spent on regions, so which line is which chain
    // needs a key of its own -- only where there are two to tell apart.
    const key = brands.size > 1 ? chainKeyEl(brands) : "";
    if (asked.length <= drawn.length && !key) return figure;
    const capped =
      asked.length <= drawn.length
        ? ""
        : html`<div class="cgp-notice">Showing the first
      ${drawn.length} of ${asked.length} regions you picked: the palette has
      ${PALETTE_SERIES_CAP} colors, and beyond that two regions would share
      one. Separate charts shows them all.</div>`;
    return html`<div>${key}${figure}${capped}</div>`;
  }

  if (!db) return html`<div class="cgp-notice">Loading the price database…</div>`;
  if (effectiveTrendMode === "separate") {
    return trendCountry === "All countries" ? allCountriesLocal() : regionBreakdown();
  }
  if (effectiveTrendMode === "change") return allCountriesChange();
  return trendCountry === "All countries" ? allCountriesUsd() : regionBreakdown();
}
// 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>`;
}