Cannabis Freedom Index

Changelog c96f787

new history layout

7 history entries added10 atlas entries written22 atlas entries edited35 files of code, design or docs +2,480 −354 lines

History changes.yaml

Added 7

Articles and atlas entries 32

Bhang culture/language/bhang.md

history

india-bhang-tax-1798, india-hemp-drugs-commission-1894, india-bhang-exemption-1961, india-ndps-act-1985, india-gujarat-bhang-2017, iran-penal-law-1926, india-assam-ganja-bhang-act-1958, malaya-bhang-tax-1826, guyana-licensing-1861, india-bombay-abkari-act-1878, india-bombay-possession-limit-1913, india-state-prohibition-laws-1949, single-convention-phase-out-1989, india-karnataka-bhang-ruling-2022

Chamba culture/language/chamba.md

related

language/dagga, language/mbanje, language/matekoane, language/bangi

Dagga culture/language/dagga.md

history

south-africa-dagga-party-2009, south-africa-natal-1870, cape-colony-dagga-poison-listing-1 … ittee-1952, south-africa-constitutional-court-2018, south-africa-private-purposes-act-2024, natal-law-tightened-1887

Ganja culture/language/ganja.md

history

bangladesh-ganja-society-1917, jamaica-ganja-law-1913, trinidad-ganja-ordinance-1925, indi … ation-ban-1987, bangladesh-ban-1989, india-bhang-tax-1798, thailand-decriminalisation-2022, guyana-licensing-1861, suriname-ganja-petition-1921, jamaica-ganja-commission-2001

Hashish culture/language/hashish.md

history

egypt-first-ban-1868, iran-penal-law-1926, afghanistan-hashish-fertiliser-edicts-1969, egy … -hashish-ban-1890, lebanon-hashish-ban-1926, nepal-shops-closed-1973, afghanistan-ban-1973, uk-hashish-poets-1896

related

traditions/hashish-in-egypt, traditions/club-des-hashischins, language/charas, language/zatla

Kif culture/language/kif.md

history

morocco-hassan-regulations-1890, morocco-rif-republic-kif-ban-1923, morocco-french-zone-ba … y-1956, morocco-cultivation-survey-2003, morocco-law-13-21-2021, morocco-royal-pardon-2024, morocco-spanish-zone-decree-1935, morocco-kif-seizures-1949

Matekoane culture/language/matekoane.md

related

language/dagga, language/mbanje, language/chamba

Mbanje culture/language/mbanje.md

related

language/dagga, language/chamba, language/matekoane

Zatla culture/language/zatla.md

related

language/kif, language/hashish, traditions/kif-and-the-sebsi, traditions/hashish-in-egypt

The Bena Riamba culture/traditions/bena-riamba.md

related

language/diamba, language/bangi, traditions/the-morning-pipe

Bhang at Holi culture/traditions/bhang-at-holi.md

history

india-hemp-drugs-commission-1894, india-bhang-exemption-1961, india-ndps-act-1985, india-gujarat-bhang-2017, india-assam-ganja-bhang-act-1958, single-convention-phase-out-1989, india-karnataka-bhang-ruling-2022

Cannabis social clubs culture/traditions/cannabis-social-clubs.md

history

spain-arsec-conviction-1997, spain-social-clubs-2001, uruguay-club-registry-2014, spain-su … t-law-2021, germany-cultivation-associations-2024, spain-smokers-club-exemption-draft-2026, spain-canamo-magazine-1993, belgium-trekt-uw-plant-2006, spain-rasquera-plantation-2012, malta-first-associations-2024, malta-cannabis-amendments-2025

related

traditions/the-coffeeshop, traditions/global-marijuana-march

The Club des Hashischins culture/traditions/club-des-hashischins.md

related

language/hashish, traditions/hashish-in-egypt, traditions/rebetiko-and-the-tekes

Hashish in Egypt culture/traditions/hashish-in-egypt.md

related

language/hashish, language/zatla, traditions/rebetiko-and-the-tekes

Hemp in Shinto culture/traditions/hemp-in-shinto.md

related

language/ma, traditions/hmong-hemp-cloth, traditions/hemp-in-the-taoist-censer

The Italian hemp rope-makers culture/traditions/italian-hemp-rope-makers.md

related

language/hemp, traditions/hmong-hemp-cloth, traditions/hemp-in-shinto

Kif and the sebsi culture/traditions/kif-and-the-sebsi.md

history

morocco-hassan-regulations-1890, morocco-french-zone-ban-1954, morocco-kif-ban-1956, morocco-spanish-zone-dahir-1954, morocco-law-13-21-2021, morocco-spanish-zone-decree-1935, morocco-kif-seizures-1949

Nimbin MardiGrass culture/traditions/nimbin-mardigrass.md

related

traditions/hempfest, traditions/global-marijuana-march, traditions/hash-bash

Pusher Street culture/traditions/pusher-street.md

history

denmark-medical-pilot-2018, denmark-euphoriants-act-1955, denmark-christiania-junk-blockade-1979, denmark-christiania-biker-gang-1984, denmark-pusher-street-closed-2024, denmark-fight-against-drugs-plan-2003

The Rastafari sacrament culture/traditions/rastafari-sacrament.md

history

ethiopia-shashamane-settlement-1965, italy-rastafari-ruling-2008, jamaica-ganja-reform-201 … -2023, saint-vincent-religious-bill-dropped-2018, kenya-high-court-rastafari-petition-2026, jamaica-ganja-commission-2001, ethiopia-criminal-code-2005, ethiopia-us-warning-rastafari-2005, namibia-constitutional-challenge-2021, grenada-bill-2021

Rebetiko and the tekés culture/traditions/rebetiko-and-the-tekes.md

related

language/hashish, traditions/club-des-hashischins, traditions/hashish-in-egypt

The elder's morning pipe culture/traditions/the-morning-pipe.md

