Changelog 044bdbb
Draw charts 19 and 20: police-record trends, latest changes and treatment shares
19 (Who the police record) was a list of numbers. Each country now has its own line since 2015, on its own scale, with the last step marked, and its latest change as a bar on a shared ±100% axis: fewer records in blue, more in red-orange (validated for colour-blind separation in both themes; side and sign carry it too). Rows stay alphabetical and counts stay in muted text, so nothing invites a cross-country enforcement rank. 20 (Police records and treatment entries) was a plain table. Police records get the same own-scale trend line; treatment entries get the share of all entrants who came mainly for cannabis, a proportion within each country's own treatment system. No ratio between the two is drawn. On a phone each row becomes a small card. Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd
2 files of code, design or docs +106 −16 lines
Code, design and documents 2 files
src/pages/charts.astro +45 −12
@@ -263,4 +263,22 @@ const OFF = DATASETS.cannabis_offences; const punished = countries.map((c) => ({ c, trend: policeTrend(c.series.cannabis_offences) })) .filter((p) => p.trend).sort((a, b) => a.c.name.localeCompare(b.c.name)); +// Each country's own count since SPARK0, as a sparkline on its own scale (0 to its highest year), +// so a line shows only the direction of that country's records, never a size next to another's. +// A missing year breaks the line. `step` is the last two years, the change the bar shows. +const SPARK0 = 2015, SPARK1 = Math.max(...punished.map((p) => p.trend.last.year)); +const policeSpark = (rows = []) => { + const pts = rows.filter((r) => r.year >= SPARK0 && Number.isFinite(r.offences)).sort((a, b) => a.year - b.year); + if (pts.length < 2) return null; + const top = Math.max(...pts.map((r) => r.offences)) || 1; + const at = (r) => [pct(r.year, SPARK0, SPARK1).toFixed(1), (94 - (r.offences / top) * 86).toFixed(1)]; + const line = pts.map((r, i) => `${i && r.year - pts[i - 1].year === 1 ? "L" : "M"}${at(r).join(",")}`).join(""); + const [a, b] = pts.slice(-2); + return { line, step: b.year - a.year === 1 ? `M${at(a).join(",")}L${at(b).join(",")}` : "", dots: `M${at(a).join(",")}l0,0M${at(b).join(",")}l0,0` }; +}; +// the bar for a change: half the track is 100%; a rise beyond that is capped and marked +const changeBar = (change) => { + const w = Math.min(Math.abs(change), 100) / 2; + return { left: change < 0 ? 50 - w : 50, width: w, over: change > 100 }; +}; // ── Stronger, cheaper: how much THC a euro buys in Europe, year by year ── @@ -1296,12 +1314,20 @@ const kick = (id) => { const i = sections.findIndex(([x]) => x === id); return ` <p class="ch-kicker"><a href="#punished">{kick("punished")}</a></p> <h2>Cannabis police records: how each country's latest count changed</h2> - <p class="ch-dek">Compare each country with its own previous reporting year. These are recorded use or possession offences or persons, depending on the country, rather than punishments. Different counting units and procedural stages make both cross-country ranks and magnitudes unreliable as enforcement comparisons. Rows are alphabetical.</p> - <ul class="ch-bars" data-no-preview> - {punished.map((p) => <li><a href={`${base}country/${p.c.slug}/#datasets`} data-punish={p.c.slug}> - <span class="ch-bar-name">{p.c.flag} {p.c.name}</span> - <span>{num(p.trend.first.offences)} ({p.trend.first.year}) → {num(p.trend.last.offences)} ({p.trend.last.year})</span> - <span class="ch-bar-num">{p.trend.change == null ? "—" : `${p.trend.change > 0 ? "+" : ""}${p.trend.change.toFixed(1)}%`}</span> - </a></li>)} - </ul> + <p class="ch-dek">Compare each country with its own previous reporting year. These are recorded use or possession offences or persons, depending on the country, rather than punishments. Different counting units and procedural stages make both cross-country ranks and magnitudes unreliable as enforcement comparisons. Rows are alphabetical. Each line is the country's own count since {SPARK0}, on its own scale; the bar is its latest change.</p> + <p class="ch-legend ch-pol-legend"><span><i class="down"></i>Fewer records than the year before</span><span><i class="up"></i>More records</span></p> + <div class="ch-pol" data-no-preview> + <div class="ch-pol-axis" aria-hidden="true"><span></span><span>Since {SPARK0}</span><span class="ch-pol-ticks"><b style="left:0">−100%</b><b style="left:25%">−50%</b><b style="left:50%">0</b><b style="left:75%">+50%</b><b style="left:100%">+100%</b></span><span></span></div> + <ul> + {punished.map((p) => { + const spark = policeSpark(p.c.series.cannabis_offences), ch = p.trend.change, bar = ch == null ? null : changeBar(ch); + return <li><a href={`${base}country/${p.c.slug}/#datasets`} data-punish={p.c.slug}> + <span class="ch-pol-name"><span class="ch-bar-name">{p.c.flag} {p.c.name}</span><small>{num(p.trend.first.offences)} ({p.trend.first.year}) → {num(p.trend.last.offences)} ({p.trend.last.year})</small></span> + {spark ? <svg class={`ch-pol-spark ${ch != null && ch < 0 ? "down" : "up"}`} viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"><path class="line" d={spark.line} />{spark.step && <path class="step" d={spark.step} />}<path class="dot" d={spark.dots} /></svg> : <span></span>} + <span class="ch-pol-track" aria-hidden="true">{bar && <i class={`${ch < 0 ? "down" : "up"}${bar.over ? " over" : ""}`} style={`left:${bar.left}%;width:${bar.width}%`}></i>}</span> + <span class={`ch-bar-num ${ch == null ? "" : ch < 0 ? "down" : "up"}`}>{ch == null ? "—" : `${ch > 0 ? "+" : ch < 0 ? "−" : ""}${Math.abs(ch).toFixed(1)}%`}</span> + </a></li>; + })} + </ul> + </div> <p class="ch-source">{OFF.title}. Latest two available years since 2020; reporting changes can also affect a country's trend. <a href="https://www.euda.europa.eu/data/stats2026/methods/dlo_en">EUDA counting methods</a>. <a href={`${base}sources/#dataset-cannabis_offences`}>About the dataset</a>.</p> </section> @@ -1311,7 +1337,14 @@ const kick = (id) => { const i = sections.findIndex(([x]) => x === id); return ` <h2>Police records and treatment entries describe different activities</h2> <p class="ch-dek">Separate annual counts, shown alphabetically: police records count events or persons under national definitions; treatment counts people entering care mainly for cannabis. They cannot measure a choice between punishment and care. The groups can overlap, including police or court referrals to treatment.</p> - <div class="ch-records-table" data-no-preview><table> - <thead><tr><th>Country</th><th>Police records · year</th><th>Treatment entries · year</th></tr></thead> - <tbody>{punishTreat.map((d) => <tr><th><a href={`${base}country/${d.c.slug}/#datasets`} data-ptreat={d.c.slug}>{d.c.name}</a></th><td>{num(d.punish)} · {d.offYear}</td><td>{num(d.treat)} · {d.trYear}</td></tr>)}</tbody> + <div class="ch-records-table ch-pt" data-no-preview><table> + <thead><tr><th>Country</th><th>Police records <small>own trend since {SPARK0} · latest · year</small></th><th>Treatment entries <small>share of all entrants who came for cannabis · count · year</small></th></tr></thead> + <tbody>{punishTreat.map((d) => { + const spark = policeSpark(d.c.series.cannabis_offences); + return <tr> + <th><a href={`${base}country/${d.c.slug}/#datasets`} data-ptreat={d.c.slug}><span>{d.c.flag}</span> {d.c.name}</a></th> + <td data-label="Police records"><span class="ch-pt-cell">{spark ? <svg class="ch-pol-spark flat" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"><path class="line" d={spark.line} /><path class="dot" d={spark.dots.replace(/^M[^M]*/, "")} /></svg> : <span></span>}<span class="ch-pt-num">{num(d.punish)} <small>{d.offYear}</small></span></span></td> + <td data-label="Treatment · share"><span class="ch-pt-cell">{d.share != null ? <span class="ch-pt-share" aria-hidden="true"><i style={`width:${d.share}%`}></i></span> : <span></span>}<span class="ch-pt-num">{d.share != null && <b>{d.share.toFixed(0)}%</b>} {num(d.treat)} <small>{d.trYear}</small></span></span></td> + </tr>; + })}</tbody> </table></div> <p class="ch-source">Police records: <a href={`${base}sources/#dataset-cannabis_offences`}>{OFF.title}</a>. Treatment: <a href={`${base}sources/#dataset-treatment`}>{TREAT.title}</a>. Croatia is excluded because it reports all clients in treatment, not annual entrants. Sweden has no matching police observation in this extract; its treatment figure combines separate sources. Observation years are printed for both counts; mismatched years and national reporting differences prevent direct ratios or rankings.</p> @@ -2320,5 +2353,5 @@ const kick = (id) => { const i = sections.findIndex(([x]) => x === id); return ` document.querySelectorAll("[data-ptreat]").forEach((a) => card(a, () => { const [name, punish, treat, oy, ty, share, score, tier] = D.punishTreat[a.dataset.ptreat]; - return `<b>${esc(name)}</b><span class="ch-tip-row">Police records, ${oy}<em>${punish.toLocaleString("en-GB")}</em></span><span class="ch-tip-row">Cannabis treatment entries, ${ty}<em>${treat.toLocaleString("en-GB")}</em></span><span class="ch-tip-sub">Separate counts with possible overlap; no punishment-to-care ratio.${a.dataset.ptreat === "se" ? " Sweden combines separate sources." : ""}</span>`; + return `<b>${esc(name)}</b><span class="ch-tip-row">Police records, ${oy}<em>${punish.toLocaleString("en-GB")}</em></span><span class="ch-tip-row">Cannabis treatment entries, ${ty}<em>${treat.toLocaleString("en-GB")}</em></span>${share != null ? `<span class="ch-tip-row">Share of all treatment entrants<em>${share.toFixed(1)}%</em></span>` : ""}<span class="ch-tip-sub">Separate counts with possible overlap; no punishment-to-care ratio.${a.dataset.ptreat === "se" ? " Sweden combines separate sources." : ""}</span>`; }));
src/styles/global.css +61 −4
@@ -3094,11 +3094,68 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent .ch-records-table { overflow-x: auto; } .ch-records-table table { width: 100%; } -#punished .ch-bars li a { grid-template-columns: minmax(7rem, 1fr) minmax(9rem, 2fr) 4rem; } +/* 19 · police records: each country's own line, then its latest change on a shared ±100% axis. + Fewer records in blue, more in red-orange (checked for colour-blind separation in both themes); + the sign, the side of the zero line and the label say it too. */ +.ch-pol { --down: var(--u3); --up: var(--t1); --cols: minmax(10rem, 1.2fr) minmax(6rem, 1fr) minmax(8rem, 1.6fr) 4.5rem; border-top: 1px solid var(--border); } +.ch-pol ul { margin: 0; padding: 0; list-style: none; } +.ch-pol li { border-bottom: 1px solid var(--border); } +.ch-pol li a, .ch-pol-axis { display: grid; grid-template-columns: var(--cols); gap: 18px; align-items: center; color: var(--text); } +.ch-pol li a { padding: 8px 0; } +.ch-pol li a:hover { background: color-mix(in srgb, var(--text) 5%, transparent); } +.ch-pol-axis { padding: 10px 0 6px; color: var(--muted); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; } +.ch-pol-ticks { position: relative; height: 1em; } +.ch-pol-ticks b { position: absolute; font-weight: 400; transform: translateX(-50%); white-space: nowrap; } +.ch-pol-ticks b:first-child { transform: none; } +.ch-pol-ticks b:last-child { transform: translateX(-100%); } +.ch-pol-name { display: grid; min-width: 0; } +.ch-pol-name small { color: var(--muted); font: 400 11px var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ch-pol-spark { width: 100%; height: 30px; overflow: visible; } +.ch-pol-spark path { fill: none; vector-effect: non-scaling-stroke; stroke-linecap: round; stroke-linejoin: round; } +.ch-pol-spark .line { stroke: var(--muted); stroke-width: 1.5; } +.ch-pol-spark .step { stroke-width: 2.5; } +.ch-pol-spark .dot { stroke-width: 6; } +.ch-pol-spark.down .step, .ch-pol-spark.down .dot { stroke: var(--down); } +.ch-pol-spark.up .step, .ch-pol-spark.up .dot { stroke: var(--up); } +.ch-pol-spark.flat .dot { stroke: var(--text-2); } +.ch-pol-track { position: relative; height: 14px; background: linear-gradient(var(--border), var(--border)) center / 100% 1px no-repeat; } +.ch-pol-track::after { content: ""; position: absolute; left: 50%; top: -4px; bottom: -4px; width: 1px; background: var(--text-2); } +.ch-pol-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; } +.ch-pol-track i.down { background: var(--down); border-radius: var(--r) 0 0 var(--r); } +.ch-pol-track i.up { background: var(--up); border-radius: 0 var(--r) var(--r) 0; } +.ch-pol-track i.over { border-right: 3px double var(--surface); } +.ch-pol .ch-bar-num { font-size: 13px; color: var(--text); } +.ch-pol-legend i.down { background: var(--u3); } +.ch-pol-legend i.up { background: var(--t1); } +/* 20 · police records and treatment entries side by side, each on its own terms */ +.ch-pt table { border-collapse: collapse; } +.ch-pt th small { display: block; margin-top: 3px; text-transform: none; letter-spacing: 0; font: 400 11px Inter, sans-serif; color: var(--muted); } +.ch-pt tbody th { font: 400 14px Inter, sans-serif; text-transform: none; letter-spacing: 0; white-space: nowrap; } +.ch-pt tbody th a { color: var(--text); } +.ch-pt td, .ch-pt tbody th { padding: 8px 18px 8px 0; vertical-align: middle; } +.ch-pt-cell { display: grid; grid-template-columns: minmax(80px, 1fr) 9.5rem; gap: 14px; align-items: center; } +.ch-pt .ch-pol-spark { height: 26px; } +.ch-pt-num { font: 400 13px var(--mono); text-align: right; white-space: nowrap; } +.ch-pt-num small { margin-left: 4px; color: var(--muted); font-size: 11px; } +.ch-pt-num b { margin-right: 6px; font-weight: 500; } +.ch-pt-share { position: relative; height: 10px; border-radius: var(--r); background: var(--surface-2); overflow: hidden; } +.ch-pt-share i { position: absolute; inset: 0 auto 0 0; background: var(--u3); border-radius: var(--r); } #work-for-a-gram .ch-bar-num { white-space: nowrap; } #work-for-a-gram .ch-bars li a { grid-template-columns: 200px minmax(0, 1fr) 140px; } @media (max-width: 600px) { - #punished .ch-bars li a { grid-template-columns: 1fr auto; } - #punished .ch-bars li a > span:nth-child(2) { grid-column: 1; font-size: .75rem; } - #punished .ch-bar-num { grid-column: 2; grid-row: 1 / 3; } + .ch-pol { --cols: minmax(0, 1fr) 4rem; } + .ch-pol li a { row-gap: 6px; } + .ch-pol li a .ch-pol-spark { grid-column: 1; grid-row: 2; height: 24px; } + .ch-pol li a .ch-pol-track { grid-column: 1 / -1; grid-row: 3; } + .ch-pol-axis { grid-template-columns: 1fr; } + .ch-pol-axis > span:not(.ch-pol-ticks) { display: none; } + /* on a phone each row becomes a small card: the country, then its two counts side by side */ + .ch-pt thead { display: none; } + .ch-pt table, .ch-pt tbody { display: block; } + .ch-pt tr { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; padding: 10px 0; border-bottom: 1px solid var(--border); } + .ch-pt tbody th { grid-column: 1 / -1; } + .ch-pt td, .ch-pt tbody th { padding: 0; border: 0; } + .ch-pt td::before { content: attr(data-label); display: block; margin-bottom: 4px; color: var(--muted); font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; } + .ch-pt-cell { grid-template-columns: 1fr; gap: 4px; } + .ch-pt-num { text-align: left; } #work-for-a-gram .ch-bars li a { grid-template-columns: 100px minmax(0, 1fr) 115px; } }