Cannabis Freedom Index

Changelog 8eb5598

survey updates

4 files of code, design or docs +37 −9 lines

Code, design and documents 4 files

scripts/survey-collect.cjs +4 −1
@@ -4,4 +4,6 @@
 // The secret key is read from SURVEY_SECRET_KEY, in the environment or in the git-ignored .env.
 // Add --out <file> to write somewhere other than survey/results-<year>.json.
+// Add --min <n> to publish groups and countries with as few as n answers, for a look at the first
+// answers while testing. With real respondents leave it out: small groups can identify a person.
 // Every event is checked (signature, kind, topic), decrypted with the project's secret key, cleaned
 // against the questionnaire and counted by survey-lib.cjs. Only survey/results-<year>.json is
@@ -70,5 +72,6 @@ async function fetchEvents() {
     for (const q of [...survey.all, ...survey.territory.questions].filter((q) => q.type === "text")) if (payload.answers[q.id]) texts.push([q.id, String(payload.answers[q.id]).slice(0, 4000)]);
   }
-  const out = aggregate(survey, responses, observations, countries, { mock: false, generated: new Date().toISOString().slice(0, 10) });
+  const min = args.includes("--min") ? Math.max(1, +args[args.indexOf("--min") + 1] || 1) : null;
+  const out = aggregate(survey, responses, observations, countries, { mock: false, generated: new Date().toISOString().slice(0, 10) }, min ? { segment: min, territory: min } : {});
   const file = args.includes("--out") ? args[args.indexOf("--out") + 1] : path.join(ROOT, "survey", `results-${EDITION}.json`);
   fs.writeFileSync(file, JSON.stringify(out, null, 1) + "\n");

scripts/survey-lib.cjs +5 −3
@@ -8,6 +8,7 @@ const yaml = require("js-yaml");
 
 const ROOT = path.join(__dirname, "..");
-const MIN_SEGMENT = 30; // a filter (e.g. one region) is published only with this many respondents
-const MIN_TERRITORY = 5; // a country only with this many observations
+// What is published: a filter (e.g. one region) only with this many respondents, a country only
+// with this many observations. survey-collect.cjs can lower them for a look at early answers.
+const DEFAULTS = { segment: 30, territory: 5 };
 
 // US dollars for one unit of each currency a price may be given in (survey/currencies.yaml)
@@ -61,5 +62,6 @@ const round = (x, d = 2) => Math.round(x * 10 ** d) / 10 ** d;
 
 // responses: cleaned personal answers; observations: cleaned territory observations
