Cannabis Freedom Index

Changelog 99e7570

better chart

4 files of code, design or docs +146 −119 lines

Code, design and documents 4 files

source-archive.json +6 −2

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

src/components/AverageChart.astro +111 −110
@@ -1,23 +1,18 @@
 ---
-// World average score over time: a step line that moves at each recorded change.
-// Four views are rendered: the whole history or the last fifteen years, each averaged per country
-// or weighted by population. Two switches pick which one is shown.
-// The plot is a stretched SVG; labels and markers are HTML so they stay readable at any width.
-import { averageHistory, averageHistoryByPopulation, formatDate, dateTime, lastUpdated } from "../lib/countries.js";
+// The world over time in one chart: stacked bands, the share of countries (or of people) in each
+// score tier year by year, with the world average score drawn over them as a line. The bands and
+// the score share one 0–100 scale. Four views: the whole history or the last fifteen years, each
+// per country or weighted by population; two switches pick which one is shown. All four are in
+// the page, as they are small.
+// The plot is a stretched SVG; labels are HTML so they stay readable at any width.
+import { averageHistory, averageHistoryByPopulation, bandHistory, bandHistoryByPopulation, countries, dateTime, lastUpdated } from "../lib/countries.js";
 import { TIERS, tierOf, tierColor } from "../lib/tiers.js";
 
 // `note` adds the caveat under the chart.
-// Only the view visible before any click is part of the page. The other three, about 400 kB of
-// markers between them, are served by src/pages/fragments/avg-chart.astro (`part="rest"`) and
-// fetched when a switch is first used.
-const { note = false, part = "main" } = Astro.props;
-const base = import.meta.env.BASE_URL;
+const { note = false } = Astro.props;
 const fmt = (v) => v.toFixed(1);
 const time = dateTime;
-
 const endYear = +lastUpdated.slice(0, 4);
-const t1 = time(lastUpdated);
 
