Cannabis Freedom Index

Changelog 2621c4b

add trends, better charts

20 history entries added5 history entries edited22 country profiles edited7 files of code, design or docs +796 −71 lines

History changes.yaml

Added 20

Edited 5

A Nepali province legalises cannabis farming for medicine

description

Gandaki province's law took effect after the province chief, letting farmerswho had first sent it back over conflicts with federal law, signed it. Farmers with a licence may grow cannabis for medical and industrial use in designated zones, with official permission and under close monitoringTHC capped at 0. The3%. No licence can be issued until the province writes the detailed rules, and the national ban on growing, holding and using cannabis stayedstays in force, so the provincial law conflicts with federal law.

sources

Kathmandu Post — Inside Gandaki's new cannabis law: who can grow it, where, and under what rules (7 August 2026) 1 unchanged

Nepali lawmakers ask for cannabis to be legalised

description

A ruling-party MP and 47 other lawmakers filed a motion in parliament seeking legalisation. A private bill followed in 2021March 2020, and in May 2024 the finance minister announced plans to allow medicinal cannabis; none has become law.

Nepal passes the Narcotic Drugs (Control) Act

sources

Nepali Times — Nepal is moving to decriminalise cannabis (22 August 2026) 1 unchanged

before

possession 6 · cultivation 3 · enforcement 6 · sharing 3 · consumption 5 · access 0

Nepal closes its legal cannabis shops

description

Under pressure from the United States and the United Nations, Nepal cancelled the licences of every cannabis shop, dealer and farmer, ending the legal trade that had made Kathmandu a stop on the hippie trail. TheGrowing and using cannabis only became crimes with the Narcotic Drugs (Control) Act followed inof 1976.

sources

Nepali Times — Nepal is moving to decriminalise cannabis (22 August 2026) 1 unchanged

Countries and states 22 files

Argentina countries/ar.yaml

sources

Argentina — Ley 24.449 de Tránsito, Article 48 (InfoLEG, consolidated text) 6 unchanged

context › driving › status

zero

context › driving › text

The national traffic law prohibits driving 'having consumed narcotics or medicines that reduce the ability to drive'. It sets no THC level, so any use counts. The same article, as amended in 2023, also bans driving with any alcohol in the blood.

context › driving › sources

ley24449

Bangladesh countries/bd.yaml

sources

The Daily Star via Asia News Network — Bangladesh's narco trail: traffickers tap 105 border points across 18 districts (28 July 2026) 5 unchanged

context › black market › text

Bangladesh's narcotics department says the country does not produce drugs but lies on a trafficking route, and that cannabis is smuggled in mostly from India, across the western and northern borders. A July 2026 report counted at least 105 border points in 18 districts used for narcotics of all kinds. Authorities seized 96,358 kg of cannabis in 2025, down from 114,345 kg in 2024. The department also reports shortages of staff, equipment and vehicles, and drug cases that stay pending for years.

context › black market › sources

ds26

Brazil countries/br.yaml

sources

Brazil — Código de Trânsito Brasileiro (Lei 9.503/1997), Articles 165, 165-A and 306 8 unchanged

context › driving › status

impairment

context › driving › text

The Traffic Code has two levels. Driving under the influence of alcohol or any other psychoactive substance that causes dependence is a very serious administrative infraction: a fine multiplied by ten and a 12-month suspension of the licence, and refusing a test brings the same. It becomes a crime, with six months to three years' detention, only when the driver's psychomotor capacity is altered. For alcohol the code sets a figure that proves this; for other substances it sets none, so proof rests on signs of impairment, a toxicology test, a clinical exam or witnesses.

context › driving › sources

ctbdrive

Chile countries/cl.yaml

sources

Diario Constitucional — Conducción bajo influencia de sustancias estupefacientes es delito de peligro abstracto y solo requiere presencia de droga (23 August 2025) 7 unchanged

context › driving › status

zero

context › driving › text

The Traffic Law makes it a crime to drive under the influence of narcotic or psychotropic substances and, unlike for alcohol, sets no levels. In August 2025 the Santiago Court of Appeals held that the offence needs only the presence of the drug: visible effects do not have to be proved, and a positive roadside drug test confirmed by a blood test is enough. It overturned the acquittal of a bus driver on that ground and ordered a new trial.

context › driving › sources

dc25

China countries/cn.yaml

context › consequences › text

