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

Changes

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>`;
}
changesWindowDays = 90

// A reading's direction is measured from the one before it, so the queries
// read this far past the window to find the first day's.
changesLookbackDays = 14
viewof changesMode = radioControl(
  "changemode",
  [["Direction", "direction"], ["How often", "often"]],
  "direction",
  "Color by"
)

viewof changesPlace = asField(
  Inputs.select(["All US states", ...changesStates], {
    label: "Drill into",
    value: "All US states"
  })
)
// Visible chunk so the controls are reachable (output: false would hide them).
html`<div class="cgp-controls-row">${[viewof changesMode, viewof changesPlace]}</div>`
changesStates = {
  const seen = new Set();
  for (const station of filteredStations) {
    if (station.country === "US" && station.region) seen.add(station.region);
  }
  return [...seen].sort();
}

// State grain: one cell per (chain, state, day). `changed` says a price moved
// and not which way, so the rises and falls are counted here, against the same
// previous reading the flag was set from.
changesByState = db
  ? await db.query(`
      WITH priced AS (
        SELECT station_key, brand, capture_date, changed,
               price_local_per_litre - lag(price_local_per_litre) OVER (
                 PARTITION BY station_key ORDER BY capture_date
               ) AS delta
        FROM history
        WHERE grade = ${sqlText(grade)}${chainClause}
          AND capture_date > (SELECT max(capture_date) FROM history) - ${
            changesWindowDays + changesLookbackDays
          }
      ),
      scoped AS (
        SELECT p.brand, s.region, p.station_key, p.capture_date, p.changed, p.delta
        FROM priced p JOIN stations s ON s.station_key = p.station_key
        WHERE s.country = 'US' AND s.region IS NOT NULL
          AND p.capture_date > (SELECT max(capture_date) FROM history) - ${changesWindowDays}
      )
      SELECT brand,
             region,
             strftime(capture_date, '%Y-%m-%d') AS d,
             count(*) FILTER (WHERE changed IS NOT NULL)   AS n_obs,
             count(*) FILTER (WHERE changed)               AS n_changed,
             count(*) FILTER (WHERE changed AND delta > 0) AS n_up,
             count(*) FILTER (WHERE changed AND delta < 0) AS n_down,
             count(DISTINCT station_key)                   AS n_stations
      FROM scoped
      GROUP BY 1, 2, 3
      ORDER BY brand, region, d
    `)
  : []

// Station grain, for one state. station_key leads the parquet's sort order, so
// this prunes row groups rather than scanning.
changesStationKeys = {
  if (changesPlace === "All US states") return [];
  return filteredStations
    .filter((s) => s.country === "US" && s.region === changesPlace)
    .map((s) => s.station_key);
}

changesByStation = db && changesStationKeys.length
  ? await db.query(`
      WITH priced AS (
        SELECT station_key, brand, capture_date, changed,
               price_local_per_litre - lag(price_local_per_litre) OVER (
                 PARTITION BY station_key ORDER BY capture_date
               ) AS delta
        FROM history
        WHERE station_key IN (${sqlList(changesStationKeys)})
          AND grade = ${sqlText(grade)}
          AND capture_date > (SELECT max(capture_date) FROM history) - ${
            changesWindowDays + changesLookbackDays
          }
      )
      SELECT station_key,
             brand,
             strftime(capture_date, '%Y-%m-%d') AS d,
             count(*) FILTER (WHERE changed IS NOT NULL)   AS n_obs,
             count(*) FILTER (WHERE changed)               AS n_changed,
             count(*) FILTER (WHERE changed AND delta > 0) AS n_up,
             count(*) FILTER (WHERE changed AND delta < 0) AS n_down
      FROM priced
      WHERE capture_date > (SELECT max(capture_date) FROM history) - ${changesWindowDays}
      GROUP BY 1, 2, 3
      ORDER BY station_key, d
    `)
  : []
function changesDomain(rows) {
  const days = rows.map((r) => r.d).filter((d) => d);
  if (!days.length) return [];
  const first = new Date(days.reduce((a, b) => (a < b ? a : b)) + "T00:00:00Z");
  const last = new Date(days.reduce((a, b) => (a > b ? a : b)) + "T00:00:00Z");
  const out = [];
  for (let t = new Date(first); t <= last; t.setUTCDate(t.getUTCDate() + 1)) {
    out.push(t.toISOString().slice(0, 10));
  }
  return out;
}

// The narrowest a day's cell is drawn. With less room than this a day, days
// are binned into weeks: a phone has a third of a desktop's width, and less
// again once a state's station names take their margin.
CHANGES_MIN_BIN_PX = 5

// The Monday of a day's week, written as a day is, so a week's bin sorts and
// labels like one.
function weekStart(day) {
  const d = new Date(day + "T00:00:00Z");
  d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
  return d.toISOString().slice(0, 10);
}

// Sums counts (not means of rates) so thin days don't get equal weight.
function changesBinned(rows, keyName, maxBins) {
  const domain = changesDomain(rows);
  const weekly = domain.length > maxBins;
  const bins = new Map();
  for (const row of rows) {
    const bin = weekly ? weekStart(row.d) : row.d;
    const key = [row[keyName], row.brand, bin].join(" :: ");
    const cell = bins.get(key) || {
      key: row[keyName],
      brand: row.brand,
      bin,
      n_obs: 0,
      n_changed: 0,
      n_up: 0,
      n_down: 0,
      n_stations: 0
    };
    cell.n_obs += Number(row.n_obs) || 0;
    cell.n_changed += Number(row.n_changed) || 0;
    cell.n_up += Number(row.n_up) || 0;
    cell.n_down += Number(row.n_down) || 0;
    cell.n_stations = Math.max(cell.n_stations, Number(row.n_stations) || 0);
    bins.set(key, cell);
  }
  const cells = [...bins.values()].map((cell) => {
    // A cell is colored on whatever readings it has: a station's row has one
    // a day, and so does a state with one station. A floor of three left
    // both gray. Only a cell with nothing to compare has no color.
    const told = cell.n_obs > 0;
    return {
      ...cell,
      rate: told ? cell.n_changed / cell.n_obs : null,
      net: told ? (cell.n_up - cell.n_down) / cell.n_obs : null
    };
  });
  const binDomain = weekly ? [...new Set(domain.map(weekStart))] : domain;
  return {cells, binDomain, weekly};
}

// Five steps from every reading falling to every reading rising. The middle
// one takes a cell within a fifth of even: prices that held, or as many up as
// down.
function colorForNet(net, pal) {
  if (net == null || !isFinite(net)) return pal.missing;
  const size = Math.abs(net);
  const step = size < 0.2 ? 0 : size < 0.6 ? 1 : 2;
  return pal.diverging[2 + Math.sign(net) * step];
}

// Plot sets a label in 10px system-ui. Measured rather than guessed at, so a
// name shortened to fit its margin does fit it.
labelWidth = {
  const context = document.createElement("canvas").getContext("2d");
  context.font = "10px system-ui, sans-serif";
  return (text) => context.measureText(text).width;
}

function changesDay(day) {
  return new Date(day + "T00:00:00Z").toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    timeZone: "UTC"
  });
}

function changesCount(n, noun) {
  return `${n.toLocaleString()} ${noun}${n === 1 ? "" : "s"}`;
}

How the price moved

function changesChart(
  rows,
  keyName,
  {rowLabel, mode, perStation, labelOf = (key) => key}
) {
  if (!rows.length) return null;
  // A chain's chart is headed with its name, so a row need not end in it.
  const rowName = new Map();
  for (const row of rows) {
    const name = labelOf(row[keyName]) || row[keyName];
    const chain = BRAND_NAMES[row.brand];
    rowName.set(
      row[keyName],
      chain && name.endsWith(" " + chain) ? name.slice(0, -chain.length - 1) : name
    );
  }
  // The margin holds the longest name, up to 45% of the chart's width and
  // always leaving the plot 140px; a name longer than that is cut to fit.
  const widest = Math.max(0, ...[...rowName.values()].map(labelWidth));
  const left = Math.min(
    Math.max(56, Math.ceil(widest + 12)),
    Math.max(56, Math.min(chartWidth - 24 - 140, Math.round(chartWidth * 0.45)))
  );
  const rowText = new Map(
    [...rowName].map(([key, name]) => {
      let text = name;
      while (text.length > 1 && labelWidth(text) > left - 12) {
        text = text.slice(0, -2).trimEnd() + "…";
      }
      return [key, text];
    })
  );
  const plotWidth = chartWidth - left - 24;
  const {cells, binDomain, weekly} = changesBinned(
    rows,
    keyName,
    Math.max(14, Math.floor(plotWidth / CHANGES_MIN_BIN_PX))
  );
  if (!cells.length) return null;
  // Plot labels every value of a band scale, and a phone has room for five.
  // Counted back from the newest bin, so that one is always named.
  const stride = Math.ceil(binDomain.length / Math.max(2, Math.floor(plotWidth / 56)));
  const dateTicks = binDomain.filter((_, i) => (binDomain.length - 1 - i) % stride === 0);
  const fillOf = (c) =>
    mode === "often" ? colorForFraction(c.rate, palette) : colorForNet(c.net, palette);
  const tipOf = (c) => {
    const head = `${rowName.get(c.key)} · ${weekly ? "week of " : ""}${changesDay(c.bin)}`;
    if (!c.n_obs) return `${head}\nno earlier reading to compare`;
    // One station's one reading is said in words; a state's are counted, so
    // a color that rests on a single station says so.
    if (perStation && c.n_obs === 1) {
      const way = c.n_down ? "fell" : c.n_up ? "rose" : "unchanged";
      return `${head}\n${way} since the previous reading`;
    }
    const held = c.n_obs - c.n_changed;
    return (
      `${head}\n${c.n_down} fell · ${c.n_up} rose\n${held} unchanged\n` +
      `of ${changesCount(c.n_obs, "reading")}`
    );
  };
  // A tip opens to one side of its cell and is cut off at the chart's edge,
  // so its lines wrap short enough to fit beside a cell in the middle.
  const tipLine = Math.max(9, Math.min(20, Math.floor((chartWidth / 2 - 40) / 12)));
  const brands = [...new Set(cells.map((c) => c.brand))].sort();

  const charts = brands.map((brand) => {
    const mine = cells.filter((c) => c.brand === brand);
    // Each chain's rows are its own set, sorted by label then key.
    const keys = [...new Set(mine.map((c) => c.key))].sort((a, b) => {
      const [x, y] = [rowName.get(a), rowName.get(b)];
      return x < y ? -1 : x > y ? 1 : a < b ? -1 : a > b ? 1 : 0;
    });
    const top = 28;
    const bottom = 30;
    // Height = margins + one full pitch per row.
    const pitch = chartWidth < 520 ? 11 : 16;
    return Plot.plot({
      width: chartWidth,
      height: top + bottom + keys.length * pitch,
      marginLeft: left,
      marginRight: 24,
      marginTop: top,
      marginBottom: bottom,
      style: {background: "transparent", color: palette.text},
      x: {domain: binDomain, label: null, ticks: dateTicks, tickFormat: changesDay},
      y: {domain: keys, label: null, tickFormat: (key) => rowText.get(key)},
      ariaLabel: `${BRAND_NAMES[brand] || brand}: ${
        mode === "often" ? "how often" : "which way"
      } the posted ${GRADE_LABELS[grade]} price moved, by ${rowLabel}`,
      marks: [
        Plot.cell(mine, {
          x: "bin",
          y: "key",
          // A function fill gets an identity color scale, so these follow
          // Quarto's light/dark toggle the way the map's markers do.
          fill: fillOf,
          inset: 0.5
        }),
        // A tip rather than a `title`, which a touch screen never shows.
        Plot.tip(
          mine,
          Plot.pointer({x: "bin", y: "key", ...tipStyle(), lineWidth: tipLine, title: tipOf})
        ),
        Plot.text([BRAND_NAMES[brand] || brand], {
          frameAnchor: "top-left",
          dy: -14,
          fontWeight: 600,
          fill: palette.text
        })
      ]
    });
  });
  return {charts, weekly};
}

changesFigure = {
  if (!db) {
    return html`<div class="cgp-notice cgp-station-chart">Loading the price database…</div>`;
  }
  const drilled = changesPlace !== "All US states";
  const rows = drilled ? changesByStation : changesByState;
  if (!rows.length) {
    return html`<div class="cgp-notice">No ${GRADE_LABELS[grade]} history yet${
      drilled ? ` for ${changesPlace}` : ""
    }. This fills in as captures accumulate.</div>`;
  }
  const nameOf = new Map(filteredStations.map((s) => [s.station_key, s.name]));
  const drawn = changesChart(rows, drilled ? "station_key" : "region", {
    rowLabel: drilled ? "station" : "state",
    mode: changesMode,
    perStation: drilled,
    labelOf: drilled ? (key) => nameOf.get(key) || key : undefined
  });
  if (!drawn) return html`<div class="cgp-notice">Not enough readings yet.</div>`;
  // A station's cell for one day is one reading: it fell, held or rose.
  const single = drilled && !drawn.weekly;
  return html`<div>${changesKeyEl(changesMode, single)}${changesSummary}${drawn.charts}</div>`;
}

// Manual legend: Plot draws none for function fills, and tooltips are
// inaccessible on touch devices.
function changesKeyEl(mode, single) {
  const swatch = (color) =>
    html`<span class="cgp-swatch" style=${"background:" + color}></span>`;
  const missing = html`<span class="cgp-legend-missing">${swatch(
    palette.missing
  )} no earlier reading</span>`;
  if (mode === "often") {
    return html`<div class="cgp-legend cgp-changes-key">
      <span class="cgp-legend-end">0%</span>${palette.ramp.map(swatch)}<span
        class="cgp-legend-end">100%</span>
      <span class="cgp-legend-note">share of the readings in a cell where the posted price had
        moved since the station's previous reading</span>
      ${missing}
    </div>`;
  }
  const [fell, , flat, , rose] = palette.diverging;
  return html`<div class="cgp-legend cgp-changes-key">
    <span class="cgp-legend-end">${single ? "▼ Fell" : "▼ More fell"}</span>${(single
      ? [fell, flat, rose]
      : palette.diverging
    ).map(swatch)}<span class="cgp-legend-end">${single ? "Rose ▲" : "More rose ▲"}</span>
    <span class="cgp-legend-note">${
      single
        ? "whether the station's posted price fell, held or rose since its previous reading"
        : "the share of a cell's readings where the posted price rose since the station's " +
          "previous reading, less the share where it fell"
    }</span>
    ${missing}
  </div>`;
}
// One line a chain: how its readings split between a fall, a rise and no move.
changesSummary = {
  const rows = changesPlace === "All US states" ? changesByState : changesByStation;
  const totals = new Map();
  for (const row of rows) {
    const t = totals.get(row.brand) || {obs: 0, changed: 0, up: 0, down: 0};
    t.obs += Number(row.n_obs) || 0;
    t.changed += Number(row.n_changed) || 0;
    t.up += Number(row.n_up) || 0;
    t.down += Number(row.n_down) || 0;
    totals.set(row.brand, t);
  }
  const share = (n, t) => fmtShare((n / t.obs) * 100);
  const lines = [...totals.entries()]
    .sort()
    .filter(([, t]) => t.obs > 0)
    .map(
      ([brand, t]) =>
        html`<div>${BRAND_NAMES[brand] || brand}: ${share(t.down, t)} fell · ${share(
          t.up,
          t
        )} rose · ${share(t.obs - t.changed, t)} unchanged, of ${changesCount(
          t.obs,
          "reading"
        )}</div>`
    );
  return lines.length ? html`<div class="cgp-notice cgp-changes-summary">${lines}</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>`;
}