Brazil countries/br.yaml
context › production › sources
groweriq, anvisa
Changelog 7253cca
12 history entries added1 country profile edited10 files of code, design or docs +411 −59 lines
groweriq, anvisa
README.md +1 @@ -39,5 +39,5 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag | `countries/us/` | A federation: `index.yaml` for the country, one file per state | | `segments.yaml` | The six segments, their weights and level descriptions | -| `changes.yaml` | Laws and events that moved a country's score, with the scores before; the home page shows the latest three | +| `changes.yaml` | Laws and events that moved a country's score, with the scores before; the home page shows the latest four | | `src/` | The website: pages, components, styles |
src/components/AverageChart.astro +109 @@ -0,0 +1,109 @@ +--- +// World average score over time: a step line that moves at each recorded change. +// The plot is a stretched SVG; labels and markers are HTML so they stay readable at any width. +import { averageHistory, formatDate, lastUpdated } from "../lib/countries.js"; + +// `note` adds the caveat under the chart. +const { note = false } = Astro.props; +const base = import.meta.env.BASE_URL; +const fmt = (v) => v.toFixed(1); +const time = (iso) => Date.parse(iso.length === 7 ? `${iso}-15` : iso); + +const first = averageHistory[0]; +const now = averageHistory.at(-1).after; +const startYear = +first.date.slice(0, 4); +const endYear = +lastUpdated.slice(0, 4); +const t0 = Date.UTC(startYear, 0, 1); +const t1 = time(lastUpdated); +const x = (t) => ((t - t0) / (t1 - t0)) * 100; + +// y axis: half-point steps that enclose the data (the range is small, so it does not start at zero) +const values = averageHistory.flatMap((p) => [p.before, p.after]); +const yMin = Math.floor(Math.min(...values) * 2) / 2; +const yMax = Math.ceil(Math.max(...values) * 2) / 2; +const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100; +const ticks = Array.from({ length: Math.round((yMax - yMin) * 2) + 1 }, (_, i) => yMin + i / 2); +const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i); + +const points = averageHistory.map((p) => ({ ...p, x: x(time(p.date)), y: y(p.after) })); +const line = `M0,${y(first.before)}` + points.map((p) => `H${p.x}V${p.y}`).join("") + "H100"; +const delta = now - first.before; +--- +<section class="card avg" aria-labelledby="avgTitle"> + <div class="avg-head"> + <h2 id="avgTitle">World average score</h2> + <p class="avg-now"><b>{fmt(now)}</b><span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{fmt(Math.abs(delta))} since {startYear}</span></p> + </div> + + <div class="avg-chart" id="avgChart"> + <div class="avg-y" aria-hidden="true"> + {ticks.map((t) => <span style={`top:${y(t)}%`}>{fmt(t)}</span>)} + </div> + <div class="avg-plot"> + <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)} />)} + <path class="line" d={line} /> + </svg> + {points.map((p) => ( + <a class="avg-dot" href={`${base}changes/${p.change.id}/`} style={`left:${p.x}%;top:${p.y}%`} + data-date={formatDate(p.date)} data-title={`${p.change.country.flag} ${p.change.title}`} + data-avg={`World average ${fmt(p.before)} → ${fmt(p.after)}`} + aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a> + ))} + <div class="avg-tip" id="avgTip" hidden><small></small><b></b><span></span></div> + </div> + <div class="avg-x" aria-hidden="true"> + {years.map((yr, i) => <span class={i % 2 ? "odd" : ""} style={`left:${x(Date.UTC(yr, 0, 1))}%`}>{yr}</span>)} + </div> + </div> + {note && <p class="avg-note">Rebuilt from the changes on this page, so it is only as complete as that list: a country with no recorded change is held at today's score for the whole period. The scale does not start at zero.</p>} +</section> + +<script> + const chart = document.querySelector("#avgChart"); + const plot = chart?.querySelector(".avg-plot"); + const tip = chart?.querySelector("#avgTip"); + if (chart && plot && 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/ChangeCard.astro +0 @@ -1,17 +0,0 @@ ---- -// A change in short: date, country, title and the overall score before → after. -// The whole card links to the change's own page. -import { formatDate } from "../lib/countries.js"; -import ScoreChange from "./ScoreChange.astro"; - -const { ch } = Astro.props; -const base = import.meta.env.BASE_URL; ---- -<a class="card change" href={`${base}changes/${ch.id}/`}> - <p class="change-meta"> - <time datetime={ch.date}>{formatDate(ch.date)}</time> - <span>{ch.country.flag} {ch.country.name}</span> - </p> - <h3>{ch.title}</h3> - <ScoreChange score={ch.score} /> -</a>
src/components/Changes.astro +31 @@ -1,16 +1,36 @@ --- -// The three most recent changes that moved a country's score (see /changes.yaml). +// Home page: the four most recent changes as a small vertical timeline (date on the left, +// details on the right), next to the world average chart. See /changes.yaml. import { changes } from "../lib/countries.js"; -import ChangeCard from "./ChangeCard.astro"; +import { tierColor } from "../lib/tiers.js"; +import AverageChart from "./AverageChart.astro"; +import ScoreChange from "./ScoreChange.astro"; const base = import.meta.env.BASE_URL; +// "1 Jan 2026", or "Feb 2026" when the day is not known +const shortDate = (iso) => + new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "short", year: "numeric", timeZone: "UTC" }); --- -<section class="changes" aria-labelledby="changesTitle"> - <div class="changes-head"> - <h2 class="changes-title" id="changesTitle">Latest changes</h2> - <a href={`${base}changes/`}>All changes →</a> - </div> - <div class="changes-grid"> - {changes.slice(0, 3).map((ch) => <ChangeCard ch={ch} />)} - </div> -</section> +<div class="home-split"> + <section class="card changes" aria-labelledby="changesTitle"> + <div class="changes-head"> + <h2 class="changes-title" id="changesTitle">Latest changes</h2> + <a href={`${base}changes/`}>All changes →</a> + </div> + <ol class="mini-tl"> + {changes.slice(0, 4).map((ch) => ( + <li style={`--c:${tierColor(ch.score.after)}`}> + <a href={`${base}changes/${ch.id}/`}> + <time datetime={ch.date}>{shortDate(ch.date)}</time> + <span class="mini-tl-body"> + <span class="mini-tl-country">{ch.country.flag} {ch.country.name}</span> + <b>{ch.title}</b> + <ScoreChange score={ch.score} /> + </span> + </a> + </li> + ))} + </ol> + </section> + <AverageChart /> +</div>
src/components/CountryArticle.astro +22 @@ -2,5 +2,5 @@ // Full write-up for one country. Rendered on its own page, and fetched into the // drawer on the home page — so there is a single template for both views. -import { SEGMENTS, REC_LABEL, MED_LABEL, countries, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js"; +import { SEGMENTS, REC_LABEL, MED_LABEL, countries, changes, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js"; import { tierOf, tierColor } from "../lib/tiers.js"; @@ -17,4 +17,6 @@ const updated = sub ? sub.reviewed : d?.reviewed; const updatedText = updated && formatDate(updated); const byName = group ? [...group.units].sort((a, b) => a.name.localeCompare(b.name)) : []; +// Recorded changes to the national score, newest first (not shown for a single state). +const history = sub ? [] : changes.filter((ch) => ch.country === c); const r = 42; const circ = 2 * Math.PI * r; @@ -48,4 +50,23 @@ const circ = 2 * Math.PI * r; </div> <p class="d-note">{v.note}</p> + {history.length > 0 && ( + <div class="c-changes"> + <h2>{history.length > 1 ? "Changes" : "Latest change"}</h2> + <ul> + {history.map((ch) => ( + <li> + <a href={`${base}changes/${ch.id}/`}> + <time datetime={ch.date}>{formatDate(ch.date)}</time> + <b>{ch.title}</b> + <span class="c-change-score"> + {ch.score.before} <span aria-hidden="true">→</span><span class="visually-hidden">to</span> {ch.score.after} + <span class={`delta ${ch.score.after >= ch.score.before ? "up" : "down"}`}>{ch.score.after >= ch.score.before ? "+" : "−"}{Math.abs(ch.score.after - ch.score.before)}</span> + </span> + </a> + </li> + ))} + </ul> + </div> + )} {group && ( <label class="field sub-select">
src/components/ScoreChange.astro +1 @@ -10,4 +10,4 @@ const delta = score.after - score.before; <span class="arrow" aria-hidden="true">→</span> <span class="pill" style={`--c:${tierColor(score.after)}`}>{score.after}</span> - <span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{Math.abs(delta)} points</span> + <span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{Math.abs(delta)} {Math.abs(delta) === 1 ? "point" : "points"}</span> </p>
src/lib/countries.js +27 @@ -106,24 +106,40 @@ export function subunitsOf(id) { // ── Changes: developments that moved a score, newest first ── -export const changes = (await getCollection("changes")) - .map(({ data }) => { +// Each entry records the segment scores before the change. The scores after it are the "before" +// of the same country's next change, or the country's current scores if there is none. +const changeEntries = (await getCollection("changes")).map(({ data }) => data).sort((a, b) => a.date.localeCompare(b.date)); +export const changes = changeEntries + .map((data, i) => { const country = countries.find((c) => c.slug === data.country); if (!country) throw new Error(`changes.yaml: "${data.id}" refers to unknown country "${data.country}"`); - const s = scoreList(data.before); - const before = Math.round(scoreOf(s)); + const next = changeEntries.slice(i + 1).find((x) => x.country === data.country); + const before = scoreList(data.before); + const after = next ? scoreList(next.before) : country.s; + if (before.every((v, k) => v === after[k])) throw new Error(`changes.yaml: "${data.id}" has the same scores before and after`); return { ...data, country, + latest: !next, // the country's most recent change: "after" is its current score source: pair(data.source), - score: { before, after: country.score }, + raw: { before: scoreOf(before), after: scoreOf(after) }, + score: { before: Math.round(scoreOf(before)), after: Math.round(scoreOf(after)) }, // every segment, with its score before and after - segments: SEGMENTS.map((seg, i) => ({ name: seg.name, weight: seg.weight, before: s[i], after: country.s[i] })), + segments: SEGMENTS.map((seg, k) => ({ name: seg.name, weight: seg.weight, before: before[k], after: after[k] })), }; }) - .sort((a, b) => b.date.localeCompare(a.date)); -for (const ch of changes) { - if (changes.filter((x) => x.country === ch.country).length > 1) throw new Error(`changes.yaml: more than one change for "${ch.country.slug}" — keep only the latest`); - if (ch.segments.every((seg) => seg.before === seg.after)) throw new Error(`changes.yaml: "${ch.id}" has the same scores before and after`); -} + .reverse(); + +// World average over time, oldest first, rebuilt by undoing the recorded changes one by one. +// Only as complete as changes.yaml: countries with no recorded change are held at today's score. +export const averageHistory = (() => { + const n = countries.length; + let sum = countries.reduce((a, c) => a + c.raw, 0); + const points = changes.map((ch) => { + const after = sum / n; + sum -= ch.raw.after - ch.raw.before; + return { change: ch, date: ch.date, before: sum / n, after }; + }); + return points.reverse(); +})(); // Review dates are stored as YYYY-MM-DD; this is how they are shown everywhere.
src/pages/changes/[id].astro +1 @@ -47,5 +47,5 @@ const diff = (seg) => seg.after - seg.before; </tbody> </table> - <p class="change-note">Segments are scored from 0 to 10. The scores after the change are {c.name}'s current scores.</p> + <p class="change-note">Segments are scored from 0 to 10. {ch.latest ? `The scores after the change are ${c.name}'s current scores.` : `${c.name}'s scores have changed again since.`}</p> <h2 class="d-h">Source</h2>
src/pages/index.astro +1 @@ -33,5 +33,4 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) => {stats.map(([n, label]) => <div class="stat"><div class="big">{n}</div><div class="label">{label}</div></div>)} </div> - <DidYouKnow /> <Changes /> </section> @@ -154,4 +153,5 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) => <Lore /> + <DidYouKnow /> </main>
src/styles/global.css +60 @@ -382,5 +382,5 @@ td.num, th.num { width: 52px; color: var(--muted); } /* ── Did you know? ──────────────────────────────────── */ -.dyk { margin-top: 12px; padding: 20px 24px 22px; border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } +.dyk { margin-top: 48px; padding: 20px 24px 22px; border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } .dyk-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; } .dyk-label { margin: 0; color: var(--accent); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .09em; } @@ -434,15 +434,28 @@ td.num, th.num { width: 52px; color: var(--muted); } /* ── Changes ────────────────────────────────────────── */ -.changes { margin-top: 28px; } -.changes-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; } -.changes-title { font-size: 20px; margin: 0; } +.home-split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; } +.home-split .avg { margin-top: 0; max-width: none; } +@media (max-width: 900px) { .home-split { grid-template-columns: 1fr; } } +.changes { padding: 20px 22px 14px; } +.changes-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; } +.changes-title { font-size: 18px; margin: 0; } .changes-head a { color: var(--text-2); font-size: 14px; font-weight: 500; white-space: nowrap; } .changes-head a:hover { color: var(--text); } -.changes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } -.change { padding: 18px 20px; display: flex; flex-direction: column; color: inherit; transition: border-color .15s, transform .15s; } -.change:hover { border-color: var(--muted); transform: translateY(-1px); } +/* date | rail with a dot | details */ +.mini-tl { list-style: none; margin: 0; padding: 0; } +.mini-tl a { display: grid; grid-template-columns: 78px 1fr; gap: 0 30px; color: inherit; position: relative; padding: 0 0 16px; } +.mini-tl li:last-child a { padding-bottom: 6px; } +.mini-tl a::before { content: ""; position: absolute; left: 92px; top: 6px; bottom: -6px; width: 2px; border-radius: 1px; background: var(--border); } +.mini-tl li:last-child a::before { display: none; } +.mini-tl a::after { content: ""; position: absolute; left: 88px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 3px var(--surface); } +.mini-tl time { color: var(--muted); font-size: 13px; text-align: right; white-space: nowrap; padding-top: 1px; font-variant-numeric: tabular-nums; } +.mini-tl-body { display: grid; gap: 3px; min-width: 0; } +.mini-tl-country { color: var(--text-2); font-size: 12.5px; font-weight: 500; } +.mini-tl b { font-size: 15.5px; line-height: 1.3; } +.mini-tl a:hover b { text-decoration: underline; text-underline-offset: 3px; } +.mini-tl .change-score { margin-top: 3px; } +.mini-tl .pill { font-size: 13px; min-width: 32px; padding: 1px 8px; border-width: 1.5px; } .change-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0 0 8px; color: var(--muted); font-size: 12.5px; } .change-meta span { color: var(--text-2); font-weight: 500; } -.change h3 { font-size: 17px; line-height: 1.25; margin: 0 0 14px; flex: 1; } .change-score { display: flex; align-items: center; gap: 8px; margin: 0; font-variant-numeric: tabular-nums; } .pill { min-width: 38px; padding: 3px 9px; border-radius: 999px; text-align: center; font-weight: 700; font-size: 15px; color: var(--text); border: 2px solid var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); } @@ -482,2 +495,41 @@ td.num, th.num { width: 52px; color: var(--muted); } .change-table .delta { font-size: inherit; } .change-note { color: var(--muted); font-size: 13px; margin: 10px 0 0; } + +/* ── World average chart ────────────────────────────── */ +.avg { margin-top: 24px; padding: 20px 22px 16px; max-width: 760px; display: flex; flex-direction: column; } +.avg-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 8px 20px; margin-bottom: 18px; } +.avg-head h2 { font-size: 18px; margin: 0; } +.avg-now { margin: 0; display: flex; align-items: baseline; gap: 10px; } +.avg-now b { font-size: 32px; line-height: 1; font-variant-numeric: tabular-nums; } +.avg-chart { flex: 1; display: grid; grid-template-columns: 34px 1fr; grid-template-rows: minmax(190px, 1fr) 24px; } +.avg-y, .avg-x { position: relative; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.avg-y span { position: absolute; right: 8px; transform: translateY(-50%); } +.avg-x { grid-column: 2; } +.avg-x span { position: absolute; top: 7px; transform: translateX(-50%); } +.avg-plot { position: relative; } +.avg-plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.avg-plot .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; } +.avg-plot .line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; } +/* a 24px hit target around a 9px dot with a surface ring */ +.avg-dot { position: absolute; width: 24px; height: 24px; margin: -12px 0 0 -12px; border-radius: 50%; } +.avg-dot::after { content: ""; position: absolute; inset: 7.5px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--surface); transition: transform .12s; } +.avg-dot.on::after, .avg-dot:focus-visible::after { transform: scale(1.5); } +.avg-dot.on { z-index: 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; } } + +/* ── Changes on a country page ──────────────────────── */ +.c-changes { margin-top: 18px; } +.c-changes h2 { margin: 0 0 8px; color: var(--muted); font-size: 12.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; } +.c-changes ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.c-changes a { display: grid; gap: 2px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: inherit; } +.c-changes a:hover { border-color: var(--muted); } +.c-changes time { color: var(--muted); font-size: 12.5px; } +.c-changes b { font-size: 14.5px; line-height: 1.3; } +.c-change-score { color: var(--text-2); font-size: 13.5px; font-variant-numeric: tabular-nums; } +.c-change-score .delta { margin-left: 6px; }