Registration as a drug user brings years of monitoring and random testing, and affects employment and travel. The Narcotics Control Law has police register drug users and sets community rehabilitation at three years; a user who comes forward to register or seeks treatment is not punished for the use. Since 1 January 2026 records of public-security penalties, drug use included, are sealed: they stay on file but may not be given out except to state organs handling a case or bodies entitled to check under national rules.

context › consequences › sources

wiki, ncl08, yicai25

sources

China — Road Traffic Safety Law (2021 amendment), Article 22 (Wikisource) Narcotics Control Law of the People's Republic of China (Wikisource) Yicai (Xinhua) — legislative commission answers questions on sealing public-security violation records (24 December 2025) 4 unchanged

context › driving › status

zero

context › driving › text

The Road Traffic Safety Law says a person who has taken state-controlled psychotropic or narcotic drugs may not drive a motor vehicle. It sets no level and does not ask whether the driver is impaired. The article itself carries no penalty of its own for drugs; the law's detailed drunk-driving penalties refer only to alcohol.

context › driving › sources

rtsl22

Indonesia countries/id.yaml

sources

RRI — Indonesia prison population reaches 271,602 amid overcrowding (5 October 2026) 6 unchanged

context › arrests › text

Indonesia's prison figures cover all narcotics, not cannabis alone. On 30 April 2026 prisons and detention centres held 271,602 people, 85% over capacity, and 146,376 of them, 53%, were there for narcotics offences. In October 2026 the law minister said the government was preparing rehabilitation and re-education centres, mainly for drug users of working age, on the president's instruction.

context › arrests › sources

rri26

Israel countries/il.yaml

sources

Israel — Traffic Ordinance, sections 62(3) and 64B (Hebrew Wikisource) 7 unchanged

context › driving › status

zero

context › driving › text

The Traffic Ordinance makes it an offence to be 'intoxicated' while driving or in charge of a vehicle, and defines an intoxicated person to include anyone who has a dangerous drug, or the metabolic products of one, in their body. No level is set, so traces left by earlier use count. The definition leaves out drugs that the health minister has exempted on set conditions. The offence carries up to two years in prison or a fine.

context › driving › sources

pkt64

Iceland countries/is.yaml

sources

Iceland — Umferðarlög nr. 77/2019, Article 50 (Althingi) Ísland.is (Icelandic Police) — Alcohol and drug use while driving 6 unchanged

context › driving › status

zero

context › driving › text

The Traffic Act forbids driving under the influence of drugs that are banned in Iceland, and says that when such a drug is measured in the driver's blood the driver is deemed under the influence and unfit to drive safely. No level is set, so any measurable amount is enough. Police can require a saliva, urine or blood sample on suspicion, and refusing has the same consequences as drug driving: penalty points, loss of the licence for up to four years or more for repeat offences, and fines or prison of up to two years in serious cases.

context › driving › sources

umf50, islandis

Japan countries/jp.yaml

sources

Japan — Road Traffic Act (Act No. 105 of 1960), Articles 66 and 117-2 (e-Gov) 6 unchanged

context › driving › status

impairment

context › driving › text

The Road Traffic Act forbids driving in a state in which normal driving may not be possible because of overwork, illness, drugs or other reasons. The law sets no THC level; the test is the driver's condition. Where that condition comes from narcotics, cannabis, opium or stimulants, the penalty is up to five years in prison or a fine of up to one million yen.

context › driving › sources

rta66

Kenya countries/ke.yaml

sources

Kenya — Traffic Act (Cap. 403), section 44 (Kenya Law) 5 unchanged

context › driving › status

limit

context › driving › text

The Traffic Act makes it an offence to drive, attempt to drive or be in charge of a vehicle while under the influence of an alcoholic drink or a drug 'beyond the prescribed limits'. The penalty is a fine of up to 100,000 shillings, up to two years in prison, or both, with disqualification for twelve months. The section itself gives no figure for cannabis, and none was found in the sources read for this profile.

context › driving › sources

ta44

South Korea countries/kr.yaml

sources

The Financial News — police announce tougher drug-driving penalties under the amended Road Traffic Act (14 January 2026) 7 unchanged

context › driving › status

impairment

context › driving › text

The Road Traffic Act punishes driving when normal driving is difficult because of drugs, which include narcotics, psychotropics and cannabis. Under amendments in force from April 2026 the maximum penalty rose from three years in prison or a 10 million won fine to five years or 20 million won, and refusing a drug test became an offence punished at the same level. Police said a prescribed medicine does not by itself lead to punishment: the test is whether attention and motor skills are actually impaired. Officers may use saliva tests, behavioural checks and urine or blood tests.

context › driving › sources

fn26

Kazakhstan countries/kz.yaml

