Changelog 7377176tree, with Claude Opus 5.5
Context maps in the home map's style, dots for small places, topic menu
- The home page's map gets a dot on each scored place too small to see (Malta, Singapore, the islands), coloured, hovered and clicked like its shape, and kept small when zooming. - A context topic's map is drawn like the home map (siteMap() in world-map.js): the 960 × 480 frame, the sphere outline, Antarctica as land, hatching where nothing is known, the same dots and hover box. - A row of every topic at the top of /context/ and each topic page (ContextMenu.astro), replacing the list at the foot.
8 files of code, design or docs +105 −39 lines
Code, design and documents 8 files
src/components/ContextMenu.astro +23 −0
@@ -0,0 +1,23 @@ +--- +// The row of context topics at the top of /context/ and of each topic's page, to jump between +// them; the current one is marked. Scrolls sideways where it does not fit, keeping the current one +// in view. +import { Icon } from "astro-icon/components"; +import { contextTopics } from "../lib/contexts.js"; + +const { current = null } = Astro.props; +const base = import.meta.env.BASE_URL; +--- +<nav class="ctxp-menu" aria-label="Context topics"> + <ol> + <li><a href={`${base}context/`} aria-current={current ? undefined : "page"}>All topics</a></li> + {contextTopics.map((t) => ( + <li><a href={`${base}context/${t.slug}/`} aria-current={t.key === current ? "page" : undefined}><Icon name={t.icon} aria-hidden="true" />{t.name}</a></li> + ))} + </ol> +</nav> +<script> + // bring the current topic into view in the row + const on = document.querySelector('.ctxp-menu a[aria-current="page"]'); + if (on) on.parentElement.parentElement.scrollLeft = on.offsetLeft - 40; +</script>
src/components/HomePage.astro +19 −2
@@ -549,4 +549,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", let metric = $("#metric").dataset.initial; // the view this page opens on let paths = null; + let dots = null; // a dot on each place too small to see on the map (Malta, Singapore, the islands) const USE_SCALES = JSON.parse($("#cfi-use-scales").textContent); const PRICE_SCALE = JSON.parse($("#cfi-price-scale").textContent); @@ -645,7 +646,10 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", const unitsOn = () => showUnits && isScore() && dataYear == null; + // a country's colour in the current view; its dot (a place too small to see) takes the same + const fillOf = (d) => (d.id === "010" ? "var(--land)" : !d.c ? "url(#hatch)" : isData() ? dataColor(d.c) : isClubs() ? clubsColor(d.c) : isPlant() ? plantColor(d.c) : isPrice() ? priceColor(d.c) : isUse() ? useColor(d.c) : tierColor(metricValue(d.c))); function paintMap() { if (paths) { - paths.attr("fill", (d) => (d.id === "010" ? "var(--land)" : !d.c ? "url(#hatch)" : isData() ? dataColor(d.c) : isClubs() ? clubsColor(d.c) : isPlant() ? plantColor(d.c) : isPrice() ? priceColor(d.c) : isUse() ? useColor(d.c) : tierColor(metricValue(d.c)))); + paths.attr("fill", fillOf); + if (dots) dots.attr("fill", fillOf); // a federation's own shape steps aside while its states are shown paths.style("display", (d) => (unitsOn() && unitPaths && d.c && FEDERATIONS.has(d.c.id) ? "none" : null)); @@ -983,4 +987,14 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", .on("pointerleave", () => (tip.hidden = true)) .on("click", (e, d) => d.c && openCountry(d.c.id)); + // a scored place smaller than a few pixels also gets a dot, so that it can be seen and hovered + const DOT_R = 2.8; + dots = g.selectAll(".tiny-dot").data(features.filter((f) => f.c && path.area(f) < 8 && Number.isFinite(path.centroid(f)[0]))).join("circle") + .attr("class", "country-shape has-data tiny-dot") + .attr("cx", (d) => path.centroid(d)[0]) + .attr("cy", (d) => path.centroid(d)[1]) + .attr("r", DOT_R) + .on("pointermove", showTip) + .on("pointerleave", () => (tip.hidden = true)) + .on("click", (e, d) => openCountry(d.c.id)); drawUnits = async () => { @@ -998,5 +1012,8 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", const z = zoom().scaleExtent([1, 10]).translateExtent([[0, 0], [960, 480]]) - .on("zoom", (e) => g.attr("transform", e.transform)); + .on("zoom", (e) => { + g.attr("transform", e.transform); + dots.attr("r", DOT_R / Math.sqrt(e.transform.k)); // the dots stay small as the map grows + }); svg.call(z); $("#zoomIn").onclick = () => svg.transition().duration(250).call(z.scaleBy, 1.8);
src/lib/contexts.js +6 −5
@@ -83,9 +83,10 @@ export const contextTopics = CONTEXT.filter(([key]) => key !== "products").map(( // The map colour of a place on a topic's map, as a CSS colour: a status tier on the --t scale, the -// accent blue where the topic is written, grey where it is explained as not applicable. +// accent blue where the topic is written, grey where it is explained as not applicable, and the +// home map's hatching where nothing is known yet. export const mapFill = (topic, row) => { - if (!row) return "var(--map-none)"; - if (topic.key === "death_penalty") return row.state === "filled" ? "var(--t0)" : row.state === "not_applicable" ? "var(--map-na)" : "var(--map-none)"; - if (topic.scheme.kind === "status") return row.status?.tier != null ? `var(--t${row.status.tier})` : "var(--map-none)"; - return row.state === "filled" ? "var(--u3)" : row.state === "not_applicable" ? "var(--map-na)" : "var(--map-none)"; + if (!row) return "url(#hatch)"; + if (topic.key === "death_penalty") return row.state === "filled" ? "var(--t0)" : row.state === "not_applicable" ? "var(--map-na)" : "url(#hatch)"; + if (topic.scheme.kind === "status") return row.status?.tier != null ? `var(--t${row.status.tier})` : "url(#hatch)"; + return row.state === "filled" ? "var(--u3)" : row.state === "not_applicable" ? "var(--map-na)" : "url(#hatch)"; };
src/lib/unit-map-tip.js +5 −4
@@ -1,4 +1,5 @@ -// The hover box of a federation's map of its states or provinces (CountryArticle.astro): the same -// box as on the home page's map. Each one is written into the page beside the map, in a <template>, +// The hover box of a map drawn at build time — a federation's map of its states or provinces +// (CountryArticle.astro), and the world map of a context topic (data-map-tips): the same box as on +// the home page's map. Each one is written into the page beside the map, in a <template>, // and shown under the pointer. Listened for on the document, so that it also works in a profile // fetched into the home page's drawer. @@ -9,6 +10,6 @@ const hide = () => { }; document.addEventListener("pointermove", (e) => { - const a = e.pointerType === "touch" ? null : e.target.closest?.(".unit-map a[data-tip]"); - const figure = a?.closest(".unit-map"); + const a = e.pointerType === "touch" ? null : e.target.closest?.(".unit-map a[data-tip], [data-map-tips] a[data-tip]"); + const figure = a?.closest(".unit-map, [data-map-tips]"); const tip = figure?.querySelector(".tooltip"); const content = figure?.querySelector(`template[data-tip-for="${a.dataset.tip}"]`);
src/lib/world-map.js +19 −0
@@ -53,4 +53,23 @@ export function worldMap(width, height, digits = 1) { } +// The home page's map drawn at build time, for pages that show the same map without its script +// (the context topics, /context/<topic>/): the same 960 × 480 frame, the whole sphere fitted in it +// and outlined, Antarctica as plain land, and a dot on each scored place too small to see. Fills +// are left to the page. +export function siteMap() { + const projection = geoNaturalEarth1().fitExtent([[8, 8], [952, 472]], { type: "Sphere" }); + const path = geoPath(projection).digits(1); + const dots = shapes.filter((s) => s.c && !s.antarctica && path.area(s.f) < 8).map((s) => { + const [x, y] = path.centroid(s.f); + return { x: +x.toFixed(1), y: +y.toFixed(1), c: s.c }; + }).filter((d) => Number.isFinite(d.x)); + return { + width: 960, height: 480, + sphere: path({ type: "Sphere" }), + shapes: shapes.map((s) => ({ d: path(s.f), c: s.c, antarctica: s.antarctica })).filter((s) => s.d), + dots, r: 2.8, + }; +} + // The map as a standalone SVG document, for the PNG images. export function worldMapSvg(width, height) {
src/pages/context/[topic].astro +17 −18
@@ -5,6 +5,7 @@ import Base from "../../layouts/Base.astro"; import { Icon } from "astro-icon/components"; +import ContextMenu from "../../components/ContextMenu.astro"; import { contextTopics, mapFill } from "../../lib/contexts.js"; -import { worldMap, MAP_RATIO } from "../../lib/world-map.js"; +import { siteMap } from "../../lib/world-map.js"; import { tierColor } from "../../lib/tiers.js"; @@ -15,6 +16,6 @@ export function getStaticPaths() { const { topic } = Astro.props; const base = import.meta.env.BASE_URL; -const W = 1000, H = Math.round(W / MAP_RATIO); -const map = worldMap(W, H, 0); +const map = siteMap(); +const W = map.width, H = map.height; const rowOf = new Map(topic.rows.map((r) => [r.c.id, r])); const pct = (n) => Math.round((n / topic.total) * 100); @@ -28,8 +29,8 @@ const sectionHref = (c) => `${base}country/${c.slug}/#seg-ctx-${topic.key}`; // the map's key const legend = isDeath - ? [["var(--t0)", "Provided for by law"], ["var(--map-na)", "Explained as not applicable"], ["var(--map-none)", "Not researched yet"]] + ? [["var(--t0)", "Provided for by law"], ["var(--map-na)", "Explained as not applicable"], ["hatch", "Not researched yet"]] : isStatus - ? [...topic.scheme.values.filter((v) => v.tier != null).map((v) => [`var(--t${v.tier})`, v.label]), ["var(--map-none)", "Not researched yet"]] - : [["var(--u3)", "Written"], ["var(--map-na)", "Explained as not applicable"], ["var(--map-none)", "Not researched yet"]]; + ? [...topic.scheme.values.filter((v) => v.tier != null).map((v) => [`var(--t${v.tier})`, v.label]), ["hatch", "Not researched yet"]] + : [["var(--u3)", "Written"], ["var(--map-na)", "Explained as not applicable"], ["hatch", "Not researched yet"]]; const figures = [ [topic.filled, "profiles with this section"], @@ -38,9 +39,9 @@ const figures = [ [`${pct(topic.filled + topic.notApplicable)}%`, `of ${topic.total} places covered`], ]; -const others = contextTopics.filter((t) => t.key !== topic.key); const description = `${topic.name} in every country: ${topic.help.charAt(0).toLowerCase()}${topic.help.slice(1)}. A world map, the figures and each country's summary.`; --- <Base title={`${topic.name}: country by country`} description={description}> <main class="changes-page ctxp"> + <ContextMenu current={topic.key} /> <section class="hero hero-sm"> <p class="eyebrow"><a href={`${base}context/`}>Context</a> <span>not part of the score</span></p> @@ -59,16 +60,20 @@ const description = `${topic.name} in every country: ${topic.help.charAt(0).toLo </div></div> {/* the same hover box and zoom buttons as the home page's map (src/lib/unit-map-tip.js, d3-zoom) */} - <figure class="unit-map ctxp-map" data-ctxp-map> + <figure class="card map-card map-wrap ctxp-map" data-ctxp-map data-map-tips> <svg viewBox={`0 0 ${W} ${H}`} role="group" aria-label={`World map: ${topic.name}`}> + <defs> + <pattern id="hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="var(--nodata)" /><rect width="2" height="5" fill="var(--hatch)" /></pattern> + </defs> <g class="ctxp-zoom"> + <path class="sphere" d={map.sphere} /> {map.shapes.map((s) => { const row = s.c ? rowOf.get(s.c.id) : null; return row - ? <a href={sectionHref(row.c)} data-tip={row.c.id} aria-label={tipOf(row)}><path d={s.d} style={`fill:${mapFill(topic, row)}`} /></a> - : <path d={s.d} class="ctxp-blank" />; + ? <a href={sectionHref(row.c)} data-tip={row.c.id} aria-label={tipOf(row)}><path class="country-shape has-data" d={s.d} style={`fill:${mapFill(topic, row)}`} /></a> + : <path class:list={["country-shape", { "land-only": s.antarctica }]} d={s.d} style={`fill:${s.antarctica ? "var(--land)" : "url(#hatch)"}`} />; })} {map.dots.map((d) => { const row = rowOf.get(d.c.id); - return row && <a href={sectionHref(row.c)} data-tip={row.c.id} aria-hidden="true" tabindex="-1"><circle cx={d.x} cy={d.y} r={map.r * 1.6} style={`fill:${mapFill(topic, row)}`} /></a>; + return row && <a href={sectionHref(row.c)} data-tip={row.c.id} aria-hidden="true" tabindex="-1"><circle class="country-shape has-data tiny-dot" cx={d.x} cy={d.y} r={map.r} style={`fill:${mapFill(topic, row)}`} /></a>; })} </g> @@ -89,5 +94,5 @@ const description = `${topic.name} in every country: ${topic.help.charAt(0).toLo ))} <figcaption class="ctxp-legend"> - {legend.map(([color, label]) => <span><i style={`background:${color}`}></i>{label}</span>)} + {legend.map(([color, label]) => <span>{color === "hatch" ? <i class="sw-nodata"></i> : <i style={`background:${color}`}></i>}{label}</span>)} </figcaption> </figure> @@ -140,10 +145,4 @@ const description = `${topic.name} in every country: ${topic.help.charAt(0).toLo </section> - <nav class="section ctxp-others" aria-label="Other context topics"> - <div class="section-head"><div><h2>Other topics</h2></div></div> - <ul> - {others.map((t) => <li><a href={`${base}context/${t.slug}/`}><Icon name={t.icon} class="ctxp-icon" aria-hidden="true" /> {t.name}</a></li>)} - </ul> - </nav> </main> </Base>
src/pages/context/index.astro +2 −0
@@ -5,4 +5,5 @@ import Base from "../../layouts/Base.astro"; import { Icon } from "astro-icon/components"; +import ContextMenu from "../../components/ContextMenu.astro"; import { contextTopics } from "../../lib/contexts.js"; @@ -13,4 +14,5 @@ const written = contextTopics.reduce((n, t) => n + t.filled, 0); <Base title="Context" description="Beyond the score: medical cannabis, clubs, seeds, hemp, the black market, driving, visitors, arrests and more, each compared across every country with a map and each profile's summary."> <main class="changes-page ctxp"> + <ContextMenu /> <section class="hero hero-sm"> <p class="eyebrow">Context <span>{contextTopics.length} topics · {total} places · {written.toLocaleString("en-GB")} sections written</span></p>
src/styles/global.css +14 −10
@@ -164,4 +164,5 @@ select:focus-visible, input:focus-visible, button:focus-visible, tr:focus-visibl .country-shape.has-data:hover, .country-shape.active { stroke: var(--text); stroke-width: 1.4; } .sphere { fill: none; stroke: var(--border); } +.tiny-dot { stroke: var(--surface); stroke-width: .8; } .map-zoom { position: absolute; right: 14px; top: 14px; display: grid; gap: 6px; } .map-msg { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); margin: 0; pointer-events: none; } @@ -3470,11 +3471,8 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent /* the map */ .ctxp-map { margin: 0; } -.ctxp-map { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r); } -.ctxp-map svg { display: block; width: 100%; height: auto; max-height: none; cursor: grab; } +.ctxp-map svg { display: block; width: 100%; height: auto; cursor: grab; touch-action: none; } .ctxp-map svg:active { cursor: grabbing; } -.ctxp-map .ctxp-legend { padding: 0 14px 12px; } -.ctxp-map path, .ctxp-map circle { stroke: var(--bg); stroke-width: .6; stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: opacity .15s; } -.ctxp-map a:hover path, .ctxp-map a:hover circle { opacity: .7; } -.ctxp-map .ctxp-blank { fill: var(--map-none); } +.ctxp-map .ctxp-legend { padding: 4px 14px 12px; } +.ctxp-map a:hover .country-shape { stroke: var(--text); stroke-width: 1.4; } .ctxp-map-sec .sub a, .ctxp .sub a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } /* the tables */ @@ -3503,9 +3501,15 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent .ctxp-status th:nth-child(2), .ctxp-status td:nth-child(2) { display: none; } } -/* other topics */ -.ctxp-others ul { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 0; padding: 0; list-style: none; } -.ctxp-others a { color: var(--text-2); font-size: 14px; } -.ctxp-others a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } /* a context section's title on a profile leads to the topic across every country */ a.ctx-topic { color: inherit; } a.ctx-topic:hover { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; } +/* the row of topics at the top of the context pages */ +.ctxp-menu { margin: 18px 0 0; border-bottom: 1px solid var(--border); } +.ctxp-menu ol { display: flex; gap: 4px 22px; margin: 0; padding: 0; list-style: none; overflow-x: auto; scrollbar-width: none; mask-image: linear-gradient(90deg, #000 calc(100% - 32px), transparent); padding-right: 28px; } +.ctxp-menu ol::-webkit-scrollbar { display: none; } +.ctxp-menu li { flex: none; } +.ctxp-menu a { display: inline-flex; align-items: center; gap: 6px; padding: 10px 0 9px; border-bottom: 2px solid transparent; margin-bottom: -1px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); white-space: nowrap; } +.ctxp-menu a svg { width: 13px; height: 13px; } +.ctxp-menu a:hover { color: var(--text); } +.ctxp-menu a[aria-current] { color: var(--text); border-bottom-color: var(--text); } +@media (min-width: 900px) { .ctxp-menu ol { flex-wrap: wrap; overflow: visible; mask-image: none; padding-right: 0; column-gap: 20px; } }