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

About

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

What this is

aboutHeader = html`<div>
  <p class="cgp-notice">Built ${meta.built_at_utc} from capture ${meta.capture_id}.
    Data: <a href=${meta.releases.current}>current release</a> ·
    <a href=${meta.releases.all}>all releases</a> ·
    <a href="https://github.com/coatless-data/club-gas-prices#schema-capture-grain-in-file-order">column
    reference</a>.</p>
</div>`

Prices are read from each chain’s public website for every station four times a day, open or not; runs can start a few hours late. Every reading is kept, and the charts use each station’s last reading of each UTC day. Each row keeps the published value, its unit and the exchange rate of the day it was read, so a past day shows that day’s USD value.

Country Endpoint, one request unless noted
United States ecom-api.costco.com/core/warehouse-locator/v1/warehouses.json for metadata, then www.costco.com/AjaxGetGasPricesService?warehouseid=… in batches of 10
Canada www.costco.ca/AjaxWarehouseBrowseLookupView?hasGas=true&populateWarehouseDetails=true&countryCode=CA
Mexico www.costco.com.mx/rest/v2/mexico/stores?fields=FULL&…
United Kingdom www.costco.co.uk/rest/v2/uk/stores?fields=FULL&…
Australia www.costco.com.au/rest/v2/australia/stores?fields=FULL&…
Japan www.costco.co.jp/rest/v2/japan/stores?fields=FULL&…
Taiwan www.costco.com.tw/rest/v2/taiwan/stores?fields=FULL&…

Grades

// Chain column appears only when grade rows carry a brand. The table scrolls
// horizontally on phones, with keyboard-accessible focus.
aboutGrades = {
  const rows = meta.grades || [];
  const withChain = rows.some((row) => row.brand);
  return html`<div class="cgp-table-scroll" role="region" aria-label="Grades" tabindex="0">
  <table class="cgp-table">
    <thead><tr><th>Country</th>${withChain ? html`<th>Chain</th>` : ""}<th>Source label</th>
      <th>Grade</th><th>Spec</th><th>Stated by</th></tr></thead>
    <tbody>${rows.map(
      (row) => html`<tr>
        <td>${COUNTRY_NAMES[row.country] || row.country}</td>
        ${withChain ? html`<td>${BRAND_NAMES[row.brand] || row.brand || "—"}</td>` : ""}
        <td>${row.grade_raw}</td>
        <td>${row.grade}</td>
        <td>${row.spec || "—"}</td>
        <td>${
          row.spec_source
            ? row.spec_source_url
              ? html`<a href=${row.spec_source_url}>${row.spec_source}</a>`
              : row.spec_source
            : "—"
        }</td>
      </tr>`
    )}</tbody>
  </table></div>`;
}

Grades are not equivalent across countries. UK and Australian diesel is premium diesel, and these are each chain’s own posted prices, not national averages. Where a source states no octane or blend the specification is blank; where it comes from a third party it is marked reported and links to that source.

Units, rates and caveats

Units. One US gallon is 3.785411784 litres. UK prices are published in pence, so GBp/L is divided by 100 for the pound figure. Puerto Rico’s prices are published in USD per litre: #335 regular was 1.097, about 4.15 per gallon, and Puerto Rico retail is priced per litre. That unit is inferred, not stated.

Exchange rates. Each capture fetches the daily reference rate from Frankfurter v2 and stores it with every row. If that fails, a dated @fawazahmed0/currency-api file is used; if that also fails, the most recent stored rate within 7 days is carried forward and labelled carried-forward:<source>. If no rate is available the USD columns are null: those stations are drawn gray on the map, show their local price only, and are left out of USD aggregates.

Filtering. Placeholder prices are dropped: stations that price before they open (the pre-opening 3.999 / 5.999 pattern in the United States, and Canadian warehouses with no gas hours), premium-only business-center placeholders, and values outside per-unit sanity bounds.

How fresh a price really is. A capture records when we read a price, not when the retailer changed it. The United Kingdom’s store feed is served with a 90-minute cache, and a re-request 73 seconds later returned a byte-identical response, so a UK price can be up to about an hour and a half old however often this runs. Every country’s feed is a published figure rather than a live reading from the pump.

UTC days versus local time. Days are UTC days. For Japan, Taiwan and Australia the last capture of a UTC day arrives early the next local morning, so local_date in the released rows can be a day ahead of capture_date.

Known limits. No source publishes a price timestamp, prices change during the day, and the storefront APIs declare CDN caching of up to 90 minutes. A chain’s posted price is not a national average.

Attribution

aboutAttribution = {
  const span = document.createElement("span");
  span.innerHTML = meta.basemap.attribution;
  return html`<div class="cgp-notice">Basemap: ${meta.basemap.provider}. ${span}</div>`;
}

Code is MIT licensed. The data files carry no license claim and include the notice at the foot of this page.

// 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>`;
}