sources

Kazakhstan — Code of Administrative Offences, Article 608 (Adilet) 8 unchanged

context › driving › status

impairment

context › driving › text

The Code of Administrative Offences punishes driving 'in a state of alcoholic, narcotic or toxic intoxication' with 15 days of administrative arrest and loss of the licence for seven years; where harm or damage results, 20 days. A repeat within a year brings 20 days and eight years. The article names a state of intoxication, not a measured level, and the Constitutional Court upheld it in October 2023.

context › driving › sources

koap608

Morocco countries/ma.yaml

sources

Hespress (MAP) — DGSN en 2025: baisse de la criminalité violente et forte mobilisation contre la drogue (17 December 2025) 8 unchanged

context › arrests › text

The national police's report for 2025 counts 106,117 drug cases and 134,126 people arrested in them, 378 of them foreign nationals; the figures cover all drugs. Police seized nearly 170 tonnes and 796 kg of cannabis resin and its derivatives during the year, against 1.7 tonnes of cocaine and about 6 kg of heroin. Controlled deliveries run with Spain accounted for 55 tonnes of resin and 38 arrests.

context › arrests › sources

dgsn25

Nigeria countries/ng.yaml

sources

The Guardian Nigeria (NAN) — NDLEA arrests 62,595 drug suspects, convicts 11,628 (6 May 2025) Vanguard via AllAfrica — Over 16,304 arrested, 4.5m kg of drugs seized in 10 months, Marwa (19 November 2025) 6 unchanged

context › arrests › text

The drug agency NDLEA reports its figures for all drugs together. Its chairman said that between January 2021 and March 2025 it arrested 62,595 suspects, 68 of them described as drug barons, secured 11,628 convictions and seized about 10.3 million kg of drugs. In the first ten months of 2025 it reported more than 16,304 arrests, about 3,000 convictions and 4.5 million kg seized. Cannabis is counted separately only by land: 1,331 hectares of cannabis farms destroyed in the four-year period and 612 hectares from January to October 2025.

context › arrests › sources

ndlea25, ndlea25b

Peru countries/pe.yaml

sources

Peru — Código Penal, Article 274 (LP Derecho, updated text) 9 unchanged

context › driving › status

impairment

context › driving › text

The Penal Code punishes driving 'under the effect of' toxic drugs, narcotics or psychotropic substances with six months to two years in prison, or 52 to 104 days of community service, and disqualification. For alcohol the same article gives a blood level; for drugs it gives none. Drivers of public transport and freight face one to three years.

context › driving › sources

cp274

Philippines countries/ph.yaml

sources

Philippines — Republic Act No. 10586, Anti-Drunk and Drugged Driving Act of 2013 (LawPhil) 9 unchanged

context › driving › status

zero

context › driving › text

The Anti-Drunk and Drugged Driving Act of 2013 defines driving under the influence of dangerous drugs as driving by a person who, after a confirmatory test, is found positive for use of any dangerous drug: no level is set and impairment need not be shown. An officer who sees signs of drug use runs field sobriety tests and then a drug test, and every driver in a crash that causes death or injury is tested. Without injury the penalty is three months in prison and a fine of 20,000 to 80,000 pesos.

context › driving › sources

ra10586

Serbia countries/rs.yaml

sources

Serbia — Zakon o bezbednosti saobraćaja na putevima, Articles 187 and 279 (Paragraf) 5 unchanged

context › driving › status

zero

context › driving › text

The Road Traffic Safety Law forbids driving under the effect of psychoactive substances, and defines that state as the presence of such substances in the body, established by a test. For alcohol the same article sets a level of 0.20 mg/ml; for drugs it sets none. Police exclude such a driver from traffic on the spot.

context › driving › sources

zobs187

Russia countries/ru.yaml

sources

Kommersant — drug convictions in 2024 and the Supreme Court's proposal to soften Article 228 (14 December 2025) RTVI — record quantity of drugs seized in Russia in 2024, Interior Ministry statistics (4 February 2025) Russia — Code of Administrative Offences, Article 12.8 (text at zakonrf.info) 5 unchanged

context › driving › status

zero

context › driving › text

The Code of Administrative Offences punishes driving in a state of intoxication with a fine of 45,000 roubles and loss of the licence for one and a half to two years, where the act is not a crime. A note to the article defines the state: for alcohol it gives measured levels, but for drugs liability arises from the presence of narcotic or psychotropic substances in the body, with no level set.

context › driving › sources

koap128

context › arrests › text

