Cannabis Freedom Index

Changelog c4021c7

expand federated countries

7 files of code, design or docs +294 −21 lines

Code, design and documents 7 files

src/components/CountryArticle.astro +70 −15
@@ -4,9 +4,16 @@
 import { SEGMENTS, CONTEXT, REC_LABEL, MED_LABEL } from "../data/data.js";
 import { DETAILS } from "../data/details.js";
-import { countries, segAvg, segmentPoints, segmentMax } from "../lib/countries.js";
+import { countries, segAvg, segmentPoints, segmentMax, subunitsOf } from "../lib/countries.js";
 import { tierOf, tierColor } from "../lib/tiers.js";
 
-const { c } = Astro.props;
-const d = DETAILS[c.id];
+// `sub` is optional: one state, province or territory of `c`. Without it the national view is shown.
+const { c, sub } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const group = subunitsOf(c.id);
+const v = sub ?? c; // whichever entity's scores are on display
+const d = sub ? null : DETAILS[c.id];
+const countryUrl = `${base}country/${c.slug}/`;
+const unitUrl = (x) => `${countryUrl}${x.id}/`;
+const byName = group ? [...group.units].sort((a, b) => a.name.localeCompare(b.name)) : [];
 const r = 42;
 const circ = 2 * Math.PI * r;
@@ -17,6 +24,6 @@ const circ = 2 * Math.PI * r;
       <span class="d-flag">{c.flag}</span>
       <div>
-        <h1>{c.name}</h1>
-        <p class="sub">{c.region}</p>
+        <h1>{v.name}</h1>
+        <p class="sub">{sub ? `${group.label} · ${c.name}` : c.region}</p>
       </div>
     </div>
@@ -24,24 +31,50 @@ const circ = 2 * Math.PI * r;
       <svg class="ring" width="104" height="104" viewBox="0 0 104 104" aria-hidden="true">
         <circle cx="52" cy="52" r={r} fill="none" stroke="var(--border)" stroke-width="9" />
-        <circle cx="52" cy="52" r={r} fill="none" stroke={tierColor(c.raw)} stroke-width="9" stroke-linecap="round"
-          stroke-dasharray={`${(c.raw / 100) * circ} ${circ}`} transform="rotate(-90 52 52)" />
-        <text x="52" y="62" text-anchor="middle">{c.score}</text>
+        <circle cx="52" cy="52" r={r} fill="none" stroke={tierColor(v.raw)} stroke-width="9" stroke-linecap="round"
+          stroke-dasharray={`${(v.raw / 100) * circ} ${circ}`} transform="rotate(-90 52 52)" />
+        <text x="52" y="62" text-anchor="middle">{v.score}</text>
       </svg>
       <div>
