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));
});
}