Russian figures cover all drugs together. In 2024 courts convicted 69,900 people of drug crimes, 50,800 of them under Article 228, the possession article: about 13% of all 512,800 convictions that year, a share that has held steady for several years. One in five of those convicted under Article 228 as the main charge was sent to prison, most often for three to five years, and fewer than 1% were released from punishment. By various estimates about a third of all prisoners are serving time for drugs. The Interior Ministry counted 69,700 people prosecuted for drug crimes in 2024, nearly 10% fewer than in 2023, while seizures reached a seven-year record of 37.8 tonnes.

context › arrests › sources

komm25, rtvi25

Singapore countries/sg.yaml

sources

Singapore — Road Traffic Act 1961, section 67 (Singapore Statutes Online) 6 unchanged

context › driving › status

impairment

context › driving › text

The Road Traffic Act makes it an offence to drive while unfit, meaning under the influence of drink, a drug or a psychoactive substance to such an extent as to be incapable of having proper control of the vehicle. Unlike for alcohol, no limit is prescribed for drugs. A first offence brings a fine of 2,000 to 10,000 dollars or up to 12 months in prison and at least two years' disqualification; a repeat offence, a fine of 5,000 to 20,000 dollars and up to two years in prison, with at least five years' disqualification.

context › driving › sources

rta67

Türkiye countries/tr.yaml

sources

Türkiye — Karayolları Trafik Kanunu No. 2918, Article 48 (Mevzuat, consolidated text) 6 unchanged

context › driving › status

zero

context › driving › text

The Highway Traffic Law forbids anyone who has taken narcotic or stimulant substances from driving, with no level set; police use roadside test kits, and a driver who objects is taken for a blood, saliva or urine sample. Under the paragraph as amended in February 2026, a driver found to have taken such a substance is fined 150,000 lira and the licence is cancelled; a new one requires a driving course and exams, which cannot begin until at least five years have passed. The Penal Code applies as well.

context › driving › sources

ktk48

Taiwan countries/tw.yaml

sources

Taiwan — Road Traffic Management and Penalty Act, Article 35 (Laws and Regulations Database, amended 28 May 2025) 5 unchanged

context › driving › status

zero

context › driving › text

The Road Traffic Management and Penalty Act punishes a driver found by a test to have taken drugs, narcotics or similar controlled substances, with no level set. Car drivers are fined 30,000 to 120,000 New Taiwan dollars and motorcyclists 15,000 to 90,000; the vehicle is removed and the licence suspended for one to two years, or revoked for good after a crash causing serious injury or death. Refusing the test costs 180,000 dollars and the licence, and officers may take the driver for sampling by force.

context › driving › sources

rtmpa35

Uzbekistan countries/uz.yaml

sources

Uzbekistan — Code of Administrative Responsibility, Article 131 (Lex.uz) 4 unchanged

context › driving › status

impairment

context › driving › text

The Code of Administrative Responsibility punishes driving in a state of alcoholic intoxication or under the influence of narcotic drugs, psychotropic substances or other substances that affect a person's mental and volitional activity. The penalty is a fine of forty base calculation units and loss of the licence for three years; a driver with no licence is fined fifty units or held for 15 days. The article sets no THC level.

context › driving › sources

koao131

Code, design and documents 7 files

src/components/AverageChart.astro +7 −48
@@ -53,5 +53,8 @@ function view(range, weighting) {
   const line = `M0,${y(start)}` + steps.map((p) => `H${p.x}V${p.y}`).join("") + "H100";
   const points = steps.filter((p) => p.change); // estimated changes move the line but get no dot
-  return { range: range.id, weight: weighting.id, startYear, now, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
+  // for the seeker (the script is in Base.astro): the axis and the steps of the line; the entries
+  // it lists are read from the dots
+  const seek = JSON.stringify({ t0, t1, start, unit: "average", dp: 1, dots: true, y: [yMin, yMax], steps: steps.map((p) => [p.x, p.x, p.before, p.after]) });
+  return { seek, range: range.id, weight: weighting.id, startYear, now, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
 }
 
@@ -85,5 +88,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
         {v.ticks.map(({ t, y }) => <span style={`top:${y}%`}>{v.whole ? t : fmt(t)}</span>)}
       </div>
-      <div class="avg-plot">
+      <div class="avg-plot" data-seek={v.seek}>
         <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
           {v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)}
@@ -96,5 +99,6 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
             aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
         ))}
-        <div class="avg-tip" hidden><small></small><b></b><span></span></div>
+        <span class="c-seek" hidden></span>
+        <div class="c-seek-tip" hidden></div>
       </div>
       <div class="avg-x" aria-hidden="true">
