Code, design and documents 7 files
src/components/AverageChart.astro +7 −48
@@ -53,5 +53,8 @@ function view(range, weighting) {
const line = `M0,${y(start)}` + steps.map((p) => `H${p.x}V${p.y}`).join("") + "H100";
const points = steps.filter((p) => p.change); // estimated changes move the line but get no dot
- return { range: range.id, weight: weighting.id, startYear, now, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
+ // for the seeker (the script is in Base.astro): the axis and the steps of the line; the entries
+ // it lists are read from the dots
+ const seek = JSON.stringify({ t0, t1, start, unit: "average", dp: 1, dots: true, y: [yMin, yMax], steps: steps.map((p) => [p.x, p.x, p.before, p.after]) });
+ return { seek, range: range.id, weight: weighting.id, startYear, now, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
}
@@ -85,5 +88,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
{v.ticks.map(({ t, y }) => <span style={`top:${y}%`}>{v.whole ? t : fmt(t)}</span>)}
</div>
- <div class="avg-plot">
+ <div class="avg-plot" data-seek={v.seek}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)}
@@ -96,5 +99,6 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
))}
- <div class="avg-tip" hidden><small></small><b></b><span></span></div>
+ <span class="c-seek" hidden></span>
+ <div class="c-seek-tip" hidden></div>
</div>
<div class="avg-x" aria-hidden="true">
@@ -122,48 +126,3 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
});
});
-
- chart?.querySelectorAll(".avg-plot").forEach((plot) => {
- const tip = plot.querySelector(".avg-tip");
- const dots = [...plot.querySelectorAll(".avg-dot")];
- const [date, title, avg] = tip.children;
- let active = null;
- const show = (dot) => {
- if (dot === active) return;
- active?.classList.remove("on");
- active = dot;
- dot.classList.add("on");
- date.textContent = dot.dataset.date;
- title.textContent = dot.dataset.title;
- avg.textContent = dot.dataset.avg;
- tip.hidden = false;
- // keep the tooltip inside the plot
- const box = plot.getBoundingClientRect();
- const d = dot.getBoundingClientRect();
- const cx = d.left + d.width / 2 - box.left;
- const half = tip.offsetWidth / 2;
- tip.style.left = `${Math.min(Math.max(cx, half), box.width - half)}px`;
- const above = d.top - box.top - tip.offsetHeight - 10;
- tip.style.top = `${above >= -30 ? above : d.bottom - box.top + 10}px`;
- };
- const hide = () => {
- active?.classList.remove("on");
- active = null;
- tip.hidden = true;
- };
- // pointer: the change nearest to the cursor along the time axis
- plot.addEventListener("pointermove", (e) => {
- let best = dots[0], dist = Infinity;
- for (const dot of dots) {
- const r = dot.getBoundingClientRect();
- const dx = Math.abs(r.left + r.width / 2 - e.clientX);
- if (dx < dist) { dist = dx; best = dot; }
- }
- show(best);
- });
- plot.addEventListener("pointerleave", hide);
- dots.forEach((dot) => {
- dot.addEventListener("focus", () => show(dot));
- dot.addEventListener("blur", hide);
- });
- });
</script>
src/components/CountryArticle.astro +2 −2
@@ -135,5 +135,5 @@ const circ = 2 * Math.PI * r;
{SHOW_PRICES && !sub && <PricePanel id={c.id} />}
{/* in the drawer the chart stays in this column; on the full page it moves under the breakdown */}
- {scored.length > 0 && <div class="chart-in-side"><CountryChart history={scored} /></div>}
+ {scored.length > 0 && <div class="chart-in-side"><CountryChart history={scored} context={history.filter((ch) => !ch.score)} /></div>}
</div>
@@ -195,5 +195,5 @@ const circ = 2 * Math.PI * r;
);
})}
- {scored.length > 0 && <div class="chart-in-main"><CountryChart history={scored} /></div>}
+ {scored.length > 0 && <div class="chart-in-main"><CountryChart history={scored} context={history.filter((ch) => !ch.score)} /></div>}
{group && (
src/components/CountryChart.astro +35 −5
@@ -1,8 +1,10 @@
---
-// A country's index score over time: a step line that moves at each of its recorded changes.
-// No script, so it also works when the article is loaded into the drawer.
+// A country's index score over time: a step line that moves at each of its recorded changes,
+// with a hollow mark for every history entry that moved no score. Hovering shows a seeker line
+// with the year, the score then and the entries at that point; the script for it is in
+// Base.astro, so it also works when the article is loaded into the drawer.
import { changeDate, dateTime, lastUpdated } from "../lib/countries.js";
-const { history } = Astro.props; // the country's changes, newest first
+const { history, context = [] } = Astro.props; // the country's score changes and its other history entries, newest first
const base = import.meta.env.BASE_URL;
const time = dateTime;
@@ -46,4 +48,27 @@ const firstCh = changes[0];
const startBelow = firstCh && firstCh.raw.after > firstCh.raw.before && startRaw / yMax >= 0.2;
// a dated change is a vertical step; an estimated one slopes across its window
+// the score at a place on the time axis (0–100), following the same line
+const rawAt = (px) => {
+ let v = startRaw;
+ for (const p of points) {
+ if (px >= p.x) v = p.ch.raw.after;
+ else { if (p.ch.estimate && px > p.x0) v = p.ch.raw.before + ((p.ch.raw.after - p.ch.raw.before) * (px - p.x0)) / (p.x - p.x0); break; }
+ }
+ return v;
+};
+// entries that moved no score sit on the line at their date
+const marks = [...context].reverse()
+ .filter((ch) => time(ch.date) >= t0 && time(ch.date) <= t1)
+ .map((ch) => ({ ch, x: x(time(ch.date)) }))
+ .map((m) => ({ ...m, y: y(rawAt(m.x)) }));
+// what the seeker needs: the axis, the steps of the line and every entry
+const seek = JSON.stringify({
+ t0, t1, start: startRaw,
+ steps: points.map((p) => [p.x0, p.x, p.ch.raw.before, p.ch.raw.after]),
+ events: [
+ ...points.map((p) => ({ x: p.x, d: changeDate(p.ch), t: p.ch.title, k: p.ch.kind.emoji, s: `${p.ch.score.before} → ${p.ch.score.after}` })),
+ ...marks.map((m) => ({ x: m.x, d: changeDate(m.ch), t: m.ch.title, k: m.ch.kind.emoji })),
+ ].sort((a, b) => a.x - b.x),
+});
const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L${p.x},${p.y}` : `H${p.x}V${p.y}`)).join("") + "H100";
---
@@ -54,5 +79,5 @@ const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L
{ticks.map((t) => <span style={`top:${y(t)}%`}>{t}</span>)}
</div>
- <div class="avg-plot">
+ <div class="avg-plot" data-seek={seek}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{ticks.map((t) => <line class="grid" x1="0" x2="100" y1={y(t)} y2={y(t)} />)}
@@ -62,9 +87,14 @@ const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L
{points.map((p) => (
<a class="avg-dot" href={`${base}history/${p.ch.id}/`} style={`left:${p.x}%;top:${p.y}%`}
- title={`${changeDate(p.ch)}: ${p.ch.title} (${p.ch.score.before} → ${p.ch.score.after})`}
aria-label={`${changeDate(p.ch)}: ${p.ch.title}. Score ${p.ch.score.before} to ${p.ch.score.after}.`}>
{p.label && <span class={`c-chart-val ${p.ch.raw.after < p.ch.raw.before ? "below" : ""}`} aria-hidden="true">{p.ch.score.after}</span>}
</a>
))}
+ {marks.map((m) => (
+ <a class="avg-dot nomove" href={`${base}history/${m.ch.id}/`} style={`left:${m.x}%;top:${m.y}%`}
+ aria-label={`${changeDate(m.ch)}: ${m.ch.title}. No change in score.`}></a>
+ ))}
+ <span class="c-seek" hidden></span>
+ <div class="c-seek-tip" hidden></div>
</div>
<div class="avg-x" aria-hidden="true">
src/layouts/Base.astro +68 −0
@@ -114,4 +114,72 @@ const schemas = jsonld ? [jsonld].flat() : [];
btn.textContent = open ? "Close all" : "Open all";
});
+ // "Score over time" on a country article and the world average on the home page: a vertical seeker follows the pointer and says
+ // what the score was then and which history entries sit at that point. It snaps to an entry
+ // that is close. Works in the drawer too, since it listens on the document.
+ var seekPlot = null;
+ function hideSeek() {
+ if (!seekPlot) return;
+ seekPlot.querySelector(".c-seek").hidden = seekPlot.querySelector(".c-seek-tip").hidden = true;
+ seekPlot.querySelectorAll(".avg-dot.on").forEach(function (dot) { dot.classList.remove("on"); });
+ seekPlot = null;
+ }
+ function seek(e) {
+ var plot = e.target.closest && e.target.closest(".avg-plot[data-seek]");
+ if (plot !== seekPlot) hideSeek();
+ if (!plot) return;
+ seekPlot = plot;
+ var data = plot._seek || (plot._seek = JSON.parse(plot.dataset.seek));
+ var dots = plot._dots || (plot._dots = Array.prototype.slice.call(plot.querySelectorAll(".avg-dot")));
+ // the world chart keeps its entries on the dots themselves
+ if (data.dots && !data.events) data.events = dots.map(function (dot) { return { x: parseFloat(dot.style.left), d: dot.dataset.date, t: dot.dataset.title, own: dot.dataset.avg }; });
+ var box = plot.getBoundingClientRect();
+ var px = Math.max(0, Math.min(100, ((e.clientX - box.left) / box.width) * 100));
+ // snap to the nearest entry within 7 pixels, and list the ones that share its place
+ var near = null;
+ data.events.forEach(function (ev) { if (!near || Math.abs(ev.x - px) < Math.abs(near.x - px)) near = ev; });
+ var here = [];
+ if (near && (Math.abs(near.x - px) * box.width) / 100 <= 7) {
+ px = near.x;
+ here = data.events.filter(function (ev) { return (Math.abs(ev.x - px) * box.width) / 100 <= (data.dots ? 1.5 : 4); }); // the world chart is dense
+ }
+ dots.forEach(function (dot) { dot.classList.toggle("on", here.length > 0 && Math.abs(parseFloat(dot.style.left) - px) < 0.001); });
+ var score = data.start;
+ for (var i = 0; i < data.steps.length; i++) {
+ var s = data.steps[i]; // [start of the step, end, score before, score after]
+ if (px >= s[1]) score = s[3];
+ else { if (px > s[0]) score = s[2] + ((s[3] - s[2]) * (px - s[0])) / (s[1] - s[0]); break; }
+ }
+ var year = new Date(data.t0 + ((data.t1 - data.t0) * px) / 100).getUTCFullYear();
+ var line = plot.querySelector(".c-seek"), tip = plot.querySelector(".c-seek-tip");
+ tip.replaceChildren();
+ var head = document.createElement("b");
+ head.textContent = year + " · " + (data.unit || "score") + " " + (data.dp ? score.toFixed(data.dp) : Math.round(score));
+ tip.append(head);
+ here.slice(0, data.dots ? 3 : 4).forEach(function (ev) {
+ var p = document.createElement("p"), when = document.createElement("small");
+ when.textContent = ev.d + " · " + (ev.own || (ev.s ? "score " + ev.s : "no change in score"));
+ p.append(when, (ev.k ? ev.k + " " : "") + ev.t);
+ tip.append(p);
+ });
+ if (here.length > (data.dots ? 3 : 4)) {
+ var more = document.createElement("p");
+ more.textContent = "and " + (here.length - (data.dots ? 3 : 4)) + " more";
+ tip.append(more);
+ }
+ line.style.left = px + "%";
+ tip.style.left = px + "%";
+ tip.style.transform = "translateX(-" + px + "%)"; // slides from left- to right-aligned, so it stays inside
+ if (data.y) {
+ // a tall plot: keep the box inside it, under the line where the line runs high and above it elsewhere
+ var at = (1 - (score - data.y[0]) / (data.y[1] - data.y[0])) * 100;
+ tip.style.top = at < 50 ? "calc(" + at + "% + 12px)" : "auto";
+ tip.style.bottom = at < 50 ? "auto" : "calc(" + (100 - at) + "% + 12px)";
+ }
+ line.hidden = tip.hidden = false;
+ }
+ document.addEventListener("pointermove", seek);
+ document.addEventListener("pointerdown", seek);
+ document.addEventListener("pointerleave", hideSeek);
+
// A link straight to a segment, e.g. /country/de/#seg-possession
if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
src/lib/countries.js +11 −0
@@ -292,4 +292,15 @@ export const changeDate = (ch) => (ch.estimate ? `Between ${ch.window[0]} and ${
+// How far a country's score moved over the last `years` years: { delta, since, last } or null
+// when nothing moved it. `last` is the most recent change in that window.
+export const TREND_YEARS = 10;
+export function trendOf(c, years = TREND_YEARS) {
+ const since = +lastUpdated.slice(0, 4) - years;
+ const moved = changes.filter((ch) => ch.country === c && ch.score && !ch.estimate && +ch.date.slice(0, 4) >= since); // newest first
+ if (!moved.length) return null;
+ const delta = Math.round(c.raw - moved.at(-1).raw.before);
+ return delta ? { delta, since, last: moved[0] } : null;
+}
+
// The most recent review date across every country and state file.
export const lastUpdated = [
src/pages/index.astro +3 −2
@@ -3,5 +3,5 @@ import Base from "../layouts/Base.astro";
import DidYouKnow from "../components/DidYouKnow.astro";
import Changes from "../components/Changes.astro";
-import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, REC_ORDER, REC_HELP, MED_LABEL, MED_HELP, countries, regions, worldAvg, lastUpdated, formatDate, subunitsOf, places } from "../lib/countries.js";
+import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, REC_ORDER, REC_HELP, MED_LABEL, MED_HELP, countries, regions, worldAvg, lastUpdated, formatDate, subunitsOf, places, trendOf, changeDate, TREND_YEARS } from "../lib/countries.js";
import { TIERS, tierColor } from "../lib/tiers.js";
import { PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "../lib/segment-ids.js";
@@ -251,5 +251,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
{...Object.fromEntries(EXTRA_COLUMNS.map((col) => [`data-${col.key}`, col.sort(c)]))}>
<td class="num">{c.rank ?? "–"}</td>
- <td><a class="cname" href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span>{c.name}</a>{c.disputed && <span class="tag-disputed" title={c.disputed.note}>disputed</span>}</td>
+ <td><a class="cname" href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span>{c.name}{trendOf(c) && ((t) => <span class={`trend ${t.delta > 0 ? "up" : "down"}`} title={`${t.delta > 0 ? "+" : "−"}${Math.abs(t.delta)} ${Math.abs(t.delta) === 1 ? "point" : "points"} since ${t.since}. Latest change: ${t.last.title} (${changeDate(t.last)})`}>{t.delta > 0 ? "▲" : "▼"}{Math.abs(t.delta)}</span>)(trendOf(c))}</a>{c.disputed && <span class="tag-disputed" title={c.disputed.note}>disputed</span>}</td>
<td class="hide-sm" data-col="region">{c.region}</td>
<td class="num-col hide-md" data-col="users" title={useNote(c)}>{c.use.estimate && <span class="approx">~</span>}{compact(c.use.users)}</td>
@@ -281,4 +281,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
</table>
</div>
+ <p class="table-updated trend-note"><span class="trend up">▲</span> <span class="trend down">▼</span> after a name: points the score gained or lost since {+lastUpdated.slice(0, 4) - TREND_YEARS}. No mark means it did not move. Hover a mark to see the latest change.</p>
<p class="table-updated">Last updated: <time datetime={lastUpdated}>{formatDate(lastUpdated)}</time></p>
</section>
src/styles/global.css +15 −5
@@ -188,4 +188,10 @@ td.num, th.num { width: 52px; color: var(--muted); }
.cname { display: flex; align-items: center; gap: 10px; font-weight: 500; }
.flag { font-size: 20px; line-height: 1; }
+/* how far the score moved over the last ten years, after the country's name */
+.trend { margin-left: -3px; font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; cursor: help; }
+.table-updated.trend-note { text-align: left; }
+.trend-note .trend { margin: 0; cursor: default; }
+.trend.up { color: var(--t5); }
+.trend.down { color: var(--t1); }
.score-col { width: 34%; min-width: 150px; }
.score-cell { display: flex; align-items: center; gap: 12px; }
@@ -560,9 +566,4 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
.avg .avg-dot::after { opacity: 0; }
.avg .avg-dot.on::after, .avg .avg-dot:focus-visible::after { opacity: 1; }
-.avg-tip { position: absolute; z-index: 2; transform: translateX(-50%); width: max-content; max-width: min(260px, 100%); padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); box-shadow: var(--shadow); pointer-events: none; font-size: 13px; line-height: 1.35; }
-.avg-tip[hidden] { display: none; }
-.avg-tip small { display: block; color: var(--muted); }
-.avg-tip b { display: block; }
-.avg-tip span { color: var(--text-2); font-variant-numeric: tabular-nums; }
.avg-note { margin: 12px 0 0; color: var(--muted); font-size: 12.5px; }
@media (max-width: 640px) { .avg-x .odd { display: none; } }
@@ -583,4 +584,13 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
.c-chart-val.below { bottom: auto; top: 100%; transform: translate(-50%, -5px); }
.c-chart-val.start.below { top: auto; transform: translateY(4px); }
+/* history entries that moved no score: hollow marks on the line */
+.avg-dot.nomove { width: 16px; height: 16px; margin: -8px 0 0 -8px; }
+.avg-dot.nomove::after { inset: 5px; background: var(--surface); box-shadow: 0 0 0 1.5px var(--muted); }
+/* the seeker: a vertical line under the pointer, with what happened there */
+.c-seek { position: absolute; top: 0; bottom: 0; width: 1px; margin-left: -.5px; background: var(--muted); pointer-events: none; }
+.c-seek-tip { position: absolute; bottom: calc(100% + 6px); z-index: 3; width: max-content; max-width: min(260px, 100%); padding: 7px 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); box-shadow: 0 6px 18px rgb(0 0 0 / .18); color: var(--text-2); font-size: 12px; line-height: 1.4; pointer-events: none; }
+.c-seek-tip b { color: var(--text); font-variant-numeric: tabular-nums; }
+.c-seek-tip p { margin: 4px 0 0; }
+.c-seek-tip small { display: block; color: var(--muted); font-size: 11px; }
.c-chart-val.start { left: 4px; bottom: auto; transform: translateY(calc(-100% - 4px)); color: var(--text-2); font-weight: 500; }