function coverageStrip(rows, {label, chart}) {
const svg = chart.tagName.toLowerCase() === "svg" ? chart : chart.querySelector(":scope > svg");
const width = Number(svg.getAttribute("width"));
const x = chart.scale("x");
const [first, last] = x.domain;
const byDay = new Map();
for (const row of rows) {
// Key on day + series so every chain in every country gets a point.
const series = seriesOf(row);
byDay.set(`${row.d} :: ${series}`, {
day: toDay(row.d),
brand: row.brand,
series,
country: row.country,
n: Number(row.n_stations) || 0
});
}
// A day the chart has no value for, such as one a currency had no rate on,
// would be drawn out in the margins, off the shared axis.
const data = [...byDay.values()].filter((row) => row.day >= first && row.day <= last);
if (data.length < 2) return "";
return Plot.plot({
width,
// "posted a price" not "open" — dips are usually collection failures.
ariaLabel: `Stations that posted a ${label} price, by day`,
// Two-line day ticks need more than Plot's default bottom margin.
height: 82,
marginLeft: x.range[0],
marginRight: width - x.range[1],
marginBottom: 34,
style: {background: "transparent", color: palette.text},
x: {label: null, grid: false, domain: x.domain, ticks: dayTicks(x.domain)},
y: {label: "stations", grid: true, nice: true, ticks: 3},
marks: [
Plot.ruleY([0], {stroke: palette.text, strokeOpacity: 0.2}),
Plot.line(data, {
x: "day",
y: "n",
z: "series",
curve: "step",
stroke: (row) => COUNTRY_COLORS[row.country],
strokeWidth: 1.5
})
]
});
}
// Charts are built in the card that shows them: OJS inserts a cell's node
// where it's defined, and a hidden chunk would produce a zero-width chart.
trendView = {
// Which chains each country has, so every chart below names a chain by the
// same rule, and only where a country has two.
const chains = chainsByCountry(trendCountryRows);
function allCountriesUsd() {
const data = trendCountryRows
.filter((row) => row.median_usd_per_litre != null)
.map((row) => ({
country: row.country,
brand: row.brand,
// The line's identity is the CHAIN in a country, not the country. Two
// chains share a country, and a series keyed on country alone would
// draw one line zig-zagging between two different companies' prices.
series: row.brand ? `${row.country}-${row.brand}` : row.country,
label: seriesLabel(row.country, row.brand, chains),
day: toDay(row.d),
median: perVolume(row.median_usd_per_litre, volume),
p25: perVolume(row.p25_usd_per_litre, volume),
p75: perVolume(row.p75_usd_per_litre, volume)
}));
if (!data.length) {
return html`<div class="cgp-notice">No USD values for ${GRADE_LABELS[grade]}.</div>`;
}
const days = [...new Set(data.map((row) => row.day.getTime()))].sort();
const ends = endLabelSpace(data.map((row) => row.label));
const marks = [];
if (showBand) {
marks.push(
Plot.areaY(data, {
x: "day",
y1: "p25",
y2: "p75",
z: "series",
fill: (row) => COUNTRY_COLORS[row.country],
fillOpacity: 0.14
})
);
}
marks.push(
Plot.line(data.filter((row) => !row.brand || row.brand === PRIMARY_BRAND), {
x: "day",
y: "median",
z: "series",
stroke: (row) => COUNTRY_COLORS[row.country],
strokeWidth: 2
}),
Plot.line(data.filter((row) => row.brand && row.brand !== PRIMARY_BRAND), {
x: "day",
y: "median",
z: "series",
stroke: (row) => COUNTRY_COLORS[row.country],
strokeWidth: 2,
strokeDasharray: "4 3"
}),
...(showDots(days.length)
? [Plot.dot(data, {x: "day", y: "median", r: 2, fill: (row) => COUNTRY_COLORS[row.country]})]
: []),
// selectLast per series so a stalled feed keeps its label.
Plot.text(
data,
Plot.selectLast({
x: "day",
y: "median",
z: "series",
text: (row) => ends.text(row.label),
dx: 6,
textAnchor: "start",
lineWidth: ends.lineWidth,
fill: (row) => COUNTRY_COLORS[row.country],
ariaDescription: END_LABELS
})
),
// Last, so it paints over the country labels rather than under them.
Plot.tip(
data,
Plot.pointer({
x: "day",
y: "median",
...tipStyle(),
title: (row) =>
`${row.label} · ${row.day.toISOString().slice(0, 10)}\n` +
`median ${fmtNumber(row.median, priceDigits)} USD ${volumeLabel}\n` +
`p25 ${fmtNumber(row.p25, priceDigits)} · p75 ${fmtNumber(row.p75, priceDigits)}`
})
)
);
const figure = Plot.plot({
width: chartWidth,
ariaLabel: `Median ${GRADE_LABELS[grade]} price over time, by country`,
height: 420,
marginRight: ends.marginRight,
style: {background: "transparent", color: palette.text},
x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(days)},
y: {label: `Median ${GRADE_LABELS[grade]}, USD ${volumeLabel}`, grid: true, nice: true},
marks
});
spreadEndLabels(figure);
return html`<div>${figure}${coverageStrip(trendCountryRows, {
label: GRADE_LABELS[grade],
chart: figure
})}<div class="cgp-notice">${coverageNote(
days.length,
dayText(new Date(days[0])),
dayText(new Date(days[days.length - 1])),
latestStationCount(trendCountryRows)
)}</div></div>`;
}
// Change view: the unit cancels (same-currency ratio), so it works in
// either currency.
function allCountriesChange() {
const byCountry = new Map();
for (const row of trendCountryRows) {
const value = perVolume(
currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
volume
);
if (value == null) continue;
const seriesKey = row.brand ? `${row.country}-${row.brand}` : row.country;
if (!byCountry.has(seriesKey)) byCountry.set(seriesKey, []);
byCountry.get(seriesKey).push({
country: row.country,
brand: row.brand,
day: toDay(row.d),
value,
p25: perVolume(
currency === "USD" ? row.p25_usd_per_litre : row.p25_local_per_litre,
volume
),
p75: perVolume(
currency === "USD" ? row.p75_usd_per_litre : row.p75_local_per_litre,
volume
)
});
}
const series = [...byCountry.values()];
const baseDay = commonBaseDay(series);
if (!baseDay) {
return html`<div class="cgp-notice">Not enough history yet for
${GRADE_LABELS[grade]}.</div>`;
}
const data = [];
for (const [code, rows] of byCountry) {
const anchor = anchorRow(rows, baseDay);
if (!anchor) continue;
const rebased = anchor.day.getTime() !== baseDay.getTime();
for (const row of rows) {
const pct = pctFrom(row.value, anchor.value);
if (pct == null) continue;
data.push({
country: row.country,
brand: row.brand,
series: code,
label: seriesLabel(row.country, row.brand, chains),
day: row.day,
pct,
p25: pctFrom(row.p25, anchor.value),
p75: pctFrom(row.p75, anchor.value),
price: row.value,
anchorPrice: anchor.value,
anchorDay: dayText(anchor.day),
// By the row's country: `code` is the series, which carries the
// chain as well, and currencyOf is keyed on the country alone.
unit: currency === "USD" ? "USD" : currencyOf.get(row.country) || "local",
rebased
});
}
}
if (!data.length) {
return html`<div class="cgp-notice">No values for ${GRADE_LABELS[grade]}.</div>`;
}
const baseText = dayText(baseDay);
const days = [...new Set(data.map((row) => row.day.getTime()))].sort();
const ends = endLabelSpace(data.map((row) => row.label));
const marks = [Plot.ruleY([0], {stroke: palette.grid})];
if (showBand) {
marks.push(
Plot.areaY(
data.filter((row) => row.p25 != null && row.p75 != null),
{
x: "day",
y1: "p25",
y2: "p75",
z: "series",
fill: (row) => COUNTRY_COLORS[row.country],
fillOpacity: 0.12
}
)
);
}
marks.push(
Plot.line(data.filter((row) => !row.brand || row.brand === PRIMARY_BRAND), {
x: "day",
y: "pct",
z: "series",
stroke: (row) => COUNTRY_COLORS[row.country],
strokeWidth: 2
}),
Plot.line(data.filter((row) => row.brand && row.brand !== PRIMARY_BRAND), {
x: "day",
y: "pct",
z: "series",
stroke: (row) => COUNTRY_COLORS[row.country],
strokeWidth: 2,
strokeDasharray: "4 3"
}),
...(showDots(days.length)
? [Plot.dot(data, {x: "day", y: "pct", r: 2, fill: (row) => COUNTRY_COLORS[row.country]})]
: []),
Plot.text(
data,
Plot.selectLast({
x: "day",
y: "pct",
z: "series",
text: (row) => ends.text(row.label),
dx: 6,
textAnchor: "start",
lineWidth: ends.lineWidth,
fill: (row) => COUNTRY_COLORS[row.country],
ariaDescription: END_LABELS
})
),
Plot.tip(
data,
Plot.pointer({
x: "day",
y: "pct",
...tipStyle(),
title: (row) =>
`${row.label} · ${dayText(row.day)}\n` +
`${fmtPct(row.pct)} since ${row.anchorDay}${
row.rebased ? " (its own first day)" : ""
}\n` +
`${fmtNumber(row.price, priceDigits)} ${row.unit}${volumeShort} that day\n` +
`${fmtNumber(row.anchorPrice, priceDigits)} ${row.unit}${volumeShort} on ${
row.anchorDay
}`
})
)
);
// Short label on narrow screens — the grade is already in the control bar.
const label =
currency === "USD"
? `Change in median ${GRADE_LABELS[grade]} since ${baseText}, in USD (%)`
: chartWidth < 500
? `Change since ${baseText}, each in its own currency (%)`
: `Change in median ${GRADE_LABELS[grade]} since ${baseText}, each country in its own currency (%)`;
const figure = Plot.plot({
width: chartWidth,
ariaLabel: `Change in median ${GRADE_LABELS[grade]} since ${baseText}, by country`,
height: 400,
// "+20.0%" is wider than Plot's 40px default gutter, which cut the sign
// off every tick above zero.
marginLeft: 52,
marginRight: ends.marginRight,
style: {background: "transparent", color: palette.text},
x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(days)},
y: {label, grid: true, nice: true, tickFormat: fmtPct},
marks
});
spreadEndLabels(figure);
return html`<div>${figure}${coverageStrip(trendCountryRows, {
label: GRADE_LABELS[grade],
chart: figure
})}<div class="cgp-notice">${coverageNote(
days.length,
dayText(new Date(days[0])),
dayText(new Date(days[days.length - 1])),
latestStationCount(trendCountryRows)
)}</div></div>`;
}
function allCountriesLocal() {
const byCountry = new Map();
for (const row of trendCountryRows) {
if (row.median_local_per_litre == null) continue;
if (!byCountry.has(row.country)) byCountry.set(row.country, []);
byCountry.get(row.country).push({
country: row.country,
brand: row.brand,
series: row.brand ? `${row.country}-${row.brand}` : row.country,
day: toDay(row.d),
median: perVolume(row.median_local_per_litre, volume),
p25: perVolume(row.p25_local_per_litre, volume),
p75: perVolume(row.p75_local_per_litre, volume)
});
}
const panels = COUNTRY_ORDER.filter((code) => byCountry.has(code)).map((code) => {
const rows = byCountry.get(code);
const marks = [];
if (showBand) {
marks.push(
// One band per chain. Without `z` the two chains' rows became one
// polygon that ran to the last day and doubled back over the first.
Plot.areaY(rows, {
x: "day",
y1: "p25",
y2: "p75",
z: "series",
fill: COUNTRY_COLORS[code],
fillOpacity: 0.16
})
);
}
const unit = `${currencyOf.get(code) || ""} ${volumeShort}`;
marks.push(
Plot.line(rows.filter((r) => !r.brand || r.brand === PRIMARY_BRAND), {
x: "day",
y: "median",
z: "series",
stroke: COUNTRY_COLORS[code],
strokeWidth: 2
}),
Plot.line(rows.filter((r) => r.brand && r.brand !== PRIMARY_BRAND), {
x: "day",
y: "median",
z: "series",
stroke: COUNTRY_COLORS[code],
strokeWidth: 2,
strokeDasharray: "4 3"
}),
Plot.dot(rows, {x: "day", y: "median", r: 2, fill: COUNTRY_COLORS[code]}),
Plot.tip(
rows,
Plot.pointerX({
x: "day",
y: "median",
...tipStyle(),
title: (row) =>
`${seriesLabel(code, row.brand, chains)} · ${dayText(row.day)}\n` +
`median ${fmtNumber(row.median, priceDigits)} ${unit}\n` +
`p25 ${fmtNumber(row.p25, priceDigits)} · p75 ${fmtNumber(row.p75, priceDigits)}`
})
)
);
const figure = Plot.plot({
// A panel, not the card: without an explicit width Quarto's autosizing
// gives every one of these the full card width, and the
// `.cgp-small-multiples` grid (columns of at least 240 px) collapses
// to a single column. An explicit width wins because the injected one
// is prepended to this object.
width: 240,
ariaLabel: `${COUNTRY_NAMES[code] || code}: median ${GRADE_LABELS[grade]} over time, ${unit}`,
height: 190,
marginLeft: 52,
style: {background: "transparent", color: palette.text},
x: {label: null, ticks: dayTicks(rows, 240), grid: true},
y: {label: `${currencyOf.get(code) || ""} ${volumeShort}`, grid: true, nice: true},
marks
});
return html`<div><div class="cgp-stale">${COUNTRY_NAMES[code]}</div>${figure}</div>`;
});
if (!panels.length) {
return html`<div class="cgp-notice">No local values for ${GRADE_LABELS[grade]}.</div>`;
}
return html`<div class="cgp-small-multiples">${panels}</div>`;
}
function regionBreakdown() {
const unit = currency === "USD" ? "USD" : currencyOf.get(trendCountry) || "local";
if (!trendRegionOptions.length) {
const rows = trendCountryRows
.filter((row) => row.country === trendCountry)
.map((row) => ({
// Per chain, as on every other chart here, should a country without
// regions ever have two.
series: seriesOf(row),
label: seriesLabel(row.country, row.brand, chains),
day: toDay(row.d),
median: perVolume(
currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
volume
)
}))
.filter((row) => row.median != null);
const figure = Plot.plot({
width: chartWidth,
ariaLabel: `${COUNTRY_NAMES[trendCountry] || trendCountry}: median ${GRADE_LABELS[grade]} over time`,
height: 400,
style: {background: "transparent", color: palette.text},
x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(rows)},
y: {label: `Median ${GRADE_LABELS[grade]}, ${unit} ${volumeLabel}`, grid: true, nice: true},
marks: [
Plot.line(rows, {
x: "day",
y: "median",
z: "series",
stroke: COUNTRY_COLORS[trendCountry],
strokeWidth: 2
}),
Plot.dot(rows, {x: "day", y: "median", r: 2, fill: COUNTRY_COLORS[trendCountry]}),
Plot.tip(
rows,
Plot.pointerX({
x: "day",
y: "median",
...tipStyle(),
title: (row) =>
`${row.label} · ${row.day.toISOString().slice(0, 10)}\n` +
`median ${fmtNumber(row.median, priceDigits)} ${unit} ${volumeLabel}`
})
)
]
});
return html`<div>
<div class="cgp-notice">No regional breakdown for ${COUNTRY_NAMES[trendCountry]};
showing the national series.</div>
${figure}
</div>`;
}
// tableau10 has ten colors. Asked for more series than that, Plot cycles
// them silently and the legend then claims two regions are one -- five US
// states would share every color. Cap the drawn set and say so, rather than
// drawing a key that is not true.
const asked = trendRegions;
const drawn = asked.slice(0, PALETTE_SERIES_CAP);
const chosen = new Set(drawn);
// A region with two chains is two lines, the way a country is: keyed on the
// region alone, one line zig-zagged between the two companies' prices. Both
// take the region's color, and the second chain is dashed.
const brands = chains.get(trendCountry) || new Set();
const data = trendRegionRows
.filter((row) => chosen.has(row.region))
.map((row) => ({
region: row.region,
brand: row.brand,
series: `${row.region} :: ${row.brand}`,
label: placeLabel(row.region, trendCountry, row.brand, chains),
day: toDay(row.d),
median: perVolume(
currency === "USD" ? row.median_usd_per_litre : row.median_local_per_litre,
volume
)
}))
.filter((row) => row.median != null);
if (!data.length) return html`<div class="cgp-notice">Pick at least one region.</div>`;
const figure = Plot.plot({
width: chartWidth,
ariaLabel: `${COUNTRY_NAMES[trendCountry] || trendCountry}: median ${GRADE_LABELS[grade]} by region`,
height: 420,
marginRight: 80,
style: {background: "transparent", color: palette.text},
// Quarto's OJS stdlib pins Plot 0.6.11, which has no `observable10`
// ordinal scheme: naming it throws and turns the whole cell into an error.
color: {legend: true, scheme: "tableau10"},
x: {label: "Capture date (UTC)", grid: true, ticks: dayTicks(data)},
y: {label: `Median ${GRADE_LABELS[grade]}, ${unit} ${volumeLabel}`, grid: true, nice: true},
marks: [
Plot.line(data.filter((row) => !row.brand || row.brand === PRIMARY_BRAND), {
x: "day",
y: "median",
z: "series",
stroke: "region",
strokeWidth: 2
}),
Plot.line(data.filter((row) => row.brand && row.brand !== PRIMARY_BRAND), {
x: "day",
y: "median",
z: "series",
stroke: "region",
strokeWidth: 2,
strokeDasharray: "4 3"
}),
Plot.dot(data, {x: "day", y: "median", r: 2, fill: "region"}),
Plot.tip(
data,
Plot.pointer({
x: "day",
y: "median",
...tipStyle(),
title: (row) =>
`${row.label} · ${row.day.toISOString().slice(0, 10)}\n` +
`median ${fmtNumber(row.median, priceDigits)} ${unit} ${volumeLabel}`
})
)
]
});
// The color legend is spent on regions, so which line is which chain
// needs a key of its own -- only where there are two to tell apart.
const key = brands.size > 1 ? chainKeyEl(brands) : "";
if (asked.length <= drawn.length && !key) return figure;
const capped =
asked.length <= drawn.length
? ""
: html`<div class="cgp-notice">Showing the first
${drawn.length} of ${asked.length} regions you picked: the palette has
${PALETTE_SERIES_CAP} colors, and beyond that two regions would share
one. Separate charts shows them all.</div>`;
return html`<div>${key}${figure}${capped}</div>`;
}
if (!db) return html`<div class="cgp-notice">Loading the price database…</div>`;
if (effectiveTrendMode === "separate") {
return trendCountry === "All countries" ? allCountriesLocal() : regionBreakdown();
}
if (effectiveTrendMode === "change") return allCountriesChange();
return trendCountry === "All countries" ? allCountriesUsd() : regionBreakdown();
}