related

language/dagga, language/matekoane, language/mbanje, traditions/bena-riamba

Code, design and documents 35 files

docs/CULTURE.md +10 −2
@@ -123,4 +123,5 @@ also: [bangue, bang]               # other spellings, related words, names to be
 countries: [in, ir]                # file names in /countries, the most closely tied first
 history: [india-bhang-exemption-1961, india-ndps-act-1985]   # ids in changes.yaml
+related: [traditions/bhang-at-holi, language/ganja]          # optional: closest atlas entries first
 image:                             # the lead picture, optional
   src: ../../src/assets/culture/example.jpg   # a local file, relative to this Markdown file
@@ -211,6 +212,13 @@ The site turns that into a figure with the caption and credit underneath. Rules:
 - A country may be listed for a law about the custom (Italy for its Rastafari ruling) when the
   text says so.
-- `history` lists the entries the text mentions or relies on. If the text names a law that has
-  no history entry, add the entry to `changes.yaml` first.
+- `history` lists the entries the text mentions or relies on, and every history entry that is
+  about the word or the custom itself (a coffeeshop law belongs to The coffeeshop even when the
+  text does not mention it). Each one listed shows the atlas entry beside it on its history page,
+  so a reader who opens the law finds the culture behind it. If the text names a law that has no
+  history entry, add the entry to `changes.yaml` first.
+- Beside each entry the page lists up to eight related words and traditions. They are worked out
+  on their own: entries the text links to or that link to it, entries sharing history entries,
+  and entries kept in two or more of the same countries. Use `related` to put the closest ones
+  first, or to tie in an entry the rules above miss (Chamba to Dagga, Zatla to Kif).
 
 ## Sources and care

source-archive.json +939 −115

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/bijoya-dashami-kolkata-2021.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/durga-bhasan-baghbazar.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/hemp-seeds-on-the-plant.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/jirzankal-braziers.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/louis-armstrong-1946.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/padrinho-sebastiao.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/assets/culture/reunion-marche-sur-le-feu.jpg +0 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/components/CountryArticle.astro +3 −17
@@ -12,6 +12,6 @@ import { PLANTING_STATUS, STATUS_SECTIONS, PLANTING_TRADE } from "../lib/segment
 import { answersFor } from "../lib/answers.js";
 import SegmentScale from "./SegmentScale.astro";
-import { Image } from "astro:assets";
-import { cultureOf, cultureSection } from "../lib/culture.js";
+import CultureList from "./CultureList.astro";
+import { cultureOf } from "../lib/culture.js";
 
 // `sub` is optional: one state, province or territory of `c`. Without it the national view is shown.
@@ -151,19 +151,5 @@ const culture = sub ? [] : cultureOf(c);
     {SHOW_PRICES && !sub && <PricePanel id={c.id} />}
     {scored.length > 0 && <CountryChart history={scored} context={history.filter((ch) => !ch.score)} />}
-    {culture.length > 0 && (
-      <section class="c-culture">
-        <h2>Culture <a href={`${base}culture/`}>The atlas →</a></h2>
-        <ul>
-          {culture.map((e) => (
-            <li>
-              <a href={`${base}${e.url}`} title={e.summary}>
-                {e.image ? <Image src={e.image.src} alt="" width={104} height={104} fit="cover" /> : <span class="c-culture-none" aria-hidden="true">{e.title.slice(0, 1)}</span>}
-                <span><small>{cultureSection(e.section).name}</small><b>{e.title}{e.spelling && e.spelling[0].text.toLowerCase() !== e.title.toLowerCase() && <i> {e.spelling[0].text}</i>}</b></span>
-              </a>
-            </li>
-          ))}
-        </ul>
-      </section>
-    )}
+    {culture.length > 0 && <CultureList entries={culture} heading="Culture" more={["The atlas →", `${base}culture/`]} />}
   </div>
 

src/components/CultureList.astro +22 −0
@@ -0,0 +1,22 @@
+---
+// A short list of atlas entries with their pictures: on a country page, a history entry and
+// beside another atlas entry. `more` is the link in the heading, as [label, href].
+import { Image } from "astro:assets";
+import { cultureSection } from "../lib/culture.js";
+
+const { entries, heading, more } = Astro.props;
+const base = import.meta.env.BASE_URL;
+---
+<section class="c-culture">
+  <h2>{heading}{more && <a href={more[1]}>{more[0]}</a>}</h2>
+  <ul>
+    {entries.map((e) => (
+      <li>
+        <a href={`${base}${e.url}`} title={e.summary}>
+          {e.image ? <Image src={e.image.src} alt="" width={104} height={104} fit="cover" /> : <span class="c-culture-none" aria-hidden="true">{e.title.slice(0, 1)}</span>}
+          <span><small>{cultureSection(e.section).name}</small><b>{e.title}{e.spelling && e.spelling[0].text.toLowerCase() !== e.title.toLowerCase() && <i> {e.spelling[0].text}</i>}</b></span>
+        </a>
+      </li>
+    ))}
+  </ul>
+</section>

