Cannabis Freedom Index

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; } }

    ↑↓ to move, ↵ to open.