@@ -122,48 +126,3 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
     });
   });
-
-  chart?.querySelectorAll(".avg-plot").forEach((plot) => {
-    const tip = plot.querySelector(".avg-tip");
-    const dots = [...plot.querySelectorAll(".avg-dot")];
-    const [date, title, avg] = tip.children;
-    let active = null;
-    const show = (dot) => {
-      if (dot === active) return;
-      active?.classList.remove("on");
-      active = dot;
-      dot.classList.add("on");
-      date.textContent = dot.dataset.date;
-      title.textContent = dot.dataset.title;
-      avg.textContent = dot.dataset.avg;
-      tip.hidden = false;
-      // keep the tooltip inside the plot
-      const box = plot.getBoundingClientRect();
-      const d = dot.getBoundingClientRect();
-      const cx = d.left + d.width / 2 - box.left;
-      const half = tip.offsetWidth / 2;
-      tip.style.left = `${Math.min(Math.max(cx, half), box.width - half)}px`;
-      const above = d.top - box.top - tip.offsetHeight - 10;
-      tip.style.top = `${above >= -30 ? above : d.bottom - box.top + 10}px`;
-    };
-    const hide = () => {
-      active?.classList.remove("on");
-      active = null;
-      tip.hidden = true;
-    };
-    // pointer: the change nearest to the cursor along the time axis
-    plot.addEventListener("pointermove", (e) => {
-      let best = dots[0], dist = Infinity;
-      for (const dot of dots) {
-        const r = dot.getBoundingClientRect();
-        const dx = Math.abs(r.left + r.width / 2 - e.clientX);
-        if (dx < dist) { dist = dx; best = dot; }
-      }
-      show(best);
-    });
-    plot.addEventListener("pointerleave", hide);
-    dots.forEach((dot) => {
-      dot.addEventListener("focus", () => show(dot));
-      dot.addEventListener("blur", hide);
-    });
-  });
 </script>

src/components/CountryArticle.astro +2 −2
@@ -135,5 +135,5 @@ const circ = 2 * Math.PI * r;
     {SHOW_PRICES && !sub && <PricePanel id={c.id} />}
     {/* in the drawer the chart stays in this column; on the full page it moves under the breakdown */}
-    {scored.length > 0 && <div class="chart-in-side"><CountryChart history={scored} /></div>}
+    {scored.length > 0 && <div class="chart-in-side"><CountryChart history={scored} context={history.filter((ch) => !ch.score)} /></div>}
   </div>
 
@@ -195,5 +195,5 @@ const circ = 2 * Math.PI * r;
       );
     })}