-function aggregate(survey, responses, observations, countries, extra = {}) {
+function aggregate(survey, responses, observations, countries, extra = {}, limits = {}) {
+  const MIN_SEGMENT = limits.segment ?? DEFAULTS.segment, MIN_TERRITORY = limits.territory ?? DEFAULTS.territory;
   const segmentsOf = (r) => {
     const rel = r.relation ?? [];

src/pages/survey/index.astro +22 −5
@@ -19,5 +19,5 @@ const pct = (count, n) => (n ? Math.round((count / n) * 100) : 0);
 const places = results.territories.map((t) => ({ ...t, c: byId.get(t.country) })).filter((t) => t.c);
 const maxScore = Math.max(60, ...places.map((t) => t.c.score));
-const maxPrice = Math.ceil(Math.max(...places.map((t) => t.price ?? 0)) / 5) * 5;
+const maxPrice = Math.max(5, Math.ceil(Math.max(0, ...places.map((t) => t.price ?? 0)) / 5) * 5);
 const W = 340, H = 240, PAD = { l: 34, r: 12, t: 12, b: 30 };
 const plots = [
@@ -33,7 +33,11 @@ const plots = [
   const cov = pts.reduce((a, t) => a + (t.c.score - mx) * (t[p.id] - my), 0);
   const r = cov / Math.sqrt(pts.reduce((a, t) => a + (t.c.score - mx) ** 2, 0) * pts.reduce((a, t) => a + (t[p.id] - my) ** 2, 0));
-  return { ...p, pts, x, y, r };
+  // a correlation needs a few countries to mean anything
+  return { ...p, pts, x, y, r: pts.length >= 3 && Number.isFinite(r) ? r : null };
 });
 const fmt = (v) => (v == null ? "–" : v.toFixed(1));
+// shown in place of the country charts and table until a country has enough descriptions
+const need = results.thresholds.territory;
+const waiting = `No country has been described by ${need} ${need === 1 ? "person" : "people"} yet, which is the least we publish. ${num(results.observations)} ${results.observations === 1 ? "place has" : "places have"} been described so far, in ${results.countries_described} ${results.countries_described === 1 ? "country" : "countries"}.`;
 ---
 <Base title={survey.title} description={`${survey.title}: an anonymous, community survey of how people use, grow and think about cannabis, set against the law in each country.`}>
@@ -61,7 +65,16 @@ const fmt = (v) => (v == null ? "–" : v.toFixed(1));
     </dl>
 
+    {!results.mock && results.respondents < results.thresholds.segment && (
+      <p class="sv-mock" role="note"><strong>Early days.</strong> {results.respondents === 1 ? "One person has" : `${num(results.respondents)} people have`} answered so far. The charts below show their answers, but nothing can be read from so few. Country figures appear once {need} people have described the same country.</p>
+    )}
+
     <p class="sv-note">People chose to answer this survey; nobody was sampled. The figures describe the {num(results.respondents)} people who took part, most of whom use cannabis, and not the population of any country. A group is shown only when at least {results.thresholds.segment} people are in it, and a country only when at least {results.thresholds.territory} people described it.</p>
 
+    {/* While the numbers are invented, the word SAMPLE lies across the results (the charts and
+        the country table, nothing else) and stays in the bar that follows the reader down. */}
+    <div class="sv-results">
+    {results.mock && <div class="sv-watermark" aria-hidden="true"></div>}
     <div class="sv-filter" id="svFilter" role="group" aria-label="Show answers from">
+      {results.mock && <strong class="sv-sample-tag">Sample data, not findings</strong>}
       <span>Answers from</span>
       {results.segments.map((s, i) => (
@@ -114,5 +127,6 @@ const fmt = (v) => (v == null ? "–" : v.toFixed(1));
         <p class="sub">Each dot is a country, placed by its <a href={`${base}#rankings`}>index score</a> from left (strict) to right (free) and by what the people who described it reported. Where the dots follow a line, life follows the law; where they scatter, it does not.</p>
       </div></div>
-      <div class="sv-plots">
+      {places.length === 0 && <p class="sv-empty-note">{waiting}</p>}
+      <div class="sv-plots" hidden={places.length === 0}>
         {plots.map((p) => (
           <figure>
@@ -129,5 +143,5 @@ const fmt = (v) => (v == null ? "–" : v.toFixed(1));
               ))}
             </svg>
-            <p class="sv-meta">Index score → · correlation {p.r.toFixed(2)} · {p.pts.length} countries</p>
+            <p class="sv-meta">Index score →{p.r != null && ` · correlation ${p.r.toFixed(2)}`} · {p.pts.length} {p.pts.length === 1 ? "country" : "countries"}</p>
           </figure>
         ))}
@@ -140,5 +154,6 @@ const fmt = (v) => (v == null ? "–" : v.toFixed(1));
         <p class="sub">What residents and visitors reported about each country, beside its score. Scales run from 1 to 5. Prices are the median reported for a gram of flower, converted to US dollars at the European Central Bank rates of {results.rates_date}.</p>
       </div></div>
-      <div class="sv-table-wrap">
+      {places.length === 0 && <p class="sv-empty-note">{waiting}</p>}
+      <div class="sv-table-wrap" hidden={places.length === 0}>
         <table class="src-table sv-table">
           <thead><tr><th>Country</th><th class="num">Index</th><th class="num">People</th><th class="num">Accepted</th><th class="num">Enforced</th><th class="num">Easy to get</th><th class="num">Used openly</th><th class="num">Gram</th></tr></thead>
@@ -161,4 +176,6 @@ const fmt = (v) => (v == null ? "–" : v.toFixed(1));
     </section>
 
+    </div>
+
     <section class="section" id="method">
       <div class="section-head"><div>

src/styles/global.css +6 −0
@@ -1517,2 +1517,8 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
   button.sv-row::after { grid-row: 1; grid-column: 2; }
 }
+.sv-empty-note { margin: 0; padding: 18px 0; border-top: 1px solid var(--text); border-bottom: 1px solid var(--border); color: var(--text-2); max-width: 80ch; }
+.sv-plots[hidden], .sv-table-wrap[hidden] { display: none; }
+/* /survey/ with invented numbers: SAMPLE across the page, and a tag in the sticky bar */
+.sv-results { position: relative; }
+.sv-watermark { position: absolute; inset: 0; z-index: 4; pointer-events: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='420' height='300'%3E%3Ctext x='210' y='160' text-anchor='middle' transform='rotate(-24 210 150)' font-family='ui-monospace,Menlo,monospace' font-size='44' font-weight='700' letter-spacing='10' fill='%23808080' fill-opacity='.17'%3ESAMPLE%3C/text%3E%3C/svg%3E"); background-size: 420px 300px; }
+.sv-sample-tag { padding: 4px 9px; margin-right: 10px; background: var(--t2); color: #0c0c0b; font: 600 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; border-radius: var(--r); }

    ↑↓ to move, ↵ to open.