src/components/HistoryChart.astro +72 −0
@@ -0,0 +1,72 @@
+---
+// The History overview in one plot: one bar per decade counting the entries, the part that moved a
+// score drawn darker, and over them the world average score since 1860 as a line. The score is
+// read on the left axis, the number of entries on the right. Each bar links to its decade's page; on the continuous list at /history/ a script jumps
+// to that decade in place instead. `current` marks the decade being shown.
+import { averageHistory, historyPeriods, HISTORY_FROM, dateTime, lastUpdated } from "../lib/countries.js";
+
+const { current } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const num = (n) => n.toLocaleString("en-GB");
+
+// one slot per decade on the time axis, the entries before 1860 in a slot of their own before it
+const X0 = HISTORY_FROM - 10;
+const X1 = historyPeriods[0].from + 10;
+const x = (yearFraction) => ((yearFraction - X0) / (X1 - X0)) * 100;
+const yearOf = (iso) => { const d = new Date(dateTime(iso)); return d.getUTCFullYear() + d.getUTCMonth() / 12 + (d.getUTCDate() - 1) / 365; };
+
+// the line, as in AverageChart: a step at each recorded change
+const points = averageHistory.filter((p) => +p.date.slice(0, 4) >= HISTORY_FROM);
+const start = points[0].before;
+const now = points.at(-1).after;
+const values = points.flatMap((p) => [p.before, p.after]);
+const yMin = Math.floor(Math.min(...values) / 10) * 10;
+const yMax = Math.ceil(Math.max(...values) / 10) * 10;
+const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100;
+const ticks = Array.from({ length: (yMax - yMin) / 10 + 1 }, (_, i) => yMin + i * 10);
+const line = `M${x(HISTORY_FROM)},${y(start)}` + points.map((p) => `H${x(yearOf(p.date))}V${y(p.after)}`).join("") + `H${x(yearOf(lastUpdated))}`;
+
+const most = Math.max(...historyPeriods.map((p) => p.items.length));
+// the right axis: round steps for the number of entries
+const countStep = most > 400 ? 100 : most > 200 ? 50 : most > 80 ? 20 : 10;
+const countMax = Math.ceil(most / countStep) * countStep;
+const countTicks = Array.from({ length: countMax / countStep + 1 }, (_, i) => i * countStep);
+const bars = [...historyPeriods].reverse().map((p) => {
+  const slot = p.from < HISTORY_FROM ? X0 : p.from;
+  const scored = p.items.filter((ch) => ch.score).length;
+  return {
+    p, scored, all: p.items.length,
+    left: x(slot), width: x(slot + 10) - x(slot),
+    height: (p.items.length / countMax) * 100,
+    title: `${p.label}: ${num(p.items.length)} ${p.items.length === 1 ? "entry" : "entries"}, ${num(scored)} moved a score`,
+  };
+});
+---
+<section class="hy" aria-labelledby="hyTitle">
+  <div class="hy-head">
+    <h2 id="hyTitle">World average score <span>and entries per decade</span></h2>
+    <p class="hy-now"><b>{now.toFixed(1)}</b> <span>today, from {start.toFixed(1)} in {HISTORY_FROM}</span></p>
+  </div>
+  <div class="hy-chart">
+    <div class="hy-y" aria-hidden="true">{ticks.map((t) => <span style={`top:${y(t)}%`}>{t}</span>)}</div>
+    <div class="hy-plot">
+      <nav class="hy-bars" aria-label="Decades">
+        {bars.map((b) => (
+          <a href={`${base}history/period/${b.p.slug}/`} data-from={b.p.from} data-to={b.p.to} title={b.title} aria-label={b.title}
+            aria-current={b.p === current ? "page" : undefined} style={`left:${b.left}%;width:${b.width}%`}>
+            <span class="hy-n">{b.all}</span>
+            <i style={`height:${Math.max(1, b.height)}%`}><i style={`height:${b.all ? (b.scored / b.all) * 100 : 0}%`}></i></i>
+            <span class="hy-label">{b.p.short}</span>
+          </a>
+        ))}
+      </nav>
+      <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="halo" d={line} />
+        <path class="line" d={line} />
+      </svg>
+    </div>
+    <div class="hy-y2" aria-hidden="true">{countTicks.map((t) => <span style={`top:${100 - (t / countMax) * 100}%`}>{num(t)}</span>)}</div>
+  </div>
+  <p class="hy-key"><span class="k-line"></span> World average score, every country counted the same (left) <span class="k-scored"></span> Entries that moved a score <span class="k-context"></span> Context only (right)</p>
+</section>

src/components/HistoryList.astro +212 −96
@@ -1,15 +1,19 @@
 ---
-// One page of the History list, newest first and grouped by year. It shows either a period
-// (`period`) or every entry of one kind (`kind`, a key of CHANGE_CATEGORIES).
-// Top to bottom: the history in numbers, the periods as a small histogram that doubles as
-// navigation, the kinds as filters, then the entries as a ledger with the year set large beside them.
-import ScoreChange from "./ScoreChange.astro";
-import { changes, articles, places, historyPeriods, formatDate, CHANGE_CATEGORIES } from "../lib/countries.js";
-import { tierColor } from "../lib/tiers.js";
+// The History list, grouped by year. Three uses:
+// - with nothing: the continuous list at /history/. The newest years are in the page; the rest are
+//   fetched a year at a time (src/pages/fragments/history/[year].astro) as the reader scrolls, in
+//   either order, from the newest entry to the oldest. ?from=1894 opens it at a year, and every year's
+//   heading links there, so each year has a permanent address.
+// - with `period`: one decade, complete, for linking and for reading without scripts.
+// - with `kind` (a key of CHANGE_CATEGORIES): every entry of that kind.
+// Top to bottom: the chart of the world average with the entries per decade (which doubles as
+// navigation), the kinds as filters, then the ledger.
+import HistoryChart from "./HistoryChart.astro";
+import HistoryYears from "./HistoryYears.astro";
+import { changes, places, historyPeriods, historyYears, CHANGE_CATEGORIES } from "../lib/countries.js";
 
 const { period, kind } = Astro.props;
+const feed = !period && !kind;
 const base = import.meta.env.BASE_URL;
-// the newest period lives at /history/ itself
-const href = (p) => (p === historyPeriods[0] ? `${base}history/` : `${base}history/period/${p.slug}/`);
 const index = historyPeriods.indexOf(period);
 const newer = historyPeriods[index - 1];
@@ -17,20 +21,21 @@ const older = historyPeriods[index + 1];
 // estimated entries have no real date, so they are not listed
 const dated = changes.filter((ch) => !ch.estimate);
