Changelog 5d7c813
survey basics
23 files of code, design or docs +6,546 −2 lines
Code, design and documents 23 files
astro.config.mjs +4 −0
@@ -13,3 +13,7 @@ export default defineConfig({ site: process.env.SITE || "https://cannabisfreedom.fyi", base, + // A build keeps its own Vite cache. Sharing the dev server's would wipe the dependencies the + // running dev server has prepared (Fuse, nostr-tools), and its lazy imports would then fail + // until it is restarted. + vite: { cacheDir: process.argv.includes("build") ? "node_modules/.vite-build" : "node_modules/.vite" }, });
docs/DATA-MAINTENANCE.md +45 −0
@@ -600,2 +600,47 @@ The number caps one run. `check` only reads; `save` asks the Internet Archive to which it limits to a few requests a minute, so run it in small batches. Run `check` after adding sources, and `save` for the ones that come back without a copy. + +## The survey (State of Cannabis) + +`/survey/` shows the counted results of the yearly survey and `/survey/take/` is the form. Survey +is in the main menu, after History. + +| File | What it is | +|---|---| +| `survey/2026.yaml` | The questionnaire of the edition: sections, questions, options, conditions, and the Nostr settings. The form and the results page are built from it. | +| `survey/results-2026.json` | The counted results the site shows and offers at `/data/survey.json`. Counts only. `"mock": true` means invented numbers, and the page says so. | +| `scripts/survey-lib.cjs` | Cleans answers against the questionnaire and counts them. A group is published only with 30 respondents, a country only with 5 observations. | +| `scripts/survey-mock.cjs` | Writes invented results, for designing the page before the survey opens. | +| `scripts/survey-collect.cjs` | Fetches the real answers, decrypts them and writes the counted results. | +| `src/lib/survey-client.js` | The browser side: the respondent's key, their stored submissions, signing and the relays. | +| `scripts/survey-keygen.cjs` | Prints a new key pair for the project. | + +**How an answer travels.** The first time someone submits, their browser makes a Nostr key and +keeps it in its local storage. The personal answers and each place described become one +replaceable event each (NIP-78, kind 30078, one `d` tag per part), signed with that key, encrypted +between it and the project's key (NIP-44) and sent to the relays listed in the questionnaire file. +Submitting again replaces a part; deleting publishes an empty replacement and a NIP-09 deletion +request. `/survey/me/` shows a browser its own submissions and lets it change them, delete them, +back up the key or restore one. The key is a pseudonym: it ties one person's parts together but +not to the person. While `open` is false, or no public key and relays are set, nothing is sent +and submissions wait in the browser. + +**To try it before opening.** Run a relay of your own, then in the browser's console set +`localStorage["cfi-survey-test"] = JSON.stringify({pubkey: "<hex>", relays: ["ws://localhost:7447"]})`. +That browser alone then submits to that relay. + +**To open the survey.** Make a Nostr key pair for the project (`node scripts/survey-keygen.cjs`) and keep the secret key off this +repository and off any server. Put the public key (hex) and two or three relays under `nostr:` in +`survey/2026.yaml`, set `open: true`, and deploy. To publish results, run +`SURVEY_SECRET_KEY=<hex> node scripts/survey-collect.cjs` on your own machine and commit the +rewritten `survey/results-2026.json`. Add `--text` to read the free-text answers in the terminal; +they are stored nowhere. + +**Keeping years comparable.** A new edition is a new `survey/<year>.yaml` and results file. Keep +question ids and option ids unchanged wherever the question is the same. + +**Known limits.** Anyone can make as many keys as they like, so one person can still answer many +times; the collector counts one set of answers per key. Relays see the address a message comes +from. A relay may ignore deletion requests, which is why a deleted part is also overwritten with +an empty one. Someone who clears their browser without a backup of the key can no longer change +their answers.
package-lock.json +105 −0
Not shown: a picture, a generated file, or past the length shown per change.
package.json +1 −0
@@ -16,4 +16,5 @@ "d3-zoom": "^3.0.0", "fuse.js": "^7.5.0", + "nostr-tools": "^2.25.2", "topojson-client": "^3.1.0", "world-atlas": "^2.0.2"
scripts/survey-collect.cjs +77 −0
@@ -0,0 +1,77 @@ +// Turns submitted survey answers into the counted results the site publishes: +// node scripts/survey-collect.cjs fetch from the relays in survey/<year>.yaml +// node scripts/survey-collect.cjs --from events.jsonl read saved events instead +// 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. +// 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 +// written: counts, never single answers. Free text is printed to the terminal with --text, for +// the researchers, and is stored nowhere by this script. The secret key is read from the +// environment and must never be put in a file of this repository. +const fs = require("fs"); +const path = require("path"); +const { verifyEvent } = require("nostr-tools/pure"); +const { decrypt, getConversationKey } = require("nostr-tools/nip44"); +const { ROOT, loadSurvey, loadRegions, clean, aggregate } = require("./survey-lib.cjs"); + +const EDITION = 2026; +const args = process.argv.slice(2); +const from = args.includes("--from") ? args[args.indexOf("--from") + 1] : null; +const survey = loadSurvey(EDITION); +const countries = loadRegions(); +const { kind, topic, relays } = survey.nostr; +// the key comes from the environment, or from the git-ignored .env file beside package.json +const fromEnvFile = () => { try { return fs.readFileSync(path.join(ROOT, ".env"), "utf8").match(/^SURVEY_SECRET_KEY=([0-9a-f]{64})\s*$/m)?.[1]; } catch { return undefined; } }; +const hex = process.env.SURVEY_SECRET_KEY ?? fromEnvFile(); +if (hex && !/^[0-9a-f]{64}$/.test(hex)) { console.error("SURVEY_SECRET_KEY must be 64 hex characters"); process.exit(1); } +const secret = hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null; + +async function fetchEvents() { + if (from) return fs.readFileSync(from, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); + if (!relays.length) { console.error(`survey/${EDITION}.yaml lists no relays; use --from <file>`); process.exit(1); } + const { SimplePool } = require("nostr-tools/pool"); + const pool = new SimplePool(); + const events = await pool.querySync(relays, { kinds: [kind], "#t": [topic] }); + pool.close(relays); + return events; +} + +(async () => { + const responses = [], observations = [], texts = []; + const dropped = { invalid: 0, superseded: 0, deleted: 0, unreadable: 0, other: 0 }; + // Each part of a submission is a replaceable event: only the newest version under one key and + // one `d` tag counts, whatever older copies a relay still returns. + const newest = new Map(); + for (const ev of await fetchEvents()) { + const d = ev.tags?.find((t) => t[0] === "d")?.[1]; + if (ev.kind !== kind || !d?.startsWith(`${topic}:`) || !ev.tags.some((t) => t[0] === "t" && t[1] === topic) || !verifyEvent(ev)) { dropped.invalid++; continue; } + const slot = `${ev.pubkey}:${d}`, had = newest.get(slot); + if (had) dropped.superseded++; + if (!had || had.created_at < ev.created_at) newest.set(slot, ev); + } + for (const ev of newest.values()) { + let payload; + try { + const plain = ev.content.startsWith("{") ? ev.content : decrypt(ev.content, getConversationKey(secret, ev.pubkey)); + payload = JSON.parse(plain); + } catch { dropped.unreadable++; continue; } + if (payload?.deleted) { dropped.deleted++; continue; } // the respondent removed this part + if (payload?.edition !== EDITION || typeof payload.answers !== "object") { dropped.other++; continue; } + if (payload.type === "response") { + const r = clean(survey.all, payload.answers, countries); + // the two questions without which an answer cannot be placed in any group + if (!r.residence || !r.relation) { dropped.other++; continue; } + responses.push(r); + } else if (payload.type === "observation") { + const o = clean(survey.territory.questions, payload.answers, countries); + if (!o.country || !o.tie) { dropped.other++; continue; } + observations.push(o); + } else { dropped.other++; continue; } + 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 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"); + console.log(`collected: ${responses.length} responses, ${observations.length} observations | dropped: ${JSON.stringify(dropped)} → ${path.relative(ROOT, file)}`); + if (args.includes("--text")) for (const [id, text] of texts) console.log(`\n[${id}] ${text}`); +})();
scripts/survey-keygen.cjs +6 −0
@@ -0,0 +1,6 @@ +// Makes the project's Nostr key pair for the survey: node scripts/survey-keygen.cjs +// The public key goes into survey/<year>.yaml. The secret key is printed once, here, and nowhere +// else: store it offline. Whoever holds it can read every answer. +const { generateSecretKey, getPublicKey } = require("nostr-tools/pure"); +const sk = generateSecretKey(); +console.log(`public key (for survey/<year>.yaml, nostr.pubkey):\n ${getPublicKey(sk)}\n\nsecret key (SURVEY_SECRET_KEY for survey-collect.cjs; keep it offline):\n ${Buffer.from(sk).toString("hex")}`);
scripts/survey-lib.cjs +131 −0
@@ -0,0 +1,131 @@ +// Shared by survey-mock.cjs and survey-collect.cjs: reads the questionnaire of an edition +// (survey/<year>.yaml), cleans raw answers against it and counts them into the aggregated file +// that the site publishes (survey/results-<year>.json). Only counts leave this module: no free +// text, no single response, and nothing for a group too small to hide in. +const fs = require("fs"); +const path = require("path"); +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 + +// US dollars for one unit of each currency a price may be given in (survey/currencies.yaml) +const money = yaml.load(fs.readFileSync(path.join(ROOT, "survey", "currencies.yaml"), "utf8")); +const USD = Object.fromEntries(Object.entries(money.per_eur).map(([code, perEur]) => [code, money.per_eur.USD / perEur])); + +function loadSurvey(edition) { + const survey = yaml.load(fs.readFileSync(path.join(ROOT, "survey", `${edition}.yaml`), "utf8")); + survey.all = survey.sections.flatMap((s) => s.questions); + return survey; +} + +// region of every country of the index, for the regional filters +function loadRegions() { + const dir = path.join(ROOT, "countries"); + const out = {}; + for (const f of fs.readdirSync(dir)) { + const file = f.endsWith(".yaml") ? path.join(dir, f) : path.join(dir, f, "index.yaml"); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue; + const d = yaml.load(fs.readFileSync(file, "utf8")); + out[f.replace(/\.yaml$/, "").toUpperCase()] = d; + } + return out; +} + +// Keep only what the questionnaire allows: known ids, known options, numbers in range. +function clean(questions, raw, countries) { + const out = {}; + for (const q of questions) { + const v = raw?.[q.id]; + if (v == null || v === "") continue; + if (q.type === "single" && typeof v === "string" && v in q.options) out[q.id] = v; + else if (q.type === "multi" && Array.isArray(v)) { + const xs = [...new Set(v.filter((x) => typeof x === "string" && x in q.options))]; + if (xs.length) out[q.id] = xs; + } else if (q.type === "scale" && Number.isInteger(v) && v >= 1 && v <= 5) out[q.id] = v; + else if (q.type === "number" && typeof v === "number" && v > 0 && v < 1e7) out[q.id] = v; + else if (q.type === "currency" && typeof v === "string" && USD[v]) out[q.id] = v; + else if (q.type === "country" && typeof v === "string" && countries[v.toUpperCase()]) out[q.id] = v.toUpperCase(); + else if (q.type === "text" && typeof v === "string") out[q.id] = v.slice(0, 4000); + } + return out; +} + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b); + return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2; +}; +const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; +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 = {}) { + const segmentsOf = (r) => { + const rel = r.relation ?? []; + const out = ["all"]; + if (rel.includes("use") || rel.includes("patient")) out.push("users"); + if (rel.includes("grow")) out.push("growers"); + if (rel.includes("never")) out.push("never"); + const region = countries[r.residence]?.region; + if (region) out.push(`region:${region}`); + return out; + }; + const members = {}; + for (const r of responses) for (const s of segmentsOf(r)) (members[s] ??= []).push(r); + const label = (id) => ({ all: "Everyone", users: "People who use it", growers: "Growers", never: "Never used it" })[id] ?? id.replace("region:", ""); + const segments = Object.entries(members) + .filter(([id, rs]) => id === "all" || rs.length >= MIN_SEGMENT) + .map(([id, rs]) => ({ id, label: label(id), n: rs.length })) + .sort((a, b) => (a.id.startsWith("region:") - b.id.startsWith("region:")) || b.n - a.n); + + const questions = {}; + for (const q of survey.all.filter((q) => q.type !== "text")) { + const entry = { n: {}, counts: {} }; + if (q.type === "scale") entry.mean = {}; + for (const { id } of segments) { + const answers = members[id].map((r) => r[q.id]).filter((v) => v != null); + entry.n[id] = answers.length; + const counts = {}; + if (q.type === "scale") for (let k = 1; k <= 5; k++) counts[k] = 0; + else if (q.options) for (const o of Object.keys(q.options)) counts[o] = 0; + for (const v of answers) for (const x of [].concat(v)) counts[x] = (counts[x] ?? 0) + 1; + entry.counts[id] = counts; + if (q.type === "scale" && answers.length) entry.mean[id] = round(mean(answers)); + } + questions[q.id] = entry; + } + + const byCountry = {}; + for (const o of observations) (byCountry[o.country] ??= []).push(o); + const avg = (os, id) => { const xs = os.map((o) => o[id]).filter((v) => v != null); return xs.length ? round(mean(xs)) : null; }; + const territories = Object.entries(byCountry) + .filter(([, os]) => os.length >= MIN_TERRITORY) + .map(([country, os]) => { + // every price in US dollars at the rates on file; a price with no currency was asked in + // dollars, and one outside 20 cents to 200 dollars a gram is taken for a slip and left out + const prices = (src) => os.filter((o) => o.t_price != null && (!src || o.t_price_source === src)) + .map((o) => o.t_price * USD[o.t_currency ?? "USD"]).filter((usd) => usd >= 0.2 && usd <= 200); + const count = (id) => { const c = {}; for (const o of os) if (o[id]) c[o[id]] = (c[o[id]] ?? 0) + 1; return c; }; + return { + country, n: os.length, tie: count("tie"), + acceptance: avg(os, "t_acceptance"), enforcement: avg(os, "t_enforcement"), access: avg(os, "t_access"), + public: count("t_public"), + price: prices().length >= MIN_TERRITORY ? round(median(prices())) : null, price_n: prices().length, + price_legal: prices("legal").length >= MIN_TERRITORY ? round(median(prices("legal"))) : null, + price_illegal: prices("illegal").length >= MIN_TERRITORY ? round(median(prices("illegal"))) : null, + }; + }) + .sort((a, b) => b.n - a.n); + + return { + edition: survey.edition, ...extra, + respondents: responses.length, observations: observations.length, + countries_lived: new Set(responses.map((r) => r.residence).filter(Boolean)).size, + countries_described: Object.keys(byCountry).length, + thresholds: { segment: MIN_SEGMENT, territory: MIN_TERRITORY }, rates_date: money.date, + segments, questions, territories, + }; +} + +module.exports = { ROOT, loadSurvey, loadRegions, clean, aggregate };
scripts/survey-mock.cjs +118 −0
@@ -0,0 +1,118 @@ +// Invented survey answers, so that /survey/ can be designed and tested before the survey opens: +// node scripts/survey-mock.cjs [respondents] +// Writes survey/results-<edition>.json with "mock": true, which the page announces loudly. The +// answers are random draws shaped by each country's index score; they are NOT findings. The same +// counting code (survey-lib.cjs) is used for real answers, so the file has the real shape. +const fs = require("fs"); +const path = require("path"); +const { ROOT, loadSurvey, loadRegions, clean, aggregate } = require("./survey-lib.cjs"); + +const EDITION = 2026; +const N = +process.argv[2] || 2600; +const survey = loadSurvey(EDITION); +const countries = loadRegions(); + +// a fixed seed: the same invented data on every run +let seed = 20261012; +const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +const gauss = () => (rnd() + rnd() + rnd() + rnd() - 2) * 0.87; +const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); +const scale = (x) => clamp(Math.round(x), 1, 5); +function pick(weights) { // {option: weight} + let r = rnd() * Object.values(weights).reduce((a, b) => a + b, 0); + for (const [k, w] of Object.entries(weights)) if ((r -= w) <= 0) return k; + return Object.keys(weights)[0]; +} +const some = (probs) => Object.entries(probs).filter(([, p]) => rnd() < p).map(([k]) => k); + +const WEIGHTS = { possession: 25, cultivation: 25, enforcement: 20, sharing: 10, consumption: 10, access: 10 }; +const score = (id) => Object.entries(WEIGHTS).reduce((a, [k, w]) => a + (countries[id].scores[k] * w) / 10, 0); // 0–100 + +// who answers a web survey in English: mostly Europe and the Americas, a long tail elsewhere +const HOME = { US: 300, DE: 260, GB: 170, CA: 150, CZ: 140, NL: 110, ES: 110, FR: 100, PL: 95, IT: 90, AU: 85, BR: 70, MX: 60, CH: 55, AT: 50, PT: 45, TH: 45, ZA: 45, IN: 45, SE: 35, UY: 30, AR: 35, CO: 30, IE: 30, BE: 30, DK: 28, NO: 22, FI: 22, IL: 25, JP: 22, NZ: 22, GR: 18, HU: 18, RO: 16, TR: 16, MA: 14, NG: 14, KE: 10, PH: 12, ID: 10, SG: 6, KR: 8, EG: 6, AE: 5, JM: 12, CL: 20, MT: 10, LU: 8 }; +for (const id of Object.keys(HOME)) if (!countries[id]) delete HOME[id]; +const ANY = Object.fromEntries(Object.keys(countries).filter((id) => countries[id].scores).map((id) => [id, HOME[id] ? HOME[id] / 4 + 3 : 1])); +// an invented street price per country, cheaper where cannabis is freer or grown locally +const basePrice = Object.fromEntries(Object.keys(ANY).map((id) => [id, clamp(13 - score(id) * 0.09 + gauss() * 4, 1.5, 28)])); + +// each country also differs from what its law predicts, so the dots do not fall on a line +const quirk = Object.fromEntries(Object.keys(ANY).map((id) => [id, { a: gauss() * 0.55, e: gauss() * 0.6, x: gauss() * 0.5 }])); +const responses = [], observations = []; +for (let i = 0; i < N; i++) { + const home = pick(HOME), s = score(home) / 100; // 0–1, how free the law is at home + const liberal = gauss(); // the person's own leaning + const r = { residence: home }; + r.age = pick({ a18: 22, a25: 34, a35: 22, a45: 12, a55: 7, a65: 3 }); + r.gender = pick({ man: 62, woman: 30, other: 4, skip: 4 }); + r.setting = pick({ city: 52, town: 33, rural: 15 }); + const kind = pick({ use: 68, past: 12, never: 14, study: 6 }); + r.relation = kind === "use" ? ["use"] : kind === "past" ? ["past"] : kind === "never" ? ["never"] : ["research", ...(rnd() < 0.5 ? ["use"] : ["never"])]; + const uses = r.relation.includes("use"); + if (uses && rnd() < 0.14 + s * 0.3) r.relation.push("grow"); + if (uses && rnd() < 0.12) r.relation.push("patient"); + if (rnd() < 0.05) r.relation.push("work"); + + if (uses || kind === "past") { + r.frequency = uses ? pick({ daily: 30, weekly: 30, monthly: 20, yearly: 20 }) : pick({ none: 80, yearly: 20 }); + r.first_age = pick({ u15: 12, a15: 38, a18: 28, a21: 14, o25: 8 }); + r.methods = some({ joint_tobacco: home === "US" || home === "CA" ? 0.1 : 0.55, joint_pure: 0.45, pipe: 0.3, vaporiser: 0.28 + s * 0.2, edibles: 0.2 + s * 0.25, oil: 0.12, concentrates: 0.06 + s * 0.15 }); + r.products = some({ flower: 0.9, hashish: ["MA", "ES", "FR", "NL", "DK", "PT"].includes(home) ? 0.6 : 0.2, edibles: 0.2 + s * 0.3, cartridges: 0.08 + s * 0.3, extracts: 0.1, cbd: 0.25 }); + r.reasons = some({ relax: 0.8, social: 0.5, sleep: 0.45, pain: 0.25, creative: 0.3, ritual: 0.08, habit: 0.2 }); + r.company = pick({ alone: 30, others: 30, both: 40 }); + } + if (r.relation.includes("grow")) { + r.grow_years = pick({ y1: 20, y3: 38, y10: 28, y10p: 14 }); + r.grow_where = some({ indoor: 0.55, balcony: 0.3, outdoor: 0.4 }); + r.grow_plants = pick({ p2: 35, p5: 40, p10: 15, p10p: 10 }); + r.grow_reasons = some({ quality: 0.7, cost: 0.6, market: 0.5, enjoy: 0.65, medical: 0.2, share: 0.25 }); + r.grow_challenges = some({ law: 0.75 - s * 0.6, neighbours: 0.4, pests: 0.45, cost: 0.3, knowledge: 0.25, seeds: 0.25 - s * 0.15 }); + r.grow_legal = countries[home].scores.cultivation >= 4 ? pick({ legal: 80, tolerated: 10, unsure: 10 }) : pick({ illegal: 65, tolerated: 20, unsure: 15 }); + } + if (uses) { + const legalShop = countries[home].scores.access >= 4; + r.source = some({ shop: legalShop ? 0.6 : 0.03, club: ["ES", "DE", "UY", "MT"].includes(home) ? 0.3 : 0.02, own: r.relation.includes("grow") ? 0.9 : 0, gift: 0.3, friends: 0.45, dealer: legalShop ? 0.2 : 0.55, online: 0.1 }); + r.spend = pick({ s0: 12, s25: 28, s75: 32, s150: 18, s150p: 10 }); + r.affordable = scale(3.2 + gauss()); + } + r.acceptance = scale(1.6 + s * 3 + quirk[home].a + liberal * 0.3 + gauss() * 0.8); + r.versus_alcohol = pick({ more: 6 + s * 20, same: 20 + s * 30, less: 74 - s * 50 }); + if (uses || kind === "past") { + r.family = pick({ open: 20 + s * 40, some: 40, hidden: 35 - s * 25, na: 5 }); + r.work = pick({ yes: 10 + s * 35, maybe: 35, no: 50 - s * 35, na: 5 }); + const hit = some({ family: 0.3 - s * 0.15, job: 0.1 - s * 0.05, doctor: 0.15, housing: 0.05, friends: 0.08 }); + r.stigma = hit.length ? hit : ["none"]; + } + r.tradition = ["IN", "MA", "JM", "NP", "ZA", "MX", "TH", "NG"].includes(home) ? pick({ yes: 65, no: 15, unsure: 20 }) : pick({ yes: 12, no: 55, unsure: 33 }); + r.generations = pick({ more: 70, same: 22, less: 8 }); + r.law_known = scale(3.4 + (uses ? 0.5 : -0.4) + gauss() * 0.9); + r.law_fair = scale(1.3 + s * 2.6 - liberal * 0.5 + (kind === "never" ? 1 : 0) + gauss() * 0.8); + r.obey = uses ? pick({ obey: 4, risk: 30, quiet: 54, open: 12 }) : pick({ obey: 40, risk: 38, quiet: 19, open: 3 }); + r.police_trust = scale(3 - (uses ? 0.6 : 0) - (1 - s) * 0.6 + gauss()); + r.police_contact = uses ? pick({ none: 52 + s * 20, stopped: 24 - s * 8, fined: 14 - s * 6, arrested: 7 - s * 4, convicted: 3 - s * 2 }) : pick({ none: 92, stopped: 6, fined: 2 }); + r.who_decides = pick({ person: 55 + liberal * 12, doctors: 12, voters: 14, parliament: 16, tradition: 3 }); + r.policy = kind === "never" ? pick({ ban: 14, medical: 30, decrim: 20, regulated: 30, free: 6 }) : pick({ ban: 1, medical: 5, decrim: 14, regulated: 55, free: 25 }); + r.opinion_change = pick({ more: 58, same: 34, less: 8 }); + responses.push(clean(survey.all, r, countries)); + + // one observation of home, and for some people one or two of places they visited + const places = [[home, "live"], ...Array.from({ length: pick({ 0: 45, 1: 35, 2: 20 }) | 0 }, () => [pick(ANY), pick({ visited: 75, lived: 25 })])]; + for (const [id, tie] of places) { + if (id === home && tie !== "live") continue; + const t = score(id) / 100, legal = countries[id].scores.access >= 4; + const o = { country: id, tie, when: tie === "live" ? "now" : pick({ now: 40, y5: 40, older: 20 }) }; + o.t_acceptance = scale(1.6 + t * 3 + quirk[id].a + gauss() * 0.8); + o.t_enforcement = scale(4.3 - t * 3 + quirk[id].e + gauss() * 0.8); + o.t_access = scale(2.2 + t * 2.6 + quirk[id].x + gauss() * 0.8); + o.t_public = pick({ open: 4 + t * 55, discreet: 30, hidden: 45 - t * 35, rare: 21 - t * 18 }); + if (rnd() < 0.6) { + o.t_price_source = legal && rnd() < 0.6 ? "legal" : "illegal"; + o.t_price = Math.round(clamp(basePrice[id] * (o.t_price_source === "legal" ? 0.9 : 1) * (1 + gauss() * 0.25), 0.5, 60) * 10) / 10; + } + observations.push(clean(survey.territory.questions, o, countries)); + } +} + +const out = aggregate(survey, responses, observations, countries, { mock: true, generated: "2026-10-12" }); +const file = path.join(ROOT, "survey", `results-${EDITION}.json`); +fs.writeFileSync(file, JSON.stringify(out, null, 1) + "\n"); +console.log(`mock: ${out.respondents} respondents, ${out.observations} observations, ${out.territories.length} countries published → ${path.relative(ROOT, file)} (${Math.round(fs.statSync(file).size / 1024)} KB)`);
src/components/Question.astro +43 −0
@@ -0,0 +1,43 @@ +--- +// One question of the survey form (survey/<year>.yaml), as a labelled field. The form's script +// reads the answer from `data-q`/`data-type`; `data-if` holds the condition for showing it. +import { countries } from "../lib/countries.js"; +import { currencies } from "../lib/survey.js"; + +const { q } = Astro.props; +const byName = [...countries].sort((a, b) => a.name.localeCompare(b.name)); +const options = q.options ? Object.entries(q.options) : []; +--- +<div class="sv-field" data-q={q.id} data-type={q.type} data-label={q.text} data-required={q.required ? "" : undefined} data-if={q.if ? JSON.stringify(q.if) : undefined}> + <p class="sv-qtext" id={`q-${q.id}`}>{q.text}{q.required && <span class="sv-req" title="Required"> *</span>}</p> + {q.help && <p class="sv-help">{q.help}</p>} + {(q.type === "single" || q.type === "multi") && ( + <div class="sv-options" role={q.type === "single" ? "radiogroup" : "group"} aria-labelledby={`q-${q.id}`}> + {options.map(([value, label]) => ( + <label><input type={q.type === "single" ? "radio" : "checkbox"} name={q.id} value={value} /> <span>{label}</span></label> + ))} + </div> + )} + {q.type === "scale" && ( + <div class="sv-scale" role="radiogroup" aria-labelledby={`q-${q.id}`}> + <span class="sv-end">{q.low}</span> + {[1, 2, 3, 4, 5].map((k) => <label><input type="radio" name={q.id} value={k} /> <span>{k}</span></label>)} + <span class="sv-end">{q.high}</span> + </div> + )} + {q.type === "country" && ( + <select name={q.id} aria-labelledby={`q-${q.id}`}> + <option value="">Choose a country…</option> + {/* the name comes first, so that typing a letter jumps to the country */} + {byName.map((c) => <option value={c.id}>{c.name} {c.flag}</option>)} + </select> + )} + {/* a text box, so that "6,5" and "6.5" are both accepted; the form's script reads the number */} + {q.type === "number" && <input type="text" class="sv-number" name={q.id} inputmode="decimal" autocomplete="off" maxlength="12" aria-labelledby={`q-${q.id}`} />} + {q.type === "currency" && ( + <select name={q.id} aria-labelledby={`q-${q.id}`}> + {currencies.map(([code, name]) => <option value={code}>{code} · {name}</option>)} + </select> + )} + {q.type === "text" && <textarea name={q.id} rows="4" maxlength="4000" aria-labelledby={`q-${q.id}`}></textarea>} +</div>
src/layouts/Base.astro +1 −0
@@ -61,4 +61,5 @@ const schemas = jsonld ? [jsonld].flat() : []; <a href={`${base}#methodology`}>Methodology</a> <a href={`${base}history/`}>History</a> + <a href={`${base}survey/`}>Survey</a> <a href={`${base}can-i/`}>Can I?</a> <a href={`${base}compare/`}>Compare</a>
src/lib/survey-client.js +134 −0
@@ -0,0 +1,134 @@ +// The survey in the browser: the respondent's key, their stored submissions, and the relays. +// Loaded only when it is needed (on "Submit", and on /survey/me/). +// +// A respondent has one Nostr key, made in their browser the first time they submit and kept in +// its local storage. It is a pseudonym: nothing ties it to a person, but it does tie together the +// messages signed with it, which is what lets someone come back, correct an answer or delete it. +// Each part of a submission (the personal answers, and every place described) is one replaceable +// event (NIP-78, kind 30078) under its own `d` tag; publishing it again replaces it. The content +// is encrypted (NIP-44) between the respondent's key and the project's, so the relays store +// ciphertext that only those two can read. +import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools/pure"; +import { encrypt, decrypt, getConversationKey } from "nostr-tools/nip44"; +import { nsecEncode, npubEncode, naddrEncode, decode } from "nostr-tools/nip19"; +import { SimplePool } from "nostr-tools/pool"; + +const KEY = "cfi-survey-key"; +const toHex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(""); +const fromHex = (h) => Uint8Array.from(h.match(/../g).map((x) => parseInt(x, 16))); +const get = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; +const set = (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); return true; } catch { return false; } }; + +// For trying the whole flow against a relay of your own before the survey opens: put +// {"pubkey": "<hex>", "relays": ["ws://localhost:7447"]} under "cfi-survey-test" in local storage. +export function effective(cfg) { + try { + const t = JSON.parse(get("cfi-survey-test") ?? "null"); + if (/^[0-9a-f]{64}$/.test(t?.pubkey ?? "") && t.relays?.length) return { ...cfg, pubkey: t.pubkey, relays: t.relays, open: true, test: true }; + } catch {} + return cfg; +} + +// ── The key ── +export const hasKey = () => /^[0-9a-f]{64}$/.test(get(KEY) ?? ""); +function secret(create = false) { + if (hasKey()) return fromHex(get(KEY)); + if (!create) return null; + const sk = generateSecretKey(); + if (!set(KEY, toHex(sk))) throw new Error("this browser does not allow storing the key"); + return sk; +} +export function identity() { + const sk = secret(); + if (!sk) return null; + const pubkey = getPublicKey(sk); + return { pubkey, npub: npubEncode(pubkey) }; +} +export const backup = () => nsecEncode(secret()); +export function restore(text) { + const value = text.trim(); + let sk; + if (/^[0-9a-f]{64}$/.test(value)) sk = fromHex(value); + else { const d = decode(value); if (d.type !== "nsec") throw new Error("that is not a secret key (it should start with nsec1)"); sk = d.data; } + getPublicKey(sk); // throws if it is not a valid key + set(KEY, toHex(sk)); +} +export const forget = () => set(KEY, null); + +// ── What this browser knows about its own submissions ── +// { items: [{ type: "response" | "observation", id, answers, updated, event, relays }] } +const storeKey = (cfg) => `cfi-survey-${cfg.edition}-mine`; +export function mine(cfg) { try { return JSON.parse(get(storeKey(cfg)) ?? "null") ?? { items: [] }; } catch { return { items: [] }; } } +export const remember = (cfg, store) => set(storeKey(cfg), JSON.stringify(store)); +export const newId = () => toHex(crypto.getRandomValues(new Uint8Array(6))); + +// ── Events ── +const dTag = (cfg, item) => `${cfg.topic}:${item.type === "response" ? "response" : `place:${item.id}`}`; +// `removed` signs an empty replacement, which overwrites the earlier version on the relays +export function sign(cfg, item, removed = false) { + const sk = secret(true); + const plain = JSON.stringify(removed ? { type: item.type, edition: cfg.edition, deleted: true } : { type: item.type, edition: cfg.edition, answers: item.answers }); + const tags = [["d", dTag(cfg, item)], ["t", cfg.topic], ["alt", `State of Cannabis ${cfg.edition}: an anonymous survey answer`]]; + if (cfg.pubkey) tags.push(["p", cfg.pubkey]); + return finalizeEvent({ kind: cfg.kind, created_at: Math.floor(Date.now() / 1000), tags, content: cfg.pubkey ? encrypt(plain, getConversationKey(sk, cfg.pubkey)) : plain }, sk); +} +// a deletion request (NIP-09) for events of ours, for relays that honour it +export function deletion(cfg, events) { + const sk = secret(); + const tags = events.flatMap((e) => [["e", e.id], ["a", `${cfg.kind}:${e.pubkey}:${e.tags.find((t) => t[0] === "d")[1]}`]]); + return finalizeEvent({ kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [...tags, ["k", String(cfg.kind)]], content: "" }, sk); +} + +// Where a stored part can be looked at from outside: its event id, and its address on njump.me, +// a public Nostr viewer. The address names the replaceable event (kind, key and `d` tag), so the +// link keeps pointing at the newest version after an edit. The viewer shows only ciphertext. +export function reference(cfg, item) { + const e = item.event; + if (!e) return null; + const naddr = naddrEncode({ kind: e.kind, pubkey: e.pubkey, identifier: e.tags.find((t) => t[0] === "d")?.[1] ?? "", relays: (cfg.relays ?? []).slice(0, 2) }); + return { id: e.id, url: `https://njump.me/${naddr}` }; +} + +// ── Relays ── +const within = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(new Error("timed out")), ms))]); +// Offers every event to every relay; returns for each event how many relays stored it. +export async function publish(cfg, events) { + const pool = new SimplePool(); + try { + return await Promise.all(events.map(async (event) => { + const results = await Promise.allSettled(pool.publish(cfg.relays, event).map((p) => within(p, 8000))); + return { id: event.id, relays: results.filter((r) => r.status === "fulfilled").length, error: results.find((r) => r.status === "rejected")?.reason?.message ?? null }; + })); + } finally { pool.close(cfg.relays); } +} +// What the relays hold under this browser's key, decrypted: the newest version of each part, and +// on how many relays that version sits. Each relay is asked by itself, so that one slow or +// unreachable relay neither hides the others nor inflates the count. +export async function fetchMine(cfg) { + const sk = secret(); + if (!sk || !cfg.open) return []; + const filter = { kinds: [cfg.kind], authors: [getPublicKey(sk)], "#t": [cfg.topic] }; + const perRelay = await Promise.all(cfg.relays.map(async (url) => { + const pool = new SimplePool(); + try { return await within(pool.querySync([url], filter), 8000); } catch { return []; } finally { pool.close([url]); } + })); + const newest = new Map(); // d tag → { event, relays } + for (const events of perRelay) { + for (const e of events) { + const d = e.tags.find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const had = newest.get(d); + if (!had || had.event.created_at < e.created_at) newest.set(d, { event: e, relays: 1 }); + else if (had.event.id === e.id) had.relays++; + } + } + const out = []; + for (const [d, { event, relays }] of newest) { + try { + const p = JSON.parse(event.content.startsWith("{") ? event.content : decrypt(event.content, getConversationKey(sk, cfg.pubkey))); + if (p.deleted || p.edition !== cfg.edition) continue; + out.push({ type: p.type, id: p.type === "response" ? "response" : d.split(":place:")[1], answers: p.answers, updated: event.created_at, event, relays }); + } catch {} + } + return out; +}
src/lib/survey.js +20 −0
@@ -0,0 +1,20 @@ +// State of Cannabis: the questionnaire of the current edition (survey/<year>.yaml) and its counted +// results (survey/results-<year>.json, written by scripts/survey-mock.cjs or survey-collect.cjs). +// The form and the results page are both built from these two files. +import yaml from "js-yaml"; +import raw from "../../survey/2026.yaml?raw"; +import results from "../../survey/results-2026.json"; … 15 more lines
src/pages/culture.astro +1 −1
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/data/survey.json.js +6 −0
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/search.json.js +1 −1
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/sitemap.xml.js +2 −0
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/survey/index.astro +225 −0
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/survey/me.astro +201 −0
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/survey/take.astro +289 −0
Not shown: a picture, a generated file, or past the length shown per change.
src/styles/global.css +198 −0
Not shown: a picture, a generated file, or past the length shown per change.
survey/2026.yaml +348 −0
Not shown: a picture, a generated file, or past the length shown per change.
survey/currencies.yaml +100 −0
Not shown: a picture, a generated file, or past the length shown per change.
survey/results-2026.json +4490 −0
Not shown: a picture, a generated file, or past the length shown per change.