Changelog 6322b7c
Map a federation's states or provinces above their list; filter history by countries in place
A federation's profile now draws its states, provinces or territories coloured by score above the ranked list, each linked to its profile (in the home page's drawer too), with the current one outlined on a state's own page. (#99) History's country control no longer leaves for a country's profile: it narrows the list in place to one or more countries, shown as removable chips with a Clear button and kept in the address as ?country=NL,DE. The continuous list loads only the chosen countries' years, from a small per-country year index; decade, kind and tag pages filter what they hold, and their links keep the choice. (#104) Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd
6 files of code, design or docs +262 −18 lines
Code, design and documents 6 files
src/components/CountryArticle.astro +25 −1
@@ -3,5 +3,6 @@ // drawer on the home page — so there is a single template for both views. import { useAges, SEGMENTS, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP, countries, changes, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js"; -import { tierOf, tierColor } from "../lib/tiers.js"; +import { TIERS, tierOf, tierColor, tierIndex } from "../lib/tiers.js"; +import { unitMap } from "../lib/unit-map.js"; import CountryChanges from "./CountryChanges.astro"; import CountryChart from "./CountryChart.astro"; @@ -32,4 +33,8 @@ const unitUrl = (x) => `${countryUrl}${x.id}/`; const updated = sub ? sub.reviewed : d?.reviewed; const updatedText = updated && formatDate(updated); +// the states or provinces on a map, coloured by score, above their list +const map = group ? unitMap(c.slug, group.units) : null; +// the tiers that appear on it, best first, for its legend +const mapTiers = map ? TIERS.filter((t, i) => group.units.some((x) => tierIndex(x.raw) === TIERS.length - 1 - i)) : []; const byName = group ? [...group.units].sort((a, b) => a.name.localeCompare(b.name)) : []; // the spread of the states' or provinces' scores, shown beside the national estimate @@ -219,4 +224,23 @@ const culture = cultureOf(c); <h2 class="d-h">{sub ? `Other ${group.plural}` : `By ${group.label.toLowerCase()}`} <span>{group.units.length} scored · national estimate {c.score}</span></h2> {!sub && <p class="d-hint">{group.intro}</p>} + {map && ( + <figure class="unit-map"> + <svg viewBox={map.viewBox} role="group" aria-label={`Map of ${c.name}: ${group.plural} coloured by score`}> + {map.shapes.map(({ unit: x, d }) => ( + <a href={unitUrl(x)} data-subunit class={sub?.id === x.id ? "current" : undefined} aria-label={`${x.name}: ${x.score}`}> + <path d={d} style={`fill:${tierColor(x.raw)}`}><title>{x.name}: {x.score} · {tierOf(x.raw).name}</title></path> + </a> + ))} + {map.dots.map(({ unit: x, x: cx, y: cy }) => ( + <a href={unitUrl(x)} data-subunit class={sub?.id === x.id ? "current" : undefined} aria-hidden="true" tabindex="-1"> + <circle cx={cx} cy={cy} r="5" style={`fill:${tierColor(x.raw)}`}><title>{x.name}: {x.score} · {tierOf(x.raw).name}</title></circle> + </a> + ))} + </svg> + <figcaption class="unit-legend"> + {mapTiers.map((t) => <span><i style={`background:${tierColor(t.min)}`}></i>{t.name}</span>)} + </figcaption> + </figure> + )} <ol class="units"> {group.units.map((x) => (
src/components/HistoryList.astro +160 −16
@@ -89,12 +89,16 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; <span class="hf-label">Country</span> <div> - <button type="button" class="hf-toggle" aria-expanded="false" aria-controls="histCountry" data-show="Choose a country">Choose a country</button> - {/* a country's history lives on its profile, with the same filters for that country alone */} + <button type="button" class="hf-toggle" aria-expanded="false" aria-controls="histCountry" data-show="Choose countries">Choose countries</button> + {/* narrows the list in place to one or more countries, kept in the address as ?country=NL,DE */} <div id="histCountry" class="hist-country" hidden> - <select aria-label="A country's history" data-history-country> - <option value="">Choose…</option> - {[...perPlace].sort((x, y) => x[0].name.localeCompare(y[0].name)).map(([c, n]) => <option value={`${base}country/${c.slug}/#history`}>{c.flag} {c.name} · {n}</option>)} + <select aria-label="Add a country to the filter" data-history-country> + <option value="">Add a country…</option> + {[...perPlace].sort((x, y) => x[0].name.localeCompare(y[0].name)).map(([c, n]) => <option value={c.id} data-label={`${c.flag} ${c.name}`}>{c.flag} {c.name} · {n}</option>)} </select> </div> + <div class="hist-chosen" data-history-chosen hidden> + <ul aria-label="Countries shown"></ul> + <button type="button" class="hf-toggle hf-clear" data-history-clear>Clear</button> + </div> </div> </div> @@ -116,4 +120,5 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; {feed && <button type="button" class="hist-more hist-more-top" hidden>Show earlier in this order</button>} +<p class="hist-none" hidden>No entries for the chosen countries here.</p> <div class="hist-years" data-order="newest" data-start-oldest={period ? "" : undefined} data-only="all" data-feed={feed ? "" : undefined} data-years={feed ? yearIndex : undefined} data-fragments={feed ? `${base}fragments/history/` : undefined}> <HistoryYears years={years} /> @@ -174,6 +179,4 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; document.getElementById(toggle.getAttribute("aria-controls")).hidden = !open; }); - // choosing a country opens its history - document.querySelector("[data-history-country]")?.addEventListener("change", (e) => { if (e.target.value) location.href = e.target.value; }); // In a year divided into months, the month index beside it marks the month being read: the last @@ -205,7 +208,104 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; const flip = (section) => { for (const ol of section.querySelectorAll("ol")) ol.append(...[...ol.children].reverse()); }; + // Countries: the list narrowed in place to one or more of them, kept in the address as + // ?country=NL,DE. Each entry carries its country (data-c; a state's entries carry its federation's). + // Entries of other countries are marked data-out, and each year's and month's counts are worked out + // again from what is left; a year or month with nothing left drops out. + const picker = document.querySelector("[data-history-country]"); + const chosenBox = document.querySelector("[data-history-chosen]"); + const none = document.querySelector(".hist-none"); + const labels = new Map([...picker.options].filter((o) => o.value).map((o) => [o.value, o.dataset.label])); + let chosen = [...new Set((params.get("country") ?? "").toUpperCase().split(","))].filter((id) => labels.has(id)); + const plural = (n, one, many) => (n === 1 ? `1 ${one}` : `${n} ${many}`); + const setCount = (el, text) => { if (el) el.textContent = text; }; + // returns the entries left in `root` (a year, or the whole list): [all, moved a score] + function sift(root) { + const keep = new Set(chosen); + let total = 0, totalScored = 0; + for (const year of root.matches(".hist-year") ? [root] : root.querySelectorAll(".hist-year")) { + let all = 0, scored = 0; + for (const li of year.querySelectorAll("li.he")) { + const out = keep.size > 0 && !keep.has(li.dataset.c); + li.toggleAttribute("data-out", out); + if (!out) { all++; if (li.hasAttribute("data-scored")) scored++; } + } + for (const month of year.querySelectorAll(".hm")) { + const left = [...month.querySelectorAll("li.he:not([data-out])")]; + const n = left.filter((li) => li.hasAttribute("data-scored")).length; + month.toggleAttribute("data-out", !left.length); + month.toggleAttribute("data-scored", n > 0); + const nav = year.querySelector(`.hist-mnav a[href="#${month.id}"]`); + if (!nav) continue; + nav.parentElement.toggleAttribute("data-out", !left.length); + nav.parentElement.toggleAttribute("data-scored", n > 0); + setCount(nav.querySelector(".n-all"), String(left.length)); + setCount(nav.querySelector(".n-scored"), String(n)); + } + year.toggleAttribute("data-out", !all); + year.toggleAttribute("data-scored", scored > 0); + setCount(year.querySelector("h2 .n-all"), plural(all, "entry", "entries")); + setCount(year.querySelector("h2 .n-scored"), plural(scored, "score change", "score changes")); + total += all; + totalScored += scored; + } + return [total, totalScored]; + } + // the counts beside "All entries" and "Score changes only"; null puts the page's own back + let totalsNow = null; + function setTotals(totals) { + totalsNow = totals; + for (const b of document.querySelectorAll(".hist-only button")) { + const small = b.querySelector("small"); + small.dataset.total ??= small.textContent; + small.textContent = totals ? totals[b.dataset.only === "scored" ? 1 : 0].toLocaleString("en-GB") : small.dataset.total; + } + const empty = !!totals && !totals[box.dataset.only === "scored" ? 1 : 0]; + none.hidden = !empty; + } + function renderChosen() { + chosenBox.querySelector("ul").replaceChildren(...chosen.map((id) => { + const li = document.createElement("li"); + const b = document.createElement("button"); + b.type = "button"; + b.dataset.remove = id; + b.title = `Remove ${labels.get(id)}`; + b.textContent = labels.get(id); + li.append(b); + return li; + })); + chosenBox.hidden = !chosen.length; + for (const o of picker.options) o.disabled = chosen.includes(o.value); + picker.value = ""; + // the kinds and tags keep the countries when followed + for (const a of document.querySelectorAll(".hist-kinds a, .hist-tags a")) { + const url = new URL(a.href); + if (chosen.length) url.searchParams.set("country", chosen.join(",")); else url.searchParams.delete("country"); + a.href = url.pathname + url.search; + } + } + function setCountries(list) { + chosen = list; + renderChosen(); + const url = new URL(location.href); + if (chosen.length) url.searchParams.set("country", chosen.join(",")); else url.searchParams.delete("country"); + history.replaceState(null, "", url); + if (feed) feed.narrow(); + else { + const totals = sift(box); + setTotals(chosen.length ? totals : null); + } + refresh(); + } + picker.addEventListener("change", () => { if (picker.value) setCountries([...chosen, picker.value]); }); + chosenBox.addEventListener("click", (e) => { + const b = e.target.closest("button[data-remove]"); + if (b) setCountries(chosen.filter((id) => id !== b.dataset.remove)); + else if (e.target.closest("[data-history-clear]")) setCountries([]); + }); + function setOnly(value) { box.dataset.only = value; press(onlyButtons, "only", value); + setTotals(totalsNow); refresh(); } @@ -215,5 +315,5 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; function refresh() { if (box.dataset.feed === undefined) return; - reset.hidden = box.dataset.order !== "oldest" && box.dataset.only !== "scored" && !new URLSearchParams(location.search).get("from"); + reset.hidden = box.dataset.order !== "oldest" && box.dataset.only !== "scored" && !chosen.length && !new URLSearchParams(location.search).get("from"); } reset.addEventListener("click", (e) => { @@ -225,5 +325,7 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; press(orderButtons, "order", order); history.replaceState(null, "", location.pathname); - feed.start(null); + chosen = []; + renderChosen(); + feed.narrow(); document.querySelector(".hist-filter").scrollIntoView({ block: "start" }); }); @@ -240,11 +342,16 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length; if (order !== "oldest") order = "newest"; press(orderButtons, "order", order); + if (chosen.length) renderChosen(); … 81 more lines
src/components/HistoryYears.astro +1 −1
@@ -32,5 +32,5 @@ const monthsOf = (year, items) => { const monthId = (m) => `m${year}-${m.key || "x"}`; const entries = (list) => list.map((ch) => ( - <li data-scored={ch.score ? "" : undefined}> + <li class="he" data-scored={ch.score ? "" : undefined} data-c={ch.country?.id}> <a href={`${base}history/${ch.id}/`}> <time datetime={ch.date}>{ch.date.length === 4 ? "" : formatDate(ch.date).replace(` ${year}`, "")}</time>
src/lib/unit-map.js +34 −0
@@ -0,0 +1,34 @@ +// A federation's map, drawn at build time: its states, provinces or territories coloured by score, +// shown above their ranked list on the country's profile. Shapes come from src/data/subunits.geo.json +// (keyed "us/ca"), the same ones the home page's map draws with its "show states" switch. +import { geoAlbersUsa, geoConicConformal, geoConicEqualArea, geoNaturalEarth1, geoPath } from "d3-geo"; +import shapes from "../data/subunits.geo.json"; + +// Each country in the projection its own maps use; the US with Alaska and Hawaii set in below. +const PROJECTIONS = { + us: () => geoAlbersUsa(), + ca: () => geoConicConformal().rotate([91.87, 0]).parallels([49, 77]), + au: () => geoConicEqualArea().rotate([-132, 0]).parallels([-18, -36]), +}; + +// width × height: the box the map is fitted into. Returns null when no shape exists for the country. +// Each shape is { unit, d } in the order of `units`; a unit too small to see (DC, the ACT) also gets a dot. +export function unitMap(slug, units, width = 640, height = 400) { + const features = units + .map((unit) => ({ unit, f: shapes.features.find((f) => f.id === `${slug}/${unit.id}`) })) + .filter((x) => x.f); + if (!features.length) return null; + const projection = (PROJECTIONS[slug] ?? geoNaturalEarth1)(); + projection.fitSize([width, height], { type: "FeatureCollection", features: features.map((x) => x.f) }); + const path = geoPath(projection).digits(1); + const drawn = features.map(({ unit, f }) => ({ unit, f, d: path(f) })).filter((x) => x.d); + // shrink the box to what was drawn, so a wide country leaves no empty band + const [[x0, y0], [x1, y1]] = path.bounds({ type: "FeatureCollection", features: drawn.map((x) => x.f) }); + const pad = 4; + const viewBox = [x0 - pad, y0 - pad, x1 - x0 + 2 * pad, y1 - y0 + 2 * pad].map((n) => +n.toFixed(1)).join(" "); + const dots = drawn.filter((x) => path.area(x.f) < 20).map(({ unit, f }) => { + const [x, y] = path.centroid(f); + return { unit, x: +x.toFixed(1), y: +y.toFixed(1) }; + }); + return { viewBox, shapes: drawn.map(({ unit, d }) => ({ unit, d })), dots }; +}
src/pages/fragments/history/countries.json.js +20 −0
@@ -0,0 +1,20 @@ +// The years each country has History entries in, for the country filter of the continuous list at +// /history/ (HistoryList.astro): { "NL": [[2024, 3, 1], …], … }, [year, all, moved a score], newest +// first, like the list's own year index. A federation's states are counted with it. Fetched on first use. +import { historyYears } from "../../../lib/countries.js"; + +export const GET = () => { + const out = {}; + for (const { year, items } of historyYears) { + const per = new Map(); + for (const ch of items) { + if (!ch.country) continue; + const n = per.get(ch.country.id) ?? [year, 0, 0]; + n[1]++; + if (ch.score) n[2]++; + per.set(ch.country.id, n); + } + for (const [id, n] of per) (out[id] ??= []).push(n); + } + return new Response(JSON.stringify(out), { headers: { "Content-Type": "application/json" } }); +};
src/styles/global.css +22 −0
@@ -508,4 +508,14 @@ a.chip:hover { border-color: var(--muted); color: var(--text); } .u-score { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; } @media (max-width: 640px) { .units { columns: 1; } } +.unit-map { margin: 8px 0 14px; } +.unit-map svg { display: block; width: 100%; height: auto; max-height: 420px; } +.unit-map path, .unit-map circle { stroke: var(--bg); stroke-width: 0.8; stroke-linejoin: round; transition: opacity 0.15s; } +.unit-map a:hover path, .unit-map a:hover circle, .unit-map a:focus-visible path { opacity: 0.75; } +.unit-map a:focus-visible { outline: none; } +.unit-map a:focus-visible path { stroke: var(--text); stroke-width: 2; } +.unit-map a.current path, .unit-map a.current circle { stroke: var(--text); stroke-width: 2; } +.unit-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 6px; color: var(--muted); font-size: 12.5px; } +.unit-legend span { display: inline-flex; align-items: center; gap: 6px; } +.unit-legend i { width: 10px; height: 10px; border-radius: 2px; } .c-updated { margin: 28px 0 0; padding-top: 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; } @@ -1374,4 +1384,16 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon .hist-country select { max-width: 100%; min-width: 260px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); font: 400 14px Inter, sans-serif; } @media (max-width: 600px) { .hf-row { grid-template-columns: minmax(0, 1fr); } } +/* the countries the list is narrowed to, each removable */ +.hist-chosen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 12px; } +.hist-chosen[hidden] { display: none; } +.hist-chosen ul { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; } +.hist-chosen li button { display: inline-flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); font: 400 13.5px Inter, sans-serif; cursor: pointer; } +.hist-chosen li button::after { content: "×"; color: var(--muted); font-size: 15px; line-height: 1; } +.hist-chosen li button:hover { border-color: var(--text-2); } +.hist-chosen li button:hover::after { color: var(--text); } +.hf-clear::after { content: none; } +.hist-years [data-out] { display: none; } +.hist-none { margin: 22px 0 0; color: var(--muted); } +.hist-none[hidden] { display: none; } /* what a kind or tag includes, under its link on hover or keyboard focus */ .has-tip { position: relative; }