-// one chart for each combination of period and weighting
 const RANGES = [
   { id: "all", label: "All", startYear: 1860 }, // the handful of older bans are already counted in the starting value
@@ -25,87 +20,80 @@ const RANGES = [
 ];
 const WEIGHTS = [
-  { id: "country", label: "By country", title: "Every country counts the same", history: averageHistory },
-  { id: "people", label: "By population", title: "Each country counts in proportion to its population today", history: averageHistoryByPopulation },
+  { id: "country", label: "By country", title: "Every country counts the same", history: averageHistory, bands: bandHistory },
+  { id: "people", label: "By population", title: "Each country counts in proportion to its population", history: averageHistoryByPopulation, bands: bandHistoryByPopulation },
 ];
+const TIER_ORDER = [...TIERS].reverse(); // severe … free, as in bandHistory
+// a date as a fractional year, so the line's steps fall where the bands' years do
+const yearOf = (iso) => { const t = time(iso), y = new Date(t).getUTCFullYear(); return y + (t - Date.UTC(y, 0, 1)) / (Date.UTC(y + 1, 0, 1) - Date.UTC(y, 0, 1)); };
 
 function view(range, weighting) {
-  const { startYear } = range;
-  const history = weighting.history;
-  const now = history.at(-1).after;
-  const t0 = Date.UTC(startYear, 0, 1);
-  const x = (t) => ((t - t0) / (t1 - t0)) * 100;
-  const inRange = history.filter((p) => time(p.date) >= t0);
-  const start = inRange[0].before; // the average when the view begins
+  const rows = weighting.bands.filter(([yr]) => yr >= range.startYear);
+  const [y0, y1] = [rows[0][0], rows.at(-1)[0]];
+  const x = (yr) => Math.min(100, ((yr - y0) / (y1 - y0)) * 100);
+
+  // the bands, freest at the bottom and strictest on top, one point per year
+  const layers = [];
+  let below = rows.map(() => 0);
+  for (let k = TIER_ORDER.length - 1; k >= 0; k--) {
+    const top = below.map((b, i) => b + rows[i][1][k]);
+    const pts = top.map((v, i) => `${x(rows[i][0]).toFixed(2)},${(100 - v * 100).toFixed(2)}`)
+      .concat(below.map((v, i) => `${x(rows[i][0]).toFixed(2)},${(100 - v * 100).toFixed(2)}`).reverse());
+    layers.push({ color: tierColor(TIER_ORDER[k].min), points: pts.join(" ") });
+    below = top;
+  }
 
-  // y axis: round steps that enclose the data (the range is small, so it does not start at zero)
-  const values = inRange.flatMap((p) => [p.before, p.after]);
-  const spread = Math.max(...values) - Math.min(...values);
-  const tick = spread > 50 ? 20 : spread > 25 ? 10 : spread > 12 ? 5 : spread > 6 ? 2 : spread > 3 ? 1 : 0.5;
-  const yMin = Math.floor(Math.min(...values) / tick) * tick;
-  const yMax = Math.ceil(Math.max(...values) / tick) * tick;
-  const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100;
-  const ticks = Array.from({ length: Math.round((yMax - yMin) / tick) + 1 }, (_, i) => yMin + i * tick);
-  // the score tiers behind the line, cut to the visible range, so the reader sees which band the world is in
-  const bands = TIERS.map((t, i) => [Math.max(t.min, yMin), Math.min(i ? TIERS[i - 1].min : 100, yMax), t])
-    .filter(([lo, hi]) => hi > lo)
-    .map(([lo, hi, t]) => ({ name: t.name, color: tierColor(t.min), top: y(hi), height: y(lo) - y(hi) }));
+  // the world average, a step at each recorded change
+  const steps = weighting.history.filter((p) => yearOf(p.date) >= y0);
+  const start = steps[0].before, now = weighting.history.at(-1).after;
+  const line = `M0,${(100 - start).toFixed(2)}` + steps.map((p) => `H${x(yearOf(p.date)).toFixed(2)}V${(100 - p.after).toFixed(2)}`).join("") + "H100";
+  // the average at the end of each year, for the readout
+  const avgAt = rows.map(([yr]) => { let v = start; for (const p of steps) { if (yearOf(p.date) >= yr + 1) break; v = p.after; } return Math.round(v * 10); });
 
-  // at most about a dozen year labels, on round steps
-  const span = endYear - startYear;
+  const span = y1 - y0;
   const step = span > 120 ? 20 : span > 50 ? 10 : span > 20 ? 5 : span > 14 ? 2 : 1;
-  const years = Array.from({ length: span + 1 }, (_, i) => startYear + i)
-    .filter((yr) => yr % step === 0)
-    .map((yr) => ({ yr, x: x(Date.UTC(yr, 0, 1)) }));
+  const years = rows.map(([yr]) => yr).filter((yr) => yr % step === 0).map((yr) => ({ yr, x: x(yr) }));
 
-  const steps = inRange.map((p) => ({ ...p, x: x(time(p.date)), y: y(p.after) }));
-  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
-  // 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, bands, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
+  // the two red tiers: prohibition and severe prohibition
+  const banned = (r) => r[1][0] + r[1][1];
+  const bannedNow = banned(rows.at(-1)), bannedThen = banned(rows[0]);
+  const sub = { big: `${Math.round(bannedNow * 100)}%`, label: weighting.id === "country" ? "Countries under prohibition or worse" : "People under prohibition or worse",
+    text: weighting.id === "country" ? `${Math.round(bannedNow * countries.length)} of ${countries.length}` : "of the world's people", delta: Math.round((bannedNow - bannedThen) * 100) };
+  // for the readout: each year's shares in tenths of a per cent, and the average in tenths
+  const data = JSON.stringify({ y0, unit: weighting.id === "country" ? countries.length : 0, avg: avgAt, rows: rows.map(([, sh]) => sh.map((v) => Math.round(v * 1000))) });
+  return { range: range.id, weight: weighting.id, startYear: range.startYear, now, delta: now - start, layers, line, years, sub, data };
 }
 
 const views = WEIGHTS.flatMap((w) => RANGES.map((r) => view(r, w)));
 const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; // the view visible before any click
+const signed = (n, unit = "") => `${n > 0 ? "+" : n < 0 ? "−" : "±"}${Math.abs(n)}${unit}`;
 ---
-{part === "rest" ? views.filter((v) => !shown(v)).map((v) => (
-    <div class="avg-chart" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}>
-      <div class="avg-y" aria-hidden="true">
-        {v.ticks.map(({ t, y }) => <span style={`top:${y}%`}>{v.whole ? t : fmt(t)}</span>)}
-      </div>
-      <div class="avg-plot" data-seek={v.seek}>
-        <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
-          {v.bands.map((b) => <rect class="band" x="0" width="100" y={b.top} height={b.height} style={`fill:${b.color}`} />)}
-          {v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)}
-          <path class="line" d={v.line} />
-        </svg>
-        {v.points.map((p) => (
-          <a class="avg-dot" href={`${base}history/${p.change.id}/`} style={`left:${p.x}%;top:${p.y}%`}
-            data-date={formatDate(p.date)} data-title={`${p.change.country.flag} ${p.change.title}`}
-            data-avg={`World average ${fmt(p.before)} → ${fmt(p.after)}`}
-            aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
+<section class="card avg" id="avgChart" aria-labelledby="avgTitle">
+  {/* two figures: the world average score, and the share under prohibition or worse */}
+  <div class="avg-head">
+    <h2 id="avgTitle" class="visually-hidden">Cannabis freedom in the world over time</h2>
+    <div class="avg-stat">
+      <p class="avg-label">World average score</p>
+      <p class="avg-now">
+        {views.map((v) => (
+          <span class="avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}>
+            <b>{fmt(v.now)}</b>
+            <span class="avg-tier" style={`--c:${tierColor(v.now)}`}>{tierOf(v.now).name}</span>
+            <span class={`delta ${v.delta >= 0 ? "up" : "down"}`}>{v.delta >= 0 ? "+" : "−"}{fmt(Math.abs(v.delta))} since {v.startYear}</span>
+          </span>
         ))}
-        {v.bands.filter((b) => b.height >= 9).map((b) => <span class="avg-band" aria-hidden="true" style={`top:${b.top + b.height / 2}%;--c:${b.color}`}>{b.name}</span>)}
-        <span class="c-seek" hidden></span>
-        <div class="c-seek-tip" hidden></div>
-      </div>
-      <div class="avg-x" aria-hidden="true">
-        {v.years.map(({ yr, x }) => <span style={`left:${x}%`}>{yr}</span>)}
-      </div>
+      </p>
+    </div>
+    <div class="avg-stat">
+      {views.map((v) => <p class="avg-label avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}>{v.sub.label}</p>)}
+      <p class="avg-now">
+        {views.map((v) => (
+          <span class="avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}>
… 121 more lines
src/pages/fragments/avg-chart.astro +0 −7
@@ -1,7 +0,0 @@
----
-// The three views of the world-average chart that the pages leave out (see AverageChart.astro).
-// A fragment, not a page: no layout, fetched by the chart's script on the first switch click.
-import AverageChart from "../../components/AverageChart.astro";
-export const partial = true;
----
-<AverageChart part="rest" />

src/styles/global.css +29 −0
@@ -563,4 +563,33 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
 .avg-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border); }
 .avg-chart[hidden] { display: none; }
+/* the stacked bands (AverageChart.astro): shares of countries or people in each tier */
+.avg-bands .avg-plot svg polygon { stroke: none; }
+/* the chart's two figures, side by side, wrapping on a narrow screen */
+.avg-head .avg-stat { display: grid; gap: 4px; min-width: 0; }
+.avg-label { margin: 0; font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); }
+.avg-label[hidden] { display: none; }
+.avg-now .avg-figure { flex-wrap: wrap; row-gap: 4px; }
+/* side by side: each number with its band and change on the line below it */
+#avgChart .avg-head { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px 28px; }
+#avgChart .avg-now .avg-figure { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 6px 10px; }
+#avgChart .avg-now .avg-figure b { grid-column: 1 / -1; }
+#avgChart .avg-now .avg-figure .delta { grid-column: 1 / -1; }
+#avgChart .avg-figure[hidden] { display: none; }
+#avgChart .avg-now b { font-size: clamp(40px, 4.2vw, 60px); }
+#avgChart .avg-now .delta, #avgChart .avg-tier { font-size: 12px; }
+/* the world average drawn over the bands */
+.avg-plot .avg-over { fill: none; stroke: var(--text); stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 2px rgb(0 0 0 / .45)); }
+.avg-legend .avg-over-key { width: 16px; height: 2px; border-radius: 1px; background: var(--text); vertical-align: 3px; }
+.bands-seek { position: absolute; top: 0; bottom: 0; width: 1.5px; margin-left: -.75px; background: var(--text); pointer-events: none; }
+.bands-tip { position: absolute; top: 8px; display: grid; gap: 3px; min-width: 200px; padding: 9px 11px; border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); box-shadow: var(--shadow); font-size: 12.5px; pointer-events: none; z-index: 3; }
+.bands-tip[hidden], .bands-seek[hidden] { display: none; }
+.bands-tip b { font: 500 13px var(--mono); margin-bottom: 2px; }
+.bands-tip span { display: flex; align-items: center; gap: 7px; color: var(--text-2); }
+.bands-tip i { width: 9px; height: 9px; border-radius: 2px; flex: none; }
+.bands-tip em { margin-left: auto; padding-left: 14px; font: 500 12px var(--mono); font-style: normal; color: var(--text); }
+.bands-tip .tip-avg { color: var(--text); padding-bottom: 4px; margin-bottom: 2px; border-bottom: 1px solid var(--border); }
+.avg-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 12px 0 0 34px; font: 400 10.5px var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
+.avg-legend[hidden] { display: none; }
+.avg-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
 .avg-now { margin: 0; display: flex; align-items: baseline; gap: 10px; }
 .avg-now b { font-size: 32px; line-height: 1; font-variant-numeric: tabular-nums; }

    ↑↓ to move, ↵ to open.