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); }