-        <div class="tier">{tierOf(c.raw).name}</div>
-        <p class="sub">#{c.rank} of {countries.length} worldwide<br>#{c.regionRank} of {c.regionCount} in {c.region}</p>
+        <div class="tier">{tierOf(v.raw).name}</div>
+        {sub
+          ? <p class="sub">#{sub.rank} of {group.units.length} in {c.name}<br>would rank #{sub.worldRank} among countries</p>
+          : <p class="sub">#{c.rank} of {countries.length} worldwide<br>#{c.regionRank} of {c.regionCount} in {c.region}</p>}
       </div>
     </div>
     <div class="d-chips">
-      <span class={`chip s-${c.rec}`}>Recreational: {REC_LABEL[c.rec]}</span>
-      <span class={`chip s-${c.med}`}>Medical: {MED_LABEL[c.med]}</span>
+      <span class={`chip s-${v.rec}`}>Recreational: {REC_LABEL[v.rec]}</span>
+      <span class={`chip s-${v.med}`}>Medical: {MED_LABEL[v.med]}</span>
     </div>
-    <p class="d-note">{c.note}</p>
+    <p class="d-note">{v.note}</p>
+    {group && (
+      <label class="field sub-select">
+        <span>{group.label}</span>
+        <select data-subunit-select aria-label={`Choose a ${group.label.toLowerCase()} of ${c.name}`}>
+          <option value={countryUrl} selected={!sub}>All of {c.name} (national estimate)</option>
+          {byName.map((x) => <option value={unitUrl(x)} selected={sub?.id === x.id}>{x.name} · {x.score}</option>)}
+        </select>
+      </label>
+    )}
   </div>
 
   <div class="c-main">
     <h2 class="d-h">Score breakdown <span>▏world average</span></h2>
-    <p class="d-hint">Select a segment to show or hide the exact rules and sources.{d && ` Reviewed ${d.reviewed}.`}</p>
-    {SEGMENTS.map((s, i) => {
+    {sub
+      ? <p class="d-hint">{group.intro} Segment-by-segment explanations are written at national level — <a href={countryUrl} data-subunit>see {c.name}</a>.</p>
+      : <p class="d-hint">Select a segment to show or hide the exact rules and sources.{d && ` Reviewed ${d.reviewed}.`}</p>}
+    {sub && SEGMENTS.map((s, i) => (
+      <div class="segrow">
+        <div class="segrow-top">
+          <span>{s.name} <small>· {s.weight}%</small></span>
+          <b>{sub.s[i]}/10 <small class="vs">{c.name}: {c.s[i]}</small></b>
+        </div>
+        <div class="bar" title={`World average ${segAvg[i].toFixed(1)}/10`}>
+          <i style={`width:${sub.s[i] * 10}%;background:${tierColor(sub.s[i] * 10)}`}></i>
+          <span class="avg-tick" style={`left:calc(${segAvg[i] * 10}% - 1px)`}></span>
+        </div>
+        <div class="pts">adds {segmentPoints(i, sub.s[i]).toFixed(1)} of {segmentMax(i).toFixed(0)} points</div>
+      </div>
+    ))}
+    {!sub && SEGMENTS.map((s, i) => {
       const seg = d?.seg[s.detail];
       return (
@@ -72,4 +105,26 @@ const circ = 2 * Math.PI * r;
     })}
 
+    {group && (
+      <section class="c-units">
+        <h2 class="d-h">{sub ? `Other ${group.plural}` : `By ${group.label.toLowerCase()}`} <span>{group.units.length} scored</span></h2>
+        {!sub && <p class="d-hint">{group.intro}</p>}
+        <ol class="units">
+          {group.units.map((x) => (
+            <li class={sub?.id === x.id ? "current" : undefined}>
+              <a href={unitUrl(x)} data-subunit>
+                <span class="u-rank">{x.rank}</span>
+                <span class="u-name">{x.name}</span>
+                <span class="u-score">{x.score}</span>
+                <span class="bar"><i style={`width:${x.raw}%;background:${tierColor(x.raw)}`}></i></span>
+              </a>
+            </li>
+          ))}
+        </ol>
+        <ul class="srcs">
+          {group.sources.map(([label, url]) => <li><a href={url} target="_blank" rel="noopener noreferrer">{label}</a></li>)}
+        </ul>
+      </section>
+    )}
+
     {d && (
       <>

src/data/subunits.js +122 −0
@@ -0,0 +1,122 @@
+/* Cannabis Freedom Index — scores below national level
+ *
+ * For federations where the rules differ sharply between states, provinces or territories.
+ * Each unit is scored on the same six segments and levels as countries (see SEGMENTS in data.js):
+ * [possession, cultivation, enforcement, sharing, consumption, access]
+ *
+ * The home-page ranking and map still use the single national estimate in data.js.
+ * Units have a short note rather than a full segment-by-segment write-up.
+ * Scores are editorial estimates, not legal advice.
+ */
+
+const u = (id, name, s, rec, med, note) => ({ id, name, s, rec, med, note });
+
+export const SUBUNITS = {
+  US: {
+    label: "State",
+    plural: "states",
+    intro: "Federal law still prohibits non-medical cannabis everywhere in the United States; these scores describe state law. Counties and cities can add their own rules, and state law does not apply on federal land.",
+    sources: [
+      ["NORML — State-by-state laws", "https://norml.org/laws/"],
+      ["National Conference of State Legislatures — State cannabis laws", "https://www.ncsl.org/health/state-medical-cannabis-laws"],
+    ],
+    units: [
+      u("al", "Alabama", [1, 0, 1, 0, 1, 0], "illegal", "limited", "Possession for personal use is a misdemeanour with up to a year in jail; a second offence is a felony. A medical law passed in 2021 but products have been held up by licensing disputes."),
+      u("ak", "Alaska", [5, 4, 6, 5, 4, 6], "legal", "yes", "Adults 21+ may hold 1 oz and grow six plants (three mature). Licensed shops since 2016, and some may offer on-site consumption."),
+      u("az", "Arizona", [5, 4, 6, 5, 3, 6], "legal", "yes", "1 oz (5 g of concentrate) and six plants per adult, twelve per household. Licensed shops since 2021."),
+      u("ar", "Arkansas", [1, 1, 2, 1, 1, 3], "illegal", "yes", "Under 4 oz is a misdemeanour with up to a year in jail. Medical cannabis is sold in dispensaries to card holders."),
+      u("ca", "California", [5, 4, 6, 5, 4, 6], "legal", "yes", "28.5 g of flower (8 g of concentrate) and six plants per household. Shops, delivery and licensed consumption lounges; many cities still ban sales."),
+      u("co", "Colorado", [5, 4, 6, 5, 4, 6], "legal", "yes", "2 oz and six plants (three flowering). The first state with licensed shops, in 2014; hospitality venues are licensed in some cities."),
+      u("ct", "Connecticut", [6, 4, 6, 5, 3, 6], "legal", "yes", "1.5 oz on you and 5 oz locked at home; six plants (three mature), twelve per household. Licensed shops since 2023."),
+      u("de", "Delaware", [5, 1, 6, 5, 3, 6], "legal", "yes", "1 oz is legal and shops opened in 2025, but growing at home remains a crime."),
+      u("dc", "District of Columbia", [5, 4, 6, 5, 3, 4], "legal", "yes", "2 oz, six plants (three mature) and gifts of up to 1 oz are legal. Congress blocks licensed adult-use sales, so access runs through self-certified medical dispensaries."),
+      u("fl", "Florida", [1, 1, 2, 1, 1, 3], "illegal", "yes", "20 g or less is a misdemeanour with up to a year in jail. A large medical programme operates; a 2024 legalisation measure won 56% but needed 60%."),
+      u("ga", "Georgia", [1, 1, 2, 1, 1, 1], "illegal", "limited", "1 oz or less is a misdemeanour with up to a year in jail, though Atlanta and several cities have cut it to a small fine. Only low-THC oil is allowed medically."),
+      u("hi", "Hawaii", [2, 1, 3, 1, 1, 3], "decrim", "yes", "Up to 3 g is a $130 violation; more is a misdemeanour. Medical cannabis is dispensed to card holders, who may also grow."),
+      u("id", "Idaho", [1, 0, 1, 0, 1, 0], "illegal", "no", "No medical programme and no decriminalisation: under 3 oz is a misdemeanour with up to a year in jail and a mandatory minimum fine."),
+      u("il", "Illinois", [5, 2, 6, 5, 3, 6], "legal", "yes", "Residents may hold 30 g. Only medical patients may grow (five plants); for others a few plants is a $200 civil fine. Licensed shops since 2020."),
+      u("in", "Indiana", [1, 0, 2, 0, 1, 0], "illegal", "no", "Possession is a misdemeanour with up to 180 days in jail. No medical programme."),
+      u("ia", "Iowa", [1, 0, 2, 0, 1, 1], "illegal", "limited", "A first offence carries up to six months in jail. A narrow medical programme caps THC per patient."),
+      u("ks", "Kansas", [1, 0, 1, 0, 1, 0], "illegal", "no", "No medical programme. A first possession offence is a misdemeanour with up to six months in jail; later offences can be felonies."),
+      u("ky", "Kentucky", [1, 1, 2, 1, 1, 2], "illegal", "yes", "Up to 8 oz is a misdemeanour capped at 45 days in jail. A medical programme began in 2025; smoking flower is not allowed under it."),
+      u("la", "Louisiana", [3, 1, 4, 1, 2, 3], "decrim", "yes", "Up to 14 g is a $100 fine with no jail. Medical cannabis, including flower, is sold through licensed pharmacies."),
+      u("me", "Maine", [5, 4, 6, 5, 3, 6], "legal", "yes", "2.5 oz and three mature plants per adult. Licensed shops since 2020, alongside a long-standing medical caregiver market."),
+      u("md", "Maryland", [5, 3, 6, 5, 3, 6], "legal", "yes", "1.5 oz and two plants per household. Licensed shops since July 2023."),
+      u("ma", "Massachusetts", [6, 4, 6, 5, 3, 6], "legal", "yes", "1 oz in public and 10 oz at home; six plants per adult, twelve per household. Licensed shops since 2018."),
+      u("mi", "Michigan", [6, 5, 6, 5, 4, 6], "legal", "yes", "2.5 oz on you, 10 oz at home and twelve plants per household — the most generous home-grow limit in the country. Shops and licensed lounges."),
+      u("mn", "Minnesota", [6, 4, 6, 5, 3, 6], "legal", "yes", "2 oz in public and 2 lb at home; eight plants (four mature). Legal since 2023, with licensed shops opening from 2025."),
+      u("ms", "Mississippi", [3, 1, 4, 1, 2, 3], "decrim", "yes", "A first offence with up to 30 g is a $100–250 fine with no jail. Medical dispensaries serve card holders."),
+      u("mo", "Missouri", [6, 3, 6, 5, 3, 6], "legal", "yes", "3 oz is legal. Growing six flowering plants requires a state registration card. Licensed shops since 2023."),
+      u("mt", "Montana", [5, 3, 6, 5, 3, 6], "legal", "yes", "1 oz and two mature plants per adult, four per household. Shops operate only in counties that voted for legalisation."),
+      u("ne", "Nebraska", [3, 1, 4, 1, 2, 1], "decrim", "limited", "A first offence with up to 1 oz is a $300 infraction. Voters approved medical cannabis in 2024; the programme is still being set up."),
+      u("nv", "Nevada", [5, 2, 6, 5, 4, 6], "legal", "yes", "2.5 oz is legal. Home growing is allowed only if you live more than 25 miles from a shop. Licensed consumption lounges operate in Las Vegas."),
+      u("nh", "New Hampshire", [3, 1, 4, 1, 2, 3], "decrim", "yes", "Up to ¾ oz is a $100 violation. The only New England state without legal sales; a therapeutic programme serves patients."),
+      u("nj", "New Jersey", [6, 1, 6, 5, 3, 6], "legal", "yes", "Up to 6 oz is legal and shops have operated since 2022, but growing even one plant at home remains a serious crime."),
+      u("nm", "New Mexico", [6, 4, 6, 5, 4, 6], "legal", "yes", "2 oz in public and no limit at home; six mature plants per adult, twelve per household. Shops and licensed consumption areas."),
+      u("ny", "New York", [6, 4, 6, 5, 6, 6], "legal", "yes", "3 oz on you and 5 lb at home; six plants per adult. Uniquely, smoking cannabis is allowed almost anywhere tobacco smoking is."),
+      u("nc", "North Carolina", [2, 1, 3, 1, 1, 0], "decrim", "no", "Up to ½ oz is a misdemeanour punished by a fine of up to $200 with no jail. No medical programme; the only legal shop is on Cherokee tribal land."),
+      u("nd", "North Dakota", [2, 1, 3, 1, 1, 3], "decrim", "yes", "Up to ½ oz is an infraction with a fine. Medical cannabis is available; voters rejected legalisation in 2018, 2022 and 2024."),
+      u("oh", "Ohio", [5, 4, 6, 5, 3, 6], "legal", "yes", "2.5 oz and six plants per adult, twelve per household. Licensed shops since August 2024; lawmakers have since tightened rules on public use."),
+      u("ok", "Oklahoma", [1, 1, 2, 1, 1, 3], "illegal", "yes", "Without a card, possession is a misdemeanour. But the medical programme has no list of qualifying conditions and is among the easiest to join; patients may grow six mature plants."),
+      u("or", "Oregon", [6, 4, 6, 5, 3, 6], "legal", "yes", "2 oz in public and 8 oz at home; four plants per household. Licensed shops since 2015."),
+      u("pa", "Pennsylvania", [1, 1, 2, 1, 1, 3], "illegal", "yes", "30 g or less is a misdemeanour with up to 30 days in jail, cut to a small fine in Philadelphia, Pittsburgh and other cities. A large medical programme operates."),
+      u("ri", "Rhode Island", [6, 4, 6, 5, 3, 6], "legal", "yes", "1 oz in public and 10 oz at home; three mature plants. Licensed shops since December 2022."),
+      u("sc", "South Carolina", [1, 0, 2, 0, 1, 0], "illegal", "no", "1 oz or less is a misdemeanour with up to 30 days in jail. No medical programme."),
+      u("sd", "South Dakota", [1, 1, 2, 1, 0, 3], "illegal", "yes", "Up to 2 oz is a misdemeanour with up to a year in jail, and ingesting cannabis is itself an offence. Medical cannabis is available to card holders."),
+      u("tn", "Tennessee", [1, 0, 1, 0, 1, 0], "illegal", "no", "½ oz or less is a misdemeanour with up to a year in jail. Only low-THC oil is permitted for a few conditions."),
+      u("tx", "Texas", [1, 0, 2, 0, 1, 1], "illegal", "limited", "2 oz or less is a misdemeanour with up to 180 days in jail; several cities no longer enforce it. A restricted low-THC medical programme was widened in 2025."),
+      u("ut", "Utah", [1, 1, 2, 1, 1, 3], "illegal", "yes", "Under 1 oz is a misdemeanour with up to six months in jail. Medical cannabis is sold through licensed pharmacies; smoking it is not allowed."),
+      u("vt", "Vermont", [5, 4, 6, 5, 3, 6], "legal", "yes", "1 oz and two mature plus four immature plants per household. The first state to legalise through its legislature; shops since 2022."),
+      u("va", "Virginia", [5, 4, 6, 5, 3, 3], "legal", "yes", "1 oz and four plants per household have been legal since 2021, but adult-use shops were repeatedly vetoed. Medical dispensaries are the only legal place to buy."),
+      u("wa", "Washington", [5, 1, 6, 4, 3, 6], "legal", "yes", "1 oz is legal and shops have operated since 2014, but only medical patients may grow; for everyone else it is a felony."),
+      u("wv", "West Virginia", [1, 1, 2, 1, 1, 3], "illegal", "yes", "Possession carries 90 days to six months in jail, with conditional discharge for a first offence. Medical dispensaries serve card holders; no flower smoking."),
+      u("wi", "Wisconsin", [1, 0, 2, 0, 1, 0], "illegal", "no", "A first offence is a misdemeanour with up to six months in jail; a second is a felony. No medical programme, though Madison and Milwaukee impose only small fines."),
+      u("wy", "Wyoming", [1, 0, 1, 0, 0, 0], "illegal", "no", "Up to 3 oz is a misdemeanour with up to a year in jail, and being under the influence is a separate offence. No medical programme."),
+    ],
+  },
+
+  CA: {
+    label: "Province or territory",
+    plural: "provinces and territories",
+    intro: "The federal Cannabis Act applies everywhere in Canada. Provinces and territories set the minimum age, where you may consume, how shops work, and whether you may grow at home.",
+    sources: [
+      ["Department of Justice Canada — Cannabis legalization and regulation", "https://www.justice.gc.ca/eng/cj-jp/cannabis/"],
+      ["Health Canada — Cannabis laws and regulations", "https://www.canada.ca/en/health-canada/services/drugs-medication/cannabis/laws-regulations.html"],
+    ],
+    units: [
+      u("ab", "Alberta", [6, 4, 6, 5, 6, 6], "legal", "yes", "Minimum age 18, the lowest in Canada. Private shops; consumption allowed where tobacco is, subject to city by-laws."),
+      u("bc", "British Columbia", [6, 4, 6, 5, 6, 6], "legal", "yes", "Age 19. Public and private shops; smoking generally allowed where tobacco is, except around children."),
+      u("mb", "Manitoba", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Private shops. Home growing was banned until May 2025; public consumption is prohibited."),
+      u("nb", "New Brunswick", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Government-run Cannabis NB plus some private shops; consumption only in private."),
+      u("nl", "Newfoundland and Labrador", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Private licensed shops; consumption only in private residences."),
+      u("ns", "Nova Scotia", [6, 4, 6, 5, 5, 6], "legal", "yes", "Age 19. Sold through the provincial liquor corporation; smoking follows tobacco rules."),
+      u("nt", "Northwest Territories", [6, 4, 6, 5, 4, 6], "legal", "yes", "Age 19. Government-run sales; communities may restrict or ban cannabis locally."),
+      u("nu", "Nunavut", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Sales mostly online through the territorial agency, with few physical shops."),
+      u("on", "Ontario", [6, 4, 6, 5, 6, 6], "legal", "yes", "Age 19. The largest private retail market in the country; smoking allowed almost anywhere tobacco is."),
+      u("pe", "Prince Edward Island", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Government-run shops only; consumption limited to private residences."),
+      u("qc", "Quebec", [5, 1, 6, 5, 3, 5], "legal", "yes", "The strictest province: age 21, no home growing, a 150 g cap at home, no public consumption, and sales only through the state-run SQDC with a narrower product range."),
+      u("sk", "Saskatchewan", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Private shops; consumption in public places is prohibited."),
+      u("yt", "Yukon", [6, 4, 6, 5, 3, 6], "legal", "yes", "Age 19. Private shops supplied by the territorial corporation; consumption in private only."),
+    ],
+  },
+
+  AU: {
+    label: "State or territory",
+    plural: "states and territories",
+    intro: "Each Australian state and territory has its own drug law. Medical cannabis is regulated federally and is available on prescription everywhere, mostly through telehealth clinics.",
+    sources: [
+      ["Wikipedia — Cannabis in Australia", "https://en.wikipedia.org/wiki/Cannabis_in_Australia"],
+      ["The Conversation — ACT cannabis laws come into effect", "https://theconversation.com/act-cannabis-laws-come-into-effect-on-friday-but-they-may-not-be-what-you-hoped-for-130050"],
+    ],
+    units: [
+      u("act", "Australian Capital Territory", [5, 3, 6, 1, 3, 3], "legal", "yes", "Since 2020 adults may hold 50 g of dried cannabis and grow two plants each, four per household. Selling, gifting and even buying seeds remain illegal."),
+      u("nsw", "New South Wales", [2, 1, 3, 1, 1, 3], "illegal", "yes", "Police may issue a caution for up to 15 g, at most twice; otherwise possession is an offence with up to two years."),
+      u("nt", "Northern Territory", [3, 2, 4, 1, 2, 3], "decrim", "yes", "Up to 50 g, or two plants at home, is dealt with by an infringement notice and a fine rather than a charge."),
+      u("qld", "Queensland", [2, 1, 4, 1, 1, 3], "illegal", "yes", "Police must offer a warning or a diversion programme for small amounts before charging, under a scheme expanded in 2024."),
+      u("sa", "South Australia", [3, 2, 4, 1, 2, 3], "decrim", "yes", "The first Australian state to decriminalise, in 1987: small amounts and one non-hydroponic plant are an expiation notice with a fine."),
+      u("tas", "Tasmania", [2, 1, 3, 1, 1, 3], "illegal", "yes", "Police may caution for up to 50 g, up to three times in ten years; otherwise it is a criminal offence."),
+      u("vic", "Victoria", [2, 1, 3, 1, 1, 3], "illegal", "yes", "A cannabis cautioning programme covers up to 50 g for people who admit the offence; otherwise possession is prosecuted."),
+      u("wa", "Western Australia", [2, 1, 3, 1, 1, 3], "illegal", "yes", "Up to 10 g leads to a one-off intervention session instead of a charge; beyond that it is a criminal offence."),
+    ],
+  },
+};

src/lib/countries.js +20 −0
@@ -24,2 +24,22 @@ export const regions = [...new Set(countries.map((c) => c.region))].sort();
 export const segAvg = SEGMENTS.map((_, i) => countries.reduce((a, c) => a + c.s[i], 0) / countries.length);
 export const worldAvg = countries.reduce((a, c) => a + c.raw, 0) / countries.length;
+
+// ── States, provinces and territories of federations ──
+import { SUBUNITS } from "../data/subunits.js";
+
+const scoreOf = (s) => s.reduce((a, v, i) => a + segmentPoints(i, v), 0);
+
+// For a country id, returns { label, plural, intro, sources, units } with each unit scored,
+// ranked within its country, and placed where it would sit among countries. Undefined if none.
+export function subunitsOf(id) {
+  const group = SUBUNITS[id];
+  if (!group) return undefined;
+  const units = group.units
+    .map((x) => {
+      const raw = scoreOf(x.s);
+      return { ...x, raw, score: Math.round(raw), worldRank: 1 + countries.filter((c) => c.raw > raw).length };
+    })
+    .sort((a, b) => b.raw - a.raw || a.name.localeCompare(b.name));
+  units.forEach((x, i) => (x.rank = i + 1));
+  return { ...group, units };
+}

src/pages/country/[id].astro +7 −0
@@ -31,3 +31,10 @@ const description = `${c.name} scores ${c.score} out of 100 on the Cannabis Free
     </nav>
   </main>
+  <script>
+    // On a full page the state/province picker simply navigates.
+    document.addEventListener("change", (e) => {
+      const select = e.target.closest("select[data-subunit-select]");
+      if (select) location.href = select.value;
+    });
+  </script>
 </Base>

src/pages/country/[id]/[sub].astro +32 −0
@@ -0,0 +1,32 @@
+---
+// One page per state, province or territory of a federation, e.g. /country/us/ca/.
+import Base from "../../../layouts/Base.astro";
+import CountryArticle from "../../../components/CountryArticle.astro";
+import { countries, subunitsOf } from "../../../lib/countries.js";
+import { tierOf } from "../../../lib/tiers.js";
+
+export function getStaticPaths() {
+  return countries.flatMap((c) =>
+    (subunitsOf(c.id)?.units ?? []).map((sub) => ({ params: { id: c.slug, sub: sub.id }, props: { c, sub } }))
+  );
+}
+
+const { c, sub } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const description = `${sub.name} (${c.name}) scores ${sub.score} out of 100 on the Cannabis Freedom Index (${tierOf(sub.raw).name}). ${sub.note}`;
+---
+<Base title={`${sub.name}, ${c.name}`} description={description}>
+  <main class="country-page">
+    <div class="page-bar">
+      <a class="back" href={`${base}country/${c.slug}/`}>← {c.flag} {c.name}</a>
+    </div>
+    <CountryArticle c={c} sub={sub} />
+  </main>
+  <script>
+    // On a full page the picker simply navigates.
+    document.addEventListener("change", (e) => {
+      const select = e.target.closest("select[data-subunit-select]");
+      if (select) location.href = select.value;
+    });
+  </script>
+</Base>

src/pages/index.astro +27 −6
@@ -321,14 +321,35 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) =>
     const cache = new Map();
 
-    async function loadArticle(id) {
-      if (!cache.has(id)) {
-        const html = await (await fetch(pageUrl(id))).text();
+    // Articles are cached by page address, which also covers states and provinces.
+    async function loadArticle(url) {
+      if (!cache.has(url)) {
+        const html = await (await fetch(url)).text();
         const article = new DOMParser().parseFromString(html, "text/html").querySelector("article.country");
         // The full page shows every explanation; in the drawer they start collapsed.
         article.querySelectorAll("details[open]").forEach((el) => el.removeAttribute("open"));
-        cache.set(id, article);
+        cache.set(url, article);
       }
-      return cache.get(id).cloneNode(true);
+      return cache.get(url).cloneNode(true);
     }
+    // Swap the drawer to another view of the same country (a state, or back to national).
+    async function showInDrawer(url) {
+      try {
+        body.replaceChildren(await loadArticle(url));
+        $("#dExpand").href = url;
+        dialog.scrollTop = 0;
+      } catch (err) {
+        location.href = url;
+      }
+    }
+    body.addEventListener("change", (e) => {
+      const select = e.target.closest("select[data-subunit-select]");
+      if (select) showInDrawer(select.value);
+    });
+    body.addEventListener("click", (e) => {
+      const a = e.target.closest("a[data-subunit]");
+      if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+      e.preventDefault();
+      showInDrawer(a.getAttribute("href"));
+    });
 
     function markActive() {
@@ -352,5 +373,5 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) =>
       $("#dCompare").href = `${base}compare/?c=${id.toLowerCase()}`;
       try {
-        const article = await loadArticle(id);
+        const article = await loadArticle(pageUrl(id));
         if (currentId() !== id) return;
         body.replaceChildren(article);

src/styles/global.css +16 −0
@@ -411,2 +411,18 @@ td.num, th.num { width: 52px; color: var(--muted); }
 .lore-src a:hover { color: var(--text); }
 @media (max-width: 860px) { .lore-grid { grid-template-columns: 1fr; } }
+
+/* ── States, provinces and territories ──────────────── */
+.sub-select { margin-top: 14px; flex-wrap: wrap; }
+.sub-select select { flex: 1; min-width: 200px; max-width: 100%; }
+.d-hint a { color: var(--text-2); text-decoration: underline; text-underline-offset: 3px; }
+.segrow-top .vs { color: var(--muted); font-weight: 400; margin-left: 8px; font-size: 12.5px; }
+.units { list-style: none; margin: 8px 0 12px; padding: 0; columns: 2; column-gap: 28px; }
+.drawer-inner .units { columns: 1; }
+.units li { break-inside: avoid; }
+.units a { display: grid; grid-template-columns: 26px minmax(0, 1fr) 30px 70px; gap: 8px; align-items: center; padding: 5px 8px; margin: 0 -8px; border-radius: 8px; font-size: 14px; }
+.units a:hover { background: var(--surface-2); }
+.units .current a { background: var(--surface-2); font-weight: 600; }
+.u-rank { color: var(--muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }
+.u-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.u-score { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
+@media (max-width: 640px) { .units { columns: 1; } }

    ↑↓ to move, ↵ to open.