-const items = kind ? dated.filter((ch) => ch.category === kind) : period.items;
+const byYear = (items) => [...new Set(items.map((ch) => +ch.date.slice(0, 4)))]
+  .map((year) => ({ year, items: items.filter((ch) => +ch.date.slice(0, 4) === year) }));
+// the continuous list starts with the newest years, enough of them to fill a screen or two
+const FIRST = 40;
+const firstYears = [];
+for (const y of historyYears) { if (firstYears.reduce((n, x) => n + x.items.length, 0) >= FIRST) break; firstYears.push(y); }
+const years = feed ? firstYears : byYear(kind ? dated.filter((ch) => ch.category === kind) : period.items);
+const items = years.flatMap((y) => y.items);
 const kindCount = (key) => dated.filter((ch) => ch.category === key).length;
-const years = [...new Set(items.map((ch) => ch.date.slice(0, 4)))]
-  .map((year) => ({ year, items: items.filter((ch) => ch.date.startsWith(year)) }));
-// The whole history in numbers, for the side panel. The same on every page of the list.
+// every year with entries, newest first, with its counts: [year, all, moved a score]
+const yearIndex = JSON.stringify(historyYears.map((y) => [y.year, y.items.length, y.items.filter((ch) => ch.score).length]));
+// Figures for the filters and the lists of most and fewest entries. The same on every page.
 const num = (n) => n.toLocaleString("en-GB");
 const perPlace = new Map();
 for (const ch of changes) if (ch.country) perPlace.set(ch.country, (perPlace.get(ch.country) ?? 0) + 1);
 const stats = {
-  total: changes.length,
   scored: dated.filter((ch) => ch.score).length,
-  context: dated.filter((ch) => !ch.score).length,
-  estimated: changes.length - dated.length,
-  checked: dated.filter((ch) => ch.checked).length,
-  international: dated.filter((ch) => !ch.country).length,
-  places: perPlace.size,
   oldest: dated.at(-1),
   newest: dated[0],
@@ -39,27 +44,9 @@ const stats = {
   fewest: places.map((c) => [c, perPlace.get(c) ?? 0]).sort((x, y) => x[1] - y[1] || x[0].name.localeCompare(y[0].name)).slice(0, 8),
 };
-// the tallest period sets the scale of the histogram
-const most = Math.max(...historyPeriods.map((p) => p.items.length));
+const all = feed ? dated.length : items.length;
+const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length;
 ---
 <div class="hist">
-<dl class="hist-stats" aria-label="History in numbers">
-  <div><dt>Entries in total</dt><dd>{num(stats.total)}</dd></div>
-  <div><dt>{kind ? "Of this kind" : "In this period"}</dt><dd>{num(items.length)}</dd></div>
-  <div><dt>Moved a score</dt><dd>{num(stats.scored)}</dd></div>
-  <div><dt>Context only</dt><dd>{num(stats.context)}</dd></div>
-  <div><dt>Countries and territories</dt><dd>{num(stats.places)}</dd></div>
-  <div><dt>Tied to no country</dt><dd>{num(stats.international)}</dd></div>
-</dl>
-<p class="hist-note">{num(stats.checked)} of the {num(dated.length)} dated entries ({Math.round((stats.checked / dated.length) * 100)}%) were checked against a source that was read. Another {num(stats.estimated)} are estimated first bans with no known date; they are not listed here. The oldest entry is from <a href={`${base}history/${stats.oldest.id}/`}>{stats.oldest.date.slice(0, 4)}</a>, the newest from <a href={`${base}history/${stats.newest.id}/`}>{formatDate(stats.newest.date)}</a>.</p>
-
-<nav class="hist-periods" aria-label="Periods">
-  {[...historyPeriods].reverse().map((p) => (
-    <a href={href(p)} aria-current={p === period ? "page" : undefined} title={`${p.label}: ${p.items.length} entries`}>
-      <span class="hp-n">{p.items.length}</span>
-      <i style={`height:${Math.max(3, (p.items.length / most) * 100)}%`}></i>
-      <span class="hp-label">{p.short}</span>
-    </a>
-  ))}
-</nav>
+<HistoryChart current={period} />
 
 <nav class="hist-kinds" aria-label="Filter by kind of event">
@@ -78,40 +65,29 @@ const most = Math.max(...historyPeriods.map((p) => p.items.length));
   </div>
   <div class="hist-order hist-only" role="group" aria-label="Which entries to show">
-    <button type="button" data-only="all" aria-pressed="true">All entries <small>{items.length}</small></button>
-    <button type="button" data-only="scored" aria-pressed="false">Score changes only <small>{items.filter((ch) => ch.score).length}</small></button>
+    <button type="button" data-only="all" aria-pressed="true">All entries <small>{num(all)}</small></button>
+    <button type="button" data-only="scored" aria-pressed="false">Score changes only <small>{num(scoredAll)}</small></button>
   </div>
+  <a class="hist-reset" href={`${base}history/`} hidden={feed} title="Back to every entry, newest first">× Reset</a>
 </div>
 
-<div class="hist-years" data-order="newest" data-only="all">
-  {years.map(({ year, items }) => (
-    <section class="hist-year" aria-labelledby={`y${year}`} data-scored={items.some((ch) => ch.score) ? "" : undefined}>
-      <h2 id={`y${year}`}>{year}<small class="n-all">{items.length === 1 ? "1 entry" : `${items.length} entries`}</small><small class="n-scored">{items.filter((ch) => ch.score).length === 1 ? "1 score change" : `${items.filter((ch) => ch.score).length} score changes`}</small></h2>
-      <ol>
-        {items.map((ch) => (
-          <li data-scored={ch.score ? "" : undefined}>
-            <a href={`${base}history/${ch.id}/`}>
-              <time datetime={ch.date}>{ch.date.length === 4 ? "" : formatDate(ch.date).replace(` ${year}`, "")}</time>
-              <div class="he-body">
-                <p class="he-place">{ch.place.flag} {ch.place.name} <span class="he-kind"><span aria-hidden="true">{ch.kind.emoji}</span> {ch.kind.label}</span> <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span> {articles.has(ch.id) && <span class="he-article">Article</span>}</p>
-                <h3>{ch.title}</h3>
-                <p class="he-text">{ch.description}</p>
-              </div>
-              {ch.score ? <ScoreChange score={ch.score} /> : <span></span>}
-            </a>
-          </li>
-        ))}
-      </ol>
-    </section>
-  ))}
+{feed && <button type="button" class="hist-more hist-more-top" hidden>Show earlier in this order</button>}
+<div class="hist-years" data-order="newest" data-only="all" data-feed={feed ? "" : undefined} data-years={feed ? yearIndex : undefined} data-fragments={feed ? `${base}fragments/history/` : undefined}>
+  <HistoryYears years={years} />
 </div>
+{feed && <>
+  <div class="hist-sentinel" aria-hidden="true"></div>
+  <p class="hist-status" role="status" aria-live="polite"></p>
+  <noscript><p class="hist-status">Older entries are on the pages of each decade: <a href={`${base}history/period/${historyPeriods[1]?.slug}/`}>{historyPeriods[1]?.label}</a> and earlier, from the chart above.</p></noscript>
+</>}
 
-{period && <nav class="pager chl-pager" aria-label="Other periods">
+{period && <nav class="pager chl-pager" aria-label="Other decades">
   {newer
-    ? <a href={href(newer)}><small>Newer</small><span>← {newer.label}</span></a>
+    ? <a href={`${base}history/period/${newer.slug}/`}><small>Newer</small><span>← {newer.label}</span></a>
     : <span></span>}
   {older
-    ? <a class="next" href={href(older)}><small>Older</small><span>{older.label} →</span></a>
+    ? <a class="next" href={`${base}history/period/${older.slug}/`}><small>Older</small><span>{older.label} →</span></a>
     : <span></span>}
 </nav>}
+{period && <p class="hist-continue"><a href={`${base}history/?from=${period.to}`}>Read on from here in the whole history →</a></p>}
 
 <div class="hist-tops">
@@ -130,38 +106,178 @@ const most = Math.max(...historyPeriods.map((p) => p.items.length));
 
 <script>
-  // Reading order: the page is written newest first; "Oldest first" turns the years and the
-  // entries inside each year around, to read the period as it happened. The choice is kept in
-  // this browser, and ?order=oldest in the address asks for it directly.
-  const years = document.querySelector(".hist-years");
-  const buttons = [...document.querySelectorAll(".hist-order button[data-order]")];
-  function setOrder(order) {
-    if (years.dataset.order !== order) {
-      years.append(...[...years.children].reverse());
-      for (const list of years.querySelectorAll("ol")) list.append(...[...list.children].reverse());
… 198 more lines
src/components/HistoryYears.astro +31 −0
@@ -0,0 +1,31 @@
+---
+// Years of the History ledger, newest first: the year set large, its entries ruled beside it.
+// Used by the decade and kind pages, and by the year fragments that the continuous list at
+// /history/ loads as the reader scrolls (src/pages/fragments/history/[year].astro).
+import ScoreChange from "./ScoreChange.astro";
+import { articles, formatDate } from "../lib/countries.js";
+
+const { years } = Astro.props; // [{ year, items }]
+const base = import.meta.env.BASE_URL;
+const scoredIn = (items) => items.filter((ch) => ch.score).length;
+---
+{years.map(({ year, items }) => (
+  <section class="hist-year" id={`y${year}`} data-year={year} aria-labelledby={`yh${year}`} data-scored={scoredIn(items) ? "" : undefined}>
+    <h2 id={`yh${year}`}><a href={`${base}history/?from=${year}`} title={`Link to ${year}`}>{year}</a><small class="n-all">{items.length === 1 ? "1 entry" : `${items.length} entries`}</small><small class="n-scored">{scoredIn(items) === 1 ? "1 score change" : `${scoredIn(items)} score changes`}</small></h2>
+    <ol>
+      {items.map((ch) => (
+        <li data-scored={ch.score ? "" : undefined}>
+          <a href={`${base}history/${ch.id}/`}>
+            <time datetime={ch.date}>{ch.date.length === 4 ? "" : formatDate(ch.date).replace(` ${year}`, "")}</time>
+            <div class="he-body">
+              <p class="he-place">{ch.place.flag} {ch.place.name} <span class="he-kind"><span aria-hidden="true">{ch.kind.emoji}</span> {ch.kind.label}</span> <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span> {articles.has(ch.id) && <span class="he-article">Article</span>}</p>
+              <h3>{ch.title}</h3>
+              <p class="he-text">{ch.description}</p>
+            </div>
+            {ch.score ? <ScoreChange score={ch.score} /> : <span></span>}
+          </a>
+        </li>
+      ))}
+    </ol>
+  </section>
+))}

src/components/ProjectNumbers.astro +9 −3
@@ -1,6 +1,8 @@
 ---
-// The project in numbers, on the About page: how much is scored, how much history is
-// recorded and how much of it is sourced. Each number links to where that material can be seen.
-import { SEGMENTS, DATA_SNAPSHOT, places, countries, changes, articles } from "../lib/countries.js";
+// The project in numbers, on the About page: how much is scored, how much history and culture
+// is recorded and how much of it is sourced. Each number links to where that material can be seen.
+import { SEGMENTS, DATA_SNAPSHOT, places, countries, changes, articles, subunitsOf } from "../lib/countries.js";
+import { MAP_VIEWS } from "../lib/map-views.js";
+import { cultureIn } from "../lib/culture.js";
 import { archivedCount } from "../lib/archive.js";
 import { sourceKey } from "../lib/sources.js";
@@ -19,7 +21,11 @@ const oldest = +dated.at(-1).date.slice(0, 4);
 const stats = [
   [num(places.length), "Countries and territories scored", `${base}#rankings`],
+  [num(places.reduce((n, c) => n + (subunitsOf(c.id)?.units.length ?? 0), 0)), "States and provinces scored", `${base}#rankings`],
+  [num(MAP_VIEWS.length), "World maps", `${base}map/`],
   [num(dated.length), "History entries", `${base}history/`],
   [num(dated.filter((ch) => ch.score).length), "Of them moved a score", `${base}history/?only=scored`],
   [num(articles.size), "Articles", `${base}articles/`],
+  [num(cultureIn("language").length), "Words for the plant", `${base}culture/language/`],
+  [num(cultureIn("traditions").length), "Traditions", `${base}culture/traditions/`],
   [num(sourceKeys.size), "Sources cited", `${base}sources/`],
   [num(explanations), "Written explanations", `${base}data/`],

src/content.config.ts +4 −0
@@ -168,4 +168,5 @@ const changes = defineCollection({
       // read. Without it the entry is listed as "not yet checked": it may rest on memory.
       checked: z.boolean().optional(),
+      related: z.array(z.string()).optional(), // ids of other entries that tell the same story; shown as related on both pages
       sources: z.array(source).min(1), // the more the better; the first is shown where there is room for one
     })
@@ -228,4 +229,7 @@ const culture = defineCollection({
       countries: z.array(z.string().regex(/^[a-z]{2}$/)).min(1), // file names in /countries, the most closely tied first
       history: z.array(z.string()).default([]), // ids in changes.yaml
+      // other atlas entries to show beside this one, as "<section>/<slug>", closest first.
+      // Links in the text and shared history entries add more on their own; see docs/CULTURE.md.
+      related: z.array(z.string().regex(/^(language|traditions)\/[a-z0-9-]+$/)).default([]),
       image: picture(image).optional(),
       // the entry's rank among the most important of its part (1 = first); see docs/CULTURE.md.

src/layouts/Base.astro +13 −0
@@ -423,4 +423,17 @@ const schemas = jsonld ? [jsonld].flat() : [];
     })();
   </script>
+  <script is:inline>
+    // Side panes stick beside the text only when the whole pane fits the window; otherwise its end
+    // could never be reached until the page ends.
+    (() => {
+      const rails = document.querySelectorAll(".ev-rail, .c-rail");
+      if (!rails.length) return;
+      const fit = () => rails.forEach((r) => r.classList.toggle("too-tall", r.scrollHeight > innerHeight - 92));
+      fit();
+      addEventListener("resize", fit);
+      addEventListener("load", fit);
+      if (window.ResizeObserver) { const ro = new ResizeObserver(fit); rails.forEach((r) => ro.observe(r)); }
+    })();
+  </script>
 </body>
 </html>

src/lib/countries.js +18 −32
@@ -258,40 +258,26 @@ export const averageHistoryByPopulation = averageOverTime(populationAt);
 
 // ── History pages ──
-// The History list is split into periods so that no page is too long or too short. It starts from
-// decades: a crowded decade is cut into two halves, and quiet decades are merged with their
-// neighbours until a page has enough on it. Estimated entries have no real date and are left out.
-const PAGE_MIN = 24, PAGE_MAX = 60;
+// The History list reads as one continuous ledger at /history/, loaded a year at a time. Each decade
+// also has a page of its own, /history/period/1890s/, which can be linked and read without scripts.
+// The few entries before the first colonial bans share one page. Estimated entries have no real date
+// and are left out. Both lists are newest first.
+export const HISTORY_FROM = 1860;
+const datedChanges = changes.filter((ch) => !ch.estimate);
+const entryYear = (ch) => +ch.date.slice(0, 4);
+export const historyYears = [...new Set(datedChanges.map((ch) => entryYear(ch)))]
+  .map((year) => ({ year, items: datedChanges.filter((ch) => entryYear(ch) === year) }));
 export const historyPeriods = (() => {
-  const dated = changes.filter((ch) => !ch.estimate); // newest first
-  const year = (ch) => +ch.date.slice(0, 4);
   const thisYear = +DATA_SNAPSHOT;
-  const count = (from, to) => dated.filter((ch) => year(ch) >= from && year(ch) <= to).length;
-  // decades from the current one back to the oldest entry, crowded ones in two halves
-  const spans = [];
-  for (let d = Math.floor(thisYear / 10) * 10; d >= Math.floor(year(dated.at(-1)) / 10) * 10; d -= 10) {
-    if (count(d, d + 9) > PAGE_MAX) spans.push([d + 5, d + 9], [d, d + 4]);
-    else spans.push([d, d + 9]);
+  const periods = [];
+  for (let d = Math.floor(thisYear / 10) * 10; d >= HISTORY_FROM; d -= 10) {
+    const to = Math.min(d + 9, thisYear);
+    periods.push({ slug: `${d}s`, label: `${d}s`, short: `${d}s`, from: d, to, items: datedChanges.filter((ch) => entryYear(ch) >= d && entryYear(ch) <= to) });
   }
-  // merge quiet spans into the one before them in time order (walking from newest to oldest)
-  const merged = [];
-  for (const [from, to] of spans) {
-    const last = merged.at(-1);
-    if (last && count(last.from, last.to) < PAGE_MIN) last.from = from;
-    else merged.push({ from, to });
-  }
-  // a quiet tail (the oldest entries) joins the period after it
-  if (merged.length > 1 && count(merged.at(-1).from, merged.at(-1).to) < PAGE_MIN) merged.at(-2).from = merged.pop().from;
-  return merged.map(({ from, to }, i) => {
-    const items = dated.filter((ch) => year(ch) >= from && year(ch) <= to);
-    const first = i === merged.length - 1 ? year(items.at(-1)) : from; // the oldest page starts at its first entry
-    const end = Math.min(to, thisYear);
-    const label = first % 10 === 0 && end === first + 9 ? `${first}s` : `${first}–${end}`;
-    // under a bar of the histogram there is room for seven characters: "1925–29", "1890–09", "to 1869"
-    const short = label.endsWith("s") ? label
-      : i === merged.length - 1 ? `to ${end}`
-      : end - first < 100 ? `${first}–${String(end).slice(2)}` : label;
-    return { slug: `${first}-${end}`, label, short, from: first, to: end, items };
-  });
+  const early = datedChanges.filter((ch) => entryYear(ch) < HISTORY_FROM);
+  if (early.length) periods.push({ slug: `before-${HISTORY_FROM}`, label: `Before ${HISTORY_FROM}`, short: "Earlier", from: entryYear(early.at(-1)), to: HISTORY_FROM - 1, items: early });
+  return periods;
 })();
+// the period an entry or a year belongs to
+export const periodOfYear = (year) => historyPeriods.find((p) => year >= p.from && year <= p.to) ?? historyPeriods.at(-1);
 
 // Review dates are stored as YYYY-MM-DD; this is how they are shown everywhere.

src/lib/culture.js +26 −0
@@ -48,5 +48,31 @@ for (const e of cultureEntries) for (const [, path] of (e.entry.body ?? "").matc
 }
 
+// Related entries, for the side of each entry page: the ones named in `related` first, in their
+// order; then those the text links to or that link here, those sharing history entries, and those
+// kept in two or more of the same countries. Weighed together, at most eight.
+const links = (e) => [...(e.entry.body ?? "").matchAll(/\]\(\/culture\/((?:language|traditions)\/[^/)#]+)/g)].map((m) => m[1]);
+for (const e of cultureEntries) {
+  const named = e.related.map((id) => {
+    const r = cultureEntries.find((x) => `${x.section}/${x.slug}` === id);
+    if (!r) throw new Error(`culture/${e.entry.id}.md: related names "${id}", which does not exist`);
+    return r;
+  });
+  const out = links(e);
+  const weigh = (x) => {
+    const id = `${x.section}/${x.slug}`;
+    const shared = x.countries.filter((c) => e.countries.includes(c)).length;
+    return (out.includes(id) ? 6 : 0) + (links(x).includes(e.entry.id) ? 4 : 0)
+      + 3 * x.history.filter((ch) => e.history.includes(ch)).length
+      + (shared >= 2 ? shared : 0);
+  };
+  const rest = cultureEntries.filter((x) => x !== e && !named.includes(x))
+    .map((x) => [x, weigh(x)]).filter(([, w]) => w > 0)
+    .sort((a, b) => b[1] - a[1] || (a[0].featured ?? Infinity) - (b[0].featured ?? Infinity));
+  e.relatedEntries = [...named, ...rest.map(([x]) => x)].slice(0, Math.max(8, named.length));
+}
+
 export const cultureIn = (section) => cultureEntries.filter((e) => e.section === section);
+// the atlas entries a history entry belongs to: named in their `history`, or linked from their text
+export const cultureOfChange = (ch) => cultureEntries.filter((e) => e.history.includes(ch) || (e.entry.body ?? "").includes(`](/history/${ch.id}/`));
 // what the atlas has on one country, for its page
 export const cultureOf = (c) => cultureEntries.filter((e) => e.countries.includes(c));

src/lib/related.js +103 −0
@@ -0,0 +1,103 @@
+// Related history entries, shown under an entry's sources. Only entries that are really tied to it:
+// - named in either entry's `related` list in changes.yaml (the way to add a tie by hand)
+// - one entry's article links to the other
+// - an atlas entry that belongs to both
+// - a source both cite that few other entries cite
+// - the same law number in both (Law 50-88)
+// - wording so close that both tell the same story, words weighed by how rare they are and place
+//   names left out, so that two entries are not tied only by happening in the same country
+// Most entries have none, and then the section is not shown.
+import { changes, articles } from "./countries.js";
+
+for (const ch of changes) for (const id of ch.related ?? []) if (!changes.some((x) => x.id === id)) throw new Error(`changes.yaml: "${ch.id}" names an unknown related entry "${id}"`);
+import { cultureOfChange } from "./culture.js";
+
+// wording alone must be this close (cosine of the weighted words) to count as the same story
+const SAME_STORY = 0.35;
+// words reduced to a rough stem, so that "rescheduled" and "rescheduling" count as one
+const stem = (w) => { const s = w.replace(/'s$/, "").replace(/(ings|ing|ed|es|e|s)$/, ""); return s.length >= 4 ? s : w; };
+// words of four letters or more, and acronyms such as DEA or UN
+const STOP = new Set("about above after again against also among another around because been before being below between both but called could does done down during each even every first from further have having here into itself just last later many more most much must never next none once only other over said same says several should since some still such than that their them then there these they this those through thus under until upon very were what when where whether which while whose will with within without would year years".split(" "));
+// and law numbers such as 50-88 or 19.172, which name one law exactly
+const words = (text) => [
+  ...(text.match(/[\p{L}][\p{L}']*/gu) ?? [])
+    .filter((w) => (w.length >= 4 && !STOP.has(w.toLowerCase())) || /^[A-Z]{2,}$/.test(w) && !/^[IVX]+$/.test(w))
+    .map((w) => stem(w.toLowerCase())),
+  ...(text.match(/\b\d+(?:[-./]\d+)+\b/g) ?? []).map((n) => `#${n}`),
+];
+const dated = changes.filter((ch) => !ch.estimate);
+const placeWords = (ch) => new Set(words(`${ch.place.name} ${ch.country?.name ?? ""}`));
+
+// document frequency of each word, over every entry
+const docs = new Map(dated.map((ch) => [ch, new Set(words(`${ch.title} ${ch.description}`))]));
+// a word in the title counts double
+const titles = new Map(dated.map((ch) => [ch, new Set(words(ch.title))]));
+const df = new Map();
+for (const set of docs.values()) for (const w of set) df.set(w, (df.get(w) ?? 0) + 1);
+const N = dated.length;
+// common words carry no meaning here; a word in more than one entry in twenty is ignored
+const weight = (w) => { const n = df.get(w) ?? 0; return n < 2 || n > N / 20 ? 0 : Math.log(N / n); };
+const vectors = new Map([...docs].map(([ch, set]) => {
+  const own = placeWords(ch);
+  const v = new Map([...set].filter((w) => !own.has(w)).map((w) => [w, weight(w) * (titles.get(ch).has(w) ? 2 : 1)]).filter(([, x]) => x > 0));
+  return [ch, { v, norm: Math.sqrt([...v.values()].reduce((a, x) => a + x * x, 0)) }];
+}));
+
+// a source counts only when few entries cite it: a general overview cited for dozens of entries
+// says nothing about how two of them are related
+const sourceUse = new Map();
+for (const ch of dated) for (const k of new Set(ch.sources.map((s) => s[3]))) sourceUse.set(k, (sourceUse.get(k) ?? 0) + 1);
+const sourceKeys = new Map(dated.map((ch) => [ch, new Set(ch.sources.map((s) => s[3]).filter((k) => sourceUse.get(k) <= 6))]));
+const culture = new Map(dated.map((ch) => [ch, new Set(cultureOfChange(ch))]));
+const linksTo = new Map(dated.map((ch) => {
+  const body = articles.get(ch.id)?.body ?? "";
+  return [ch, new Set([...body.matchAll(/\]\(\/history\/([^/)#]+)/g)].map((m) => m[1]))];
+}));
+const year = (ch) => +ch.date.slice(0, 4);
+
+// How strongly two entries are tied, and why. Only real ties count: a link one way or the other,
+// a custom both belong to, a little-cited source both rest on, or wording close enough that both
+// plainly tell the same story (the same law, ruling or case).
+export function tie(a, b) {
+  const why = [];
+  if (a.related?.includes(b.id) || b.related?.includes(a.id)) why.push(["named", 10]);
+  if (linksTo.get(a).has(b.id) || linksTo.get(b).has(a.id)) why.push(["article", 6]);
+  for (const e of culture.get(a)) if (culture.get(b).has(e)) why.push([`atlas:${e.slug}`, 4]);
+  let shared = 0;
+  for (const k of sourceKeys.get(a)) if (sourceKeys.get(b).has(k)) shared++;
+  if (shared) why.push(["source", 3 * Math.min(shared, 2)]);
+  const va = vectors.get(a), vb = vectors.get(b);
+  // the same law number in both names the same law
+  for (const w of docs.get(a)) if (w.startsWith("#") && docs.get(b).has(w)) { why.push([`law:${w.slice(1)}`, 6]); break; }
+  if (va.norm && vb.norm) {
+    let dot = 0;
+    for (const [w, x] of va.v) { const y = vb.v.get(w); if (y) dot += x * y; }
+    const cos = dot / (va.norm * vb.norm);
+    // only within one country: across countries, near-identical wording marks a template (one more
+    // country joining a treaty), not a shared story
+    if (cos >= SAME_STORY && a.country && a.country === b.country) why.push([`wording:${cos.toFixed(2)}`, 10 * cos]);
+  }
+  // Within one country any tie counts. Across countries it takes two different kinds of tie, a tie
+  // named by hand, or an article link to or from an international entry such as a treaty.
+  const kinds = new Set(why.map(([k]) => k.split(":")[0]));
+  const same = a.country && a.country === b.country;
+  const intl = !a.country || !b.country;
+  // A single shared source is usually a country's general overview, too broad to tie two entries
+  // on its own; two shared sources, or a source with any other tie, count.
+  const onlyOneSource = kinds.size === 1 && kinds.has("source") && shared < 2;
+  const counts = same ? why.length > 0 && !onlyOneSource : kinds.has("named") || kinds.size >= 2 || (intl && kinds.has("article"));
+  return { why, score: counts ? why.reduce((n, [, x]) => n + x, 0) : 0 };
+}
+
+// up to `limit` entries related to `ch`, leaving out those in `skip`, oldest first
+export function relatedTo(ch, skip = [], limit = 5) {
+  if (ch.estimate) return [];
+  return dated
+    .filter((x) => x !== ch && !skip.includes(x))
+    .map((x) => [x, tie(ch, x).score])
+    .filter(([, s]) => s > 0)
+    .sort((x, y) => y[1] - x[1])
+    .slice(0, limit)
+    .map(([x]) => x)
+    .sort((x, y) => x.date.localeCompare(y.date));
… 2 more lines
src/og/country.astro +9 −9

Not shown: a picture, a generated file, or past the length shown per change.

src/og/culture.astro +19 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/og/history.astro +10 −10

Not shown: a picture, a generated file, or past the length shown per change.

src/og/page.astro +5 −5

Not shown: a picture, a generated file, or past the length shown per change.

src/og/render.js +19 −3

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/about.astro +22 −12

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/culture/[section]/[slug].astro +3 −1

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/fragments/history/[year].astro +13 −0

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/history/[id].astro +48 −5

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/history/index.astro +4 −4

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/history/period/[period].astro +2 −2

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/og/[...path].png.js +13 −5

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/search.json.js +1 −1

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/sitemap.xml.js +1 −1

Not shown: a picture, a generated file, or past the length shown per change.

src/styles/global.css +91 −21

Not shown: a picture, a generated file, or past the length shown per change.

    ↑↓ to move, ↵ to open.