Cannabis Freedom Index

Changelog a34a404

Give the map of a federation's states or provinces the world map's hover box

Hovering a state, province or territory now shows the same box as on the home page's map with states shown: flag and name, the score in its tier's colour, the tier and its rank within the country, a bar and the short summary. The boxes are written into the page and shown by a small delegated script, so they also work in the home page's drawer. The summary shortening moves from map-notes.json.js to src/lib/short.js so both can use it. Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd

6 files of code, design or docs +71 −22 lines

Code, design and documents 6 files

src/components/CountryArticle.astro +19 −4
@@ -5,4 +5,5 @@ import { useAges, SEGMENTS, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP, countries,
 import { TIERS, tierOf, tierColor, tierIndex } from "../lib/tiers.js";
 import { unitMap } from "../lib/unit-map.js";
+import { short } from "../lib/short.js";
 import CountryChanges from "./CountryChanges.astro";
 import CountryChart from "./CountryChart.astro";
@@ -228,14 +229,25 @@ const culture = cultureOf(c);
             <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 href={unitUrl(x)} data-subunit data-tip={x.id} class={sub?.id === x.id ? "current" : undefined} aria-label={`${x.name}: ${x.score}, ${tierOf(x.raw).name}`}>
+                  <path d={d} style={`fill:${tierColor(x.raw)}`} />
                 </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 href={unitUrl(x)} data-subunit data-tip={x.id} class={sub?.id === x.id ? "current" : undefined} aria-hidden="true" tabindex="-1">
+                  <circle cx={cx} cy={cy} r="5" style={`fill:${tierColor(x.raw)}`} />
                 </a>
               ))}
             </svg>
+            {/* the hover box of each, as on the home page's map with its states shown (src/lib/unit-map-tip.js) */}
+            <div class="tooltip" hidden></div>
+            {group.units.map((x) => (
+              <template data-tip-for={x.id}>
+                <div class="tip-head"><strong><span class="tip-flag">{c.flag}</span>{x.name}</strong><span class="tip-chip" style={`--chip:${tierColor(x.raw)}`}>{x.score}</span></div>
+                <div class="tip-meta">{tierOf(x.raw).name} · #{x.rank} of {group.units.length} in {c.name}</div>
+                <div class="tip-bar"><i style={`width:${Math.max(2, Math.min(100, x.raw))}%;background:${tierColor(x.raw)}`}></i></div>
+                {short(x.note) && <p class="tip-note">{short(x.note)}</p>}
+                <div class="tip-foot">Click for the full profile</div>
+              </template>
+            ))}
             <figcaption class="unit-legend">
               {mapTiers.map((t) => <span><i style={`background:${tierColor(t.min)}`}></i>{t.name}</span>)}
@@ -378,2 +390,5 @@ const culture = cultureOf(c);
   </div>
 </article>
+<script>
+  import "../lib/unit-map-tip.js";
+</script>

src/components/HomePage.astro +1 −0
@@ -527,4 +527,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
     import territoryShapes from "../data/territories.geo.json";
     import { tierOf, tierColor } from "../lib/tiers.js";
+    import "../lib/unit-map-tip.js"; // the hover box of a federation's map, in the drawer
 
     const base = import.meta.env.BASE_URL;

src/lib/short.js +19 −0
@@ -0,0 +1,19 @@
+// A long text cut to what fits in a hover box: on the home page's map (map-notes.json.js) and on
+// a federation's map of its states or provinces (CountryArticle.astro).
+const LIMIT = 220;
+
+// The first sentences of a text, as many as fit in about LIMIT characters; a first sentence that
+// is longer than that is cut at a word and ends in an ellipsis.
+export function short(text) {
+  if (!text) return undefined;
+  const flat = text.replace(/\s+/g, " ").trim();
+  // a sentence ends at . ! or ? followed by a space and a capital, digit or quote, so "7.5 g" stays whole
+  const sentences = flat.split(/(?<=[.!?])\s+(?=[A-Z0-9“"'(])/);
+  let out = "";
+  for (const s of sentences) {
+    if (out && out.length + 1 + s.length > LIMIT) break;
+    out = out ? `${out} ${s}` : s;
+  }
+  if (out.length <= LIMIT + 40) return out;
+  return out.slice(0, LIMIT).replace(/\s+\S*$/, "") + "…";
+}

src/lib/unit-map-tip.js +30 −0
@@ -0,0 +1,30 @@
+// 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>,
+// 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.
+let open = null;
+const hide = () => {
+  if (open) open.hidden = true;
+  open = null;
+};
+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 tip = figure?.querySelector(".tooltip");
+  const content = figure?.querySelector(`template[data-tip-for="${a.dataset.tip}"]`);
+  if (!tip || !content) return hide();
+  if (tip.dataset.for !== a.dataset.tip) {
+    tip.replaceChildren(content.content.cloneNode(true));
+    tip.dataset.for = a.dataset.tip;
+  }
+  if (open !== tip) hide();
+  tip.hidden = false;
+  open = tip;
+  // above the pointer where there is room, below it otherwise; never past the map's edges
+  const box = figure.getBoundingClientRect();
+  const x = e.clientX - box.left, y = e.clientY - box.top;
+  const w = tip.offsetWidth, h = tip.offsetHeight;
+  tip.style.left = Math.max(8, Math.min(x + 16, box.width - w - 8)) + "px";
+  tip.style.top = (y - h - 14 >= 8 ? y - h - 14 : y + 18) + "px";
+}, { passive: true });
+document.addEventListener("pointerout", (e) => { if (!e.relatedTarget) hide(); });

src/pages/map-notes.json.js +1 −17
@@ -6,23 +6,7 @@
 // States and provinces are keyed like their map shapes ("us/ca") and carry n and s only.
 import { places, countries, subunitsOf } from "../lib/countries.js";
+import { short } from "../lib/short.js";
 
 const MAP_CONTEXT = ["planting", "medical", "clubs", "driving", "visitors"];
-const LIMIT = 220;
-
-// The first sentences of a text, as many as fit in about LIMIT characters; a first sentence that
-// is longer than that is cut at a word and ends in an ellipsis.
-function short(text) {
-  if (!text) return undefined;
-  const flat = text.replace(/\s+/g, " ").trim();
-  // a sentence ends at . ! or ? followed by a space and a capital, digit or quote, so "7.5 g" stays whole
-  const sentences = flat.split(/(?<=[.!?])\s+(?=[A-Z0-9“"'(])/);
-  let out = "";
-  for (const s of sentences) {
-    if (out && out.length + 1 + s.length > LIMIT) break;
-    out = out ? `${out} ${s}` : s;
-  }
-  if (out.length <= LIMIT + 40) return out;
-  return out.slice(0, LIMIT).replace(/\s+\S*$/, "") + "…";
-}
 
 const notes = {};

src/styles/global.css +1 −1
@@ -508,5 +508,5 @@ 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 { position: relative; 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; }

    ↑↓ to move, ↵ to open.