-    {scored.length > 0 && <div class="chart-in-main"><CountryChart history={scored} /></div>}
+    {scored.length > 0 && <div class="chart-in-main"><CountryChart history={scored} context={history.filter((ch) => !ch.score)} /></div>}
 
     {group && (

src/components/CountryChart.astro +35 −5
@@ -1,8 +1,10 @@
 ---
-// A country's index score over time: a step line that moves at each of its recorded changes.
-// No script, so it also works when the article is loaded into the drawer.
+// A country's index score over time: a step line that moves at each of its recorded changes,
+// with a hollow mark for every history entry that moved no score. Hovering shows a seeker line
+// with the year, the score then and the entries at that point; the script for it is in
+// Base.astro, so it also works when the article is loaded into the drawer.
 import { changeDate, dateTime, lastUpdated } from "../lib/countries.js";
 
-const { history } = Astro.props; // the country's changes, newest first
+const { history, context = [] } = Astro.props; // the country's score changes and its other history entries, newest first
 const base = import.meta.env.BASE_URL;
 const time = dateTime;
@@ -46,4 +48,27 @@ const firstCh = changes[0];
 const startBelow = firstCh && firstCh.raw.after > firstCh.raw.before && startRaw / yMax >= 0.2;
 // a dated change is a vertical step; an estimated one slopes across its window
+// the score at a place on the time axis (0–100), following the same line
+const rawAt = (px) => {
+  let v = startRaw;
+  for (const p of points) {
+    if (px >= p.x) v = p.ch.raw.after;
+    else { if (p.ch.estimate && px > p.x0) v = p.ch.raw.before + ((p.ch.raw.after - p.ch.raw.before) * (px - p.x0)) / (p.x - p.x0); break; }
+  }
+  return v;
+};
+// entries that moved no score sit on the line at their date
+const marks = [...context].reverse()
+  .filter((ch) => time(ch.date) >= t0 && time(ch.date) <= t1)
+  .map((ch) => ({ ch, x: x(time(ch.date)) }))
+  .map((m) => ({ ...m, y: y(rawAt(m.x)) }));
+// what the seeker needs: the axis, the steps of the line and every entry
+const seek = JSON.stringify({
+  t0, t1, start: startRaw,
+  steps: points.map((p) => [p.x0, p.x, p.ch.raw.before, p.ch.raw.after]),
+  events: [
+    ...points.map((p) => ({ x: p.x, d: changeDate(p.ch), t: p.ch.title, k: p.ch.kind.emoji, s: `${p.ch.score.before} → ${p.ch.score.after}` })),
+    ...marks.map((m) => ({ x: m.x, d: changeDate(m.ch), t: m.ch.title, k: m.ch.kind.emoji })),
+  ].sort((a, b) => a.x - b.x),
+});
 const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L${p.x},${p.y}` : `H${p.x}V${p.y}`)).join("") + "H100";
 ---
@@ -54,5 +79,5 @@ const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L
       {ticks.map((t) => <span style={`top:${y(t)}%`}>{t}</span>)}
     </div>
-    <div class="avg-plot">
+    <div class="avg-plot" data-seek={seek}>
       <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)} />)}
@@ -62,9 +87,14 @@ const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L
       {points.map((p) => (
         <a class="avg-dot" href={`${base}history/${p.ch.id}/`} style={`left:${p.x}%;top:${p.y}%`}
-          title={`${changeDate(p.ch)}: ${p.ch.title} (${p.ch.score.before} → ${p.ch.score.after})`}
           aria-label={`${changeDate(p.ch)}: ${p.ch.title}. Score ${p.ch.score.before} to ${p.ch.score.after}.`}>
           {p.label && <span class={`c-chart-val ${p.ch.raw.after < p.ch.raw.before ? "below" : ""}`} aria-hidden="true">{p.ch.score.after}</span>}
         </a>
       ))}
+      {marks.map((m) => (
+        <a class="avg-dot nomove" href={`${base}history/${m.ch.id}/`} style={`left:${m.x}%;top:${m.y}%`}
+          aria-label={`${changeDate(m.ch)}: ${m.ch.title}. No change in score.`}></a>
+      ))}
+      <span class="c-seek" hidden></span>
+      <div class="c-seek-tip" hidden></div>
     </div>
     <div class="avg-x" aria-hidden="true">

src/layouts/Base.astro +68 −0
@@ -114,4 +114,72 @@ const schemas = jsonld ? [jsonld].flat() : [];
       btn.textContent = open ? "Close all" : "Open all";
     });
+    // "Score over time" on a country article and the world average on the home page: a vertical seeker follows the pointer and says
+    // what the score was then and which history entries sit at that point. It snaps to an entry
+    // that is close. Works in the drawer too, since it listens on the document.
+    var seekPlot = null;
+    function hideSeek() {
+      if (!seekPlot) return;
+      seekPlot.querySelector(".c-seek").hidden = seekPlot.querySelector(".c-seek-tip").hidden = true;
+      seekPlot.querySelectorAll(".avg-dot.on").forEach(function (dot) { dot.classList.remove("on"); });
+      seekPlot = null;
+    }
+    function seek(e) {
+      var plot = e.target.closest && e.target.closest(".avg-plot[data-seek]");
+      if (plot !== seekPlot) hideSeek();
+      if (!plot) return;
+      seekPlot = plot;
+      var data = plot._seek || (plot._seek = JSON.parse(plot.dataset.seek));
+      var dots = plot._dots || (plot._dots = Array.prototype.slice.call(plot.querySelectorAll(".avg-dot")));
+      // the world chart keeps its entries on the dots themselves
+      if (data.dots && !data.events) data.events = dots.map(function (dot) { return { x: parseFloat(dot.style.left), d: dot.dataset.date, t: dot.dataset.title, own: dot.dataset.avg }; });
+      var box = plot.getBoundingClientRect();
+      var px = Math.max(0, Math.min(100, ((e.clientX - box.left) / box.width) * 100));
+      // snap to the nearest entry within 7 pixels, and list the ones that share its place
+      var near = null;
+      data.events.forEach(function (ev) { if (!near || Math.abs(ev.x - px) < Math.abs(near.x - px)) near = ev; });
+      var here = [];
+      if (near && (Math.abs(near.x - px) * box.width) / 100 <= 7) {
+        px = near.x;
+        here = data.events.filter(function (ev) { return (Math.abs(ev.x - px) * box.width) / 100 <= (data.dots ? 1.5 : 4); }); // the world chart is dense
+      }
+      dots.forEach(function (dot) { dot.classList.toggle("on", here.length > 0 && Math.abs(parseFloat(dot.style.left) - px) < 0.001); });
+      var score = data.start;
+      for (var i = 0; i < data.steps.length; i++) {
+        var s = data.steps[i]; // [start of the step, end, score before, score after]
+        if (px >= s[1]) score = s[3];
+        else { if (px > s[0]) score = s[2] + ((s[3] - s[2]) * (px - s[0])) / (s[1] - s[0]); break; }
+      }
+      var year = new Date(data.t0 + ((data.t1 - data.t0) * px) / 100).getUTCFullYear();
+      var line = plot.querySelector(".c-seek"), tip = plot.querySelector(".c-seek-tip");
+      tip.replaceChildren();
+      var head = document.createElement("b");
+      head.textContent = year + " · " + (data.unit || "score") + " " + (data.dp ? score.toFixed(data.dp) : Math.round(score));
+      tip.append(head);
+      here.slice(0, data.dots ? 3 : 4).forEach(function (ev) {
+        var p = document.createElement("p"), when = document.createElement("small");
+        when.textContent = ev.d + " · " + (ev.own || (ev.s ? "score " + ev.s : "no change in score"));
+        p.append(when, (ev.k ? ev.k + " " : "") + ev.t);
+        tip.append(p);
+      });
+      if (here.length > (data.dots ? 3 : 4)) {
+        var more = document.createElement("p");
+        more.textContent = "and " + (here.length - (data.dots ? 3 : 4)) + " more";
+        tip.append(more);
+      }
+      line.style.left = px + "%";
+      tip.style.left = px + "%";
+      tip.style.transform = "translateX(-" + px + "%)"; // slides from left- to right-aligned, so it stays inside
+      if (data.y) {
+        // a tall plot: keep the box inside it, under the line where the line runs high and above it elsewhere
+        var at = (1 - (score - data.y[0]) / (data.y[1] - data.y[0])) * 100;
+        tip.style.top = at < 50 ? "calc(" + at + "% + 12px)" : "auto";
+        tip.style.bottom = at < 50 ? "auto" : "calc(" + (100 - at) + "% + 12px)";
+      }
+      line.hidden = tip.hidden = false;
+    }
+    document.addEventListener("pointermove", seek);
+    document.addEventListener("pointerdown", seek);
+    document.addEventListener("pointerleave", hideSeek);
+
     // A link straight to a segment, e.g. /country/de/#seg-possession
     if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));

src/lib/countries.js +11 −0
@@ -292,4 +292,15 @@ export const changeDate = (ch) => (ch.estimate ? `Between ${ch.window[0]} and ${
 
 
+// How far a country's score moved over the last `years` years: { delta, since, last } or null
+// when nothing moved it. `last` is the most recent change in that window.
+export const TREND_YEARS = 10;
+export function trendOf(c, years = TREND_YEARS) {
+  const since = +lastUpdated.slice(0, 4) - years;
+  const moved = changes.filter((ch) => ch.country === c && ch.score && !ch.estimate && +ch.date.slice(0, 4) >= since); // newest first
+  if (!moved.length) return null;
+  const delta = Math.round(c.raw - moved.at(-1).raw.before);
+  return delta ? { delta, since, last: moved[0] } : null;
+}
+
 // The most recent review date across every country and state file.
 export const lastUpdated = [

src/pages/index.astro +3 −2
@@ -3,5 +3,5 @@ import Base from "../layouts/Base.astro";
 import DidYouKnow from "../components/DidYouKnow.astro";
 import Changes from "../components/Changes.astro";
-import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, REC_ORDER, REC_HELP, MED_LABEL, MED_HELP, countries, regions, worldAvg, lastUpdated, formatDate, subunitsOf, places } from "../lib/countries.js";
+import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, REC_ORDER, REC_HELP, MED_LABEL, MED_HELP, countries, regions, worldAvg, lastUpdated, formatDate, subunitsOf, places, trendOf, changeDate, TREND_YEARS } from "../lib/countries.js";
 import { TIERS, tierColor } from "../lib/tiers.js";
 import { PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "../lib/segment-ids.js";
@@ -251,5 +251,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
                   {...Object.fromEntries(EXTRA_COLUMNS.map((col) => [`data-${col.key}`, col.sort(c)]))}>
                 <td class="num">{c.rank ?? "–"}</td>
-                <td><a class="cname" href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span>{c.name}</a>{c.disputed && <span class="tag-disputed" title={c.disputed.note}>disputed</span>}</td>
+                <td><a class="cname" href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span>{c.name}{trendOf(c) && ((t) => <span class={`trend ${t.delta > 0 ? "up" : "down"}`} title={`${t.delta > 0 ? "+" : "−"}${Math.abs(t.delta)} ${Math.abs(t.delta) === 1 ? "point" : "points"} since ${t.since}. Latest change: ${t.last.title} (${changeDate(t.last)})`}>{t.delta > 0 ? "▲" : "▼"}{Math.abs(t.delta)}</span>)(trendOf(c))}</a>{c.disputed && <span class="tag-disputed" title={c.disputed.note}>disputed</span>}</td>
                 <td class="hide-sm" data-col="region">{c.region}</td>
                 <td class="num-col hide-md" data-col="users" title={useNote(c)}>{c.use.estimate && <span class="approx">~</span>}{compact(c.use.users)}</td>
@@ -281,4 +281,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
         </table>
       </div>
+      <p class="table-updated trend-note"><span class="trend up">▲</span> <span class="trend down">▼</span> after a name: points the score gained or lost since {+lastUpdated.slice(0, 4) - TREND_YEARS}. No mark means it did not move. Hover a mark to see the latest change.</p>
       <p class="table-updated">Last updated: <time datetime={lastUpdated}>{formatDate(lastUpdated)}</time></p>
     </section>

src/styles/global.css +15 −5
@@ -188,4 +188,10 @@ td.num, th.num { width: 52px; color: var(--muted); }
 .cname { display: flex; align-items: center; gap: 10px; font-weight: 500; }
 .flag { font-size: 20px; line-height: 1; }
+/* how far the score moved over the last ten years, after the country's name */
+.trend { margin-left: -3px; font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; cursor: help; }
+.table-updated.trend-note { text-align: left; }
+.trend-note .trend { margin: 0; cursor: default; }
+.trend.up { color: var(--t5); }
+.trend.down { color: var(--t1); }
 .score-col { width: 34%; min-width: 150px; }
 .score-cell { display: flex; align-items: center; gap: 12px; }
@@ -560,9 +566,4 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
 .avg .avg-dot::after { opacity: 0; }
 .avg .avg-dot.on::after, .avg .avg-dot:focus-visible::after { opacity: 1; }
-.avg-tip { position: absolute; z-index: 2; transform: translateX(-50%); width: max-content; max-width: min(260px, 100%); padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); box-shadow: var(--shadow); pointer-events: none; font-size: 13px; line-height: 1.35; }
-.avg-tip[hidden] { display: none; }
-.avg-tip small { display: block; color: var(--muted); }
-.avg-tip b { display: block; }
-.avg-tip span { color: var(--text-2); font-variant-numeric: tabular-nums; }
 .avg-note { margin: 12px 0 0; color: var(--muted); font-size: 12.5px; }
 @media (max-width: 640px) { .avg-x .odd { display: none; } }
@@ -583,4 +584,13 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
 .c-chart-val.below { bottom: auto; top: 100%; transform: translate(-50%, -5px); }
 .c-chart-val.start.below { top: auto; transform: translateY(4px); }
+/* history entries that moved no score: hollow marks on the line */
+.avg-dot.nomove { width: 16px; height: 16px; margin: -8px 0 0 -8px; }
+.avg-dot.nomove::after { inset: 5px; background: var(--surface); box-shadow: 0 0 0 1.5px var(--muted); }
+/* the seeker: a vertical line under the pointer, with what happened there */
+.c-seek { position: absolute; top: 0; bottom: 0; width: 1px; margin-left: -.5px; background: var(--muted); pointer-events: none; }
+.c-seek-tip { position: absolute; bottom: calc(100% + 6px); z-index: 3; width: max-content; max-width: min(260px, 100%); padding: 7px 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); box-shadow: 0 6px 18px rgb(0 0 0 / .18); color: var(--text-2); font-size: 12px; line-height: 1.4; pointer-events: none; }
+.c-seek-tip b { color: var(--text); font-variant-numeric: tabular-nums; }
+.c-seek-tip p { margin: 4px 0 0; }
+.c-seek-tip small { display: block; color: var(--muted); font-size: 11px; }
 .c-chart-val.start { left: 4px; bottom: auto; transform: translateY(calc(-100% - 4px)); color: var(--text-2); font-weight: 500; }
 

    ↑↓ to move, ↵ to open.