Changelog 2980a6f
ux tweaks
15 files of code, design or docs +3,863 −3,084 lines
Code, design and documents 15 files
docs/DATA-MAINTENANCE.md +10 −1
@@ -616,6 +616,15 @@ is in the main menu, after History. | `scripts/survey-keygen.cjs` | Prints a new key pair for the project. | +**Three kinds of contribution.** The questionnaire file has three parts, stored and counted +separately: `sections` is the yearly personal survey (what a person does and believes), `territory` +is a report on one country they know (what they observed), and `prices` is one price observation. +A person sends one survey and any number of place and price reports. Country averages weigh a +place report by how long its author has spent there (a visit of days counts 1, residence of more +than five years counts 4). Country prices are the median of the price reports for flower, +converted to US dollars per gram with `survey/currencies.yaml`, after leaving out values far from +the rest. + **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 +keeps it in its local storage. The survey, each place report and each price report 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.
scripts/survey-collect.cjs +8 −4
@@ -40,5 +40,5 @@ async function fetchEvents() { (async () => { - const responses = [], observations = [], texts = []; + const responses = [], observations = [], prices = [], 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 @@ -69,12 +69,16 @@ async function fetchEvents() { if (!o.country || !o.tie) { dropped.other++; continue; } observations.push(o); + } else if (payload.type === "price") { + const pr = clean(survey.prices.questions, payload.answers, countries); + if (!pr.p_country || !pr.p_price) { dropped.other++; continue; } + prices.push(pr); } 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)]); + for (const q of [...survey.all, ...survey.territory.questions, ...survey.prices.questions].filter((q) => q.type === "text")) if (payload.answers[q.id]) texts.push([q.id, String(payload.answers[q.id]).slice(0, 4000)]); } 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 out = aggregate(survey, responses, observations, prices, 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"); - console.log(`collected: ${responses.length} responses, ${observations.length} observations | dropped: ${JSON.stringify(dropped)} → ${path.relative(ROOT, file)}`); + console.log(`collected: ${responses.length} responses, ${observations.length} place reports, ${prices.length} price reports | 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-lib.cjs +48 −21
@@ -43,5 +43,7 @@ function clean(questions, raw, countries) { 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))]; + let xs = [...new Set(v.filter((x) => typeof x === "string" && x in q.options))]; + // options that cannot stand together: the excluding one gives way ("never used it" beside "I use it") + for (const [one, others] of Object.entries(q.excludes ?? {})) if (xs.includes(one) && others.some((o) => xs.includes(o))) xs = xs.filter((x) => x !== one); if (xs.length) out[q.id] = xs; } else if (q.type === "scale" && Number.isInteger(v) && v >= 1 && v <= 5) out[q.id] = v; @@ -61,6 +63,19 @@ 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 = {}, limits = {}) { +// How much a place report counts towards its country's averages: someone who lived there for +// years has seen more than a weekend visitor. Without a stated duration, the tie decides. +const WEIGHT = { days: 1, weeks: 1, months: 2, years: 3, long: 4 }; +const weightOf = (o) => WEIGHT[o.t_duration] ?? (o.tie === "visited" ? 1 : 3); +const quantile = (sorted, q) => { const i = (sorted.length - 1) * q, lo = Math.floor(i); return sorted[lo] + (sorted[Math.min(lo + 1, sorted.length - 1)] - sorted[lo]) * (i - lo); }; +// Prices far from the rest (beyond 1.5 times the middle spread) are left out once there are +// enough to judge by; one slip or one joke should not move a country's median. +function trimmed(xs) { + if (xs.length < 8) return xs; + const s = [...xs].sort((a, b) => a - b), q1 = quantile(s, 0.25), q3 = quantile(s, 0.75), reach = 1.5 * (q3 - q1); + return s.filter((x) => x >= q1 - reach && x <= q3 + reach); +} + +// responses: cleaned personal answers; observations: cleaned place reports; prices: cleaned price reports +function aggregate(survey, responses, observations, prices, countries, extra = {}, limits = {}) { const MIN_SEGMENT = limits.segment ?? DEFAULTS.segment, MIN_TERRITORY = limits.territory ?? DEFAULTS.territory; const segmentsOf = (r) => { @@ -99,31 +114,43 @@ function aggregate(survey, responses, observations, countries, extra = {}, limit } - const byCountry = {}; + const byCountry = {}, pricesBy = {}; 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); + // a price per gram of flower in US dollars at the rates on file; a price with no currency was + // given in dollars; hashish and anything outside 20 cents to 200 dollars a gram are left out + for (const p of prices) { + const usd = (p.p_price * USD[p.p_currency ?? "USD"]) / (p.p_grams ?? 1); + if ((p.p_product ?? "flower") === "flower" && usd >= 0.2 && usd <= 200) (pricesBy[p.p_country] ??= []).push({ usd, legal: ["shop", "club"].includes(p.p_market), known: Boolean(p.p_market) }); + } + // averages of the 1–5 scales, weighted by how well the reporter knows the place + const avg = (os, id) => { + const xs = os.filter((o) => o[id] != null); + const w = xs.reduce((a, o) => a + weightOf(o), 0); + return xs.length ? round(xs.reduce((a, o) => a + o[id] * weightOf(o), 0) / w) : null; + }; + const med = (xs) => (xs.length >= MIN_TERRITORY ? round(median(trimmed(xs))) : null); + const territories = [...new Set([...Object.keys(byCountry), ...Object.keys(pricesBy)])] + .map((country) => ({ country, os: byCountry[country] ?? [], ps: pricesBy[country] ?? [] })) + .filter(({ os, ps }) => os.length >= MIN_TERRITORY || ps.length >= MIN_TERRITORY) + .map(({ country, os, ps }) => { + const enough = os.length >= MIN_TERRITORY; 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, + country, n: os.length, tie: enough ? count("tie") : {}, + // how many of the reports come from people who lived there, not passed through + residents: os.filter((o) => o.tie !== "visited").length, + acceptance: enough ? avg(os, "t_acceptance") : null, enforcement: enough ? avg(os, "t_enforcement") : null, access: enough ? avg(os, "t_access") : null, + public: enough ? count("t_public") : {}, + price: med(ps.map((p) => p.usd)), price_n: ps.length, + price_legal: med(ps.filter((p) => p.known && p.legal).map((p) => p.usd)), + price_illegal: med(ps.filter((p) => p.known && !p.legal).map((p) => p.usd)), }; }) - .sort((a, b) => b.n - a.n); + .sort((a, b) => b.n - a.n || b.price_n - a.price_n); return { edition: survey.edition, ...extra, - respondents: responses.length, observations: observations.length, + respondents: responses.length, observations: observations.length, price_reports: prices.length, countries_lived: new Set(responses.map((r) => r.residence).filter(Boolean)).size, - countries_described: Object.keys(byCountry).length, + countries_described: new Set([...Object.keys(byCountry), ...Object.keys(pricesBy)]).size, thresholds: { segment: MIN_SEGMENT, territory: MIN_TERRITORY }, rates_date: money.date, segments, questions, territories,
scripts/survey-mock.cjs +17 −8
@@ -38,5 +38,5 @@ const basePrice = Object.fromEntries(Object.keys(ANY).map((id) => [id, clamp(13 // 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 = []; +const responses = [], observations = [], prices = []; for (let i = 0; i < N; i++) { const home = pick(HOME), s = score(home) / 100; // 0–1, how free the law is at home @@ -85,7 +85,12 @@ for (let i = 0; i < N; i++) { 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.taught = some({ family: 0.55, school: 0.45, religion: 0.15 + (1 - s) * 0.15, media: 0.5, friends: 0.6, state: 0.3 + (1 - s) * 0.2, doctors: 0.1, self: uses ? 0.7 : 0.1 }); + r.taught_message = pick({ dangerous: 45 - s * 20, immoral: 14 - s * 8, mixed: 22, fine: 5 + s * 20, silence: 14 }); + r.law_moral = uses ? pick({ much: 8, some: 22, no: 40, opposite: 30 }) : pick({ much: 30, some: 35, no: 28, opposite: 7 }); + r.abroad = pick({ more: 38, less: 3, no: 34, never: 25 }); + r.law_or_attitudes = pick({ law: 18, attitudes: 34, both: 42, neither: 6 }); 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.obey = uses ? pick({ obey: 2, change: 9, protest: 6, risk: 22, quiet: 40, open: 8, depends: 13 }) : pick({ obey: 26, change: 24, protest: 8, risk: 18, quiet: 8, open: 1, depends: 15 }); 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 }); @@ -100,12 +105,16 @@ for (let i = 0; i < N; i++) { 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 }) }; + const o = { country: id, tie, when: tie === "live" ? "y0" : pick({ y0: 22, y1: 22, y5: 32, y10: 14, older: 10 }) }; + o.t_duration = tie === "visited" ? pick({ days: 45, weeks: 40, months: 15 }) : pick({ months: 8, years: 27, long: 65 }); 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; + // price reports are separate: some people give one, a few give several, some an odd one out + for (let k = rnd() < 0.55 ? (rnd() < 0.15 ? 2 : 1) : 0; k > 0; k--) { + const market = legal && rnd() < 0.6 ? pick({ shop: 80, club: 20 }) : pick({ dealer: 65, friend: 35 }); + const grams = pick({ 1: 60, 3.5: 12, 5: 14, 10: 10, 28: 4 }) * 1; + const perGram = clamp(basePrice[id] * (["shop", "club"].includes(market) ? 0.9 : 1) * (1 + gauss() * 0.25) * (rnd() < 0.02 ? 6 : 1), 0.5, 150); + prices.push(clean(survey.prices.questions, { p_country: id, p_product: rnd() < 0.9 ? "flower" : "hashish", p_price: Math.round(perGram * grams * 10) / 10, p_currency: "USD", p_grams: grams, p_market: market, p_when: pick({ y0: 45, y1: 30, y5: 20, older: 5 }) }, countries)); } observations.push(clean(survey.territory.questions, o, countries)); @@ -113,6 +122,6 @@ for (let i = 0; i < N; i++) { } -const out = aggregate(survey, responses, observations, countries, { mock: true, generated: "2026-10-12" }); +const out = aggregate(survey, responses, observations, prices, 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)`); +console.log(`mock: ${out.respondents} respondents, ${out.observations} place reports, ${out.price_reports} price reports, ${out.territories.length} countries published → ${path.relative(ROOT, file)} (${Math.round(fs.statSync(file).size / 1024)} KB)`);
src/components/HomePage.astro +3 −0
@@ -4,4 +4,5 @@ import DidYouKnow from "../components/DidYouKnow.astro"; import Changes from "../components/Changes.astro"; import ProjectNumbers from "../components/ProjectNumbers.astro"; +import Tools from "./Tools.astro"; import AverageChart from "../components/AverageChart.astro"; import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, REC_ORDER, REC_HELP, MED_LABEL, MED_HELP, countries, regions, worldAvg, lastUpdated, formatDate, subunitsOf, places, trendOf, changeDate, TREND_YEARS } from "../lib/countries.js"; @@ -374,4 +375,6 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", </section>} + {!view && <Tools />} + {!view && <ProjectNumbers />}
src/components/Question.astro +1 −1
@@ -9,5 +9,5 @@ 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}> +<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} data-excludes={q.excludes ? JSON.stringify(q.excludes) : 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>}
src/components/Tools.astro +29 −0
@@ -0,0 +1,29 @@ +--- +// "Tools" on the home page, after the methodology: the pages that answer a question of the +// reader's own instead of showing the whole index. They are not in the top menu. +const base = import.meta.env.BASE_URL; +const tools = [ + ["Can I?", "can-i/", "Pick a country and get plain answers: can I carry it, grow it, buy it, use it in public, and what happens if I am caught?", "Ask about a country"], + ["Compare", "compare/", "Put up to six countries side by side: their scores, exact rules and sources in one table, with a link you can share.", "Compare countries"], + ["Download the data", "data/", "Scores, history and a yearly panel since 1900 as CSV and JSON, free to reuse with attribution.", "Get the files"], +]; +--- +<section class="section tools" id="tools"> + <div class="section-head"> + <div> + <h2>Tools</h2> + <p class="sub">Three ways to put the index to work on a question of your own.</p> + </div> + </div> + <ul class="tools-grid"> + {tools.map(([name, href, text, go]) => ( + <li> + <a href={`${base}${href}`}> + <h3>{name}</h3> + <p>{text}</p> + <span class="tools-go">{go} <span aria-hidden="true">→</span></span> + </a> + </li> + ))} + </ul> +</section>
src/layouts/Base.astro +24 −7
@@ -56,5 +56,10 @@ const schemas = jsonld ? [jsonld].flat() : []; Cannabis Freedom Index </a> - <nav class="nav"> + {/* Search comes first in the bar, with the keys that open it from anywhere. */} + <button class="icon-btn search-btn" id="searchOpen" aria-label="Search" title="Search (⌘K)" aria-keyshortcuts="Meta+K Control+K"> + <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> + <kbd class="search-key" id="searchKey">⌘K</kbd> + </button> + <nav class="nav" id="siteNav" aria-label="Main"> <a href={`${base}#map`}>Map</a> <a href={`${base}#rankings`}>Rankings</a> @@ -62,10 +67,5 @@ const schemas = jsonld ? [jsonld].flat() : []; <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> </nav> - <button class="icon-btn search-btn" id="searchOpen" aria-label="Search" title="Search (⌘K)" aria-keyshortcuts="Meta+K Control+K"> - <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> - </button> <a class="icon-btn" href={repo} target="_blank" rel="noopener noreferrer" aria-label="Source code on GitHub" title="Source code on GitHub"> <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.52 2.34 1.08 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.560 9.560 0 0 1 5 0c1.910-1.290 2.750-1.020 2.750-1.020.550 1.380.200 2.400.100 2.650.640.700 1.030 1.590 1.030 2.680 0 3.840-2.340 4.680-4.570 4.930.360.310.680.920.680 1.850v2.740c0 .270.180.580.690.480A10 10 0 0 0 12 2z"/></svg> @@ -74,4 +74,8 @@ const schemas = jsonld ? [jsonld].flat() : []; <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9c0-.5 0-.9-.1-1.4A5.5 5.5 0 0 1 13.4 3.1 9 9 0 0 0 12 3z" fill="currentColor"/></svg> </button> + {/* Phones and narrow windows: the links fold into a panel opened by this button. */} + <button class="icon-btn nav-toggle" id="navToggle" aria-label="Menu" aria-controls="siteNav" aria-expanded="false"> + <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path class="nt-open" d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path class="nt-close" d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> + </button> </header> @@ -92,5 +96,5 @@ const schemas = jsonld ? [jsonld].flat() : []; <footer class="footer"> <p>Cannabis Freedom Index · Informational only, not legal advice · Map data © Natural Earth</p> - <p class="footer-links"><a href={`${base}changelog/`}>Changelog</a> · <a href={`${base}rss.xml`}>RSS</a> · <a href={`${base}data/`}>Download the data</a> · <a href={`${base}llms.txt`} title="A plain-text guide to this site for language models">For AI: llms.txt</a> · <a href={`${base}data/#licence`}>Licence: data CC BY 4.0, code MIT</a></p> + <p class="footer-links"><a href={`${base}can-i/`}>Can I?</a> · <a href={`${base}compare/`}>Compare</a> · <a href={`${base}changelog/`}>Changelog</a> · <a href={`${base}rss.xml`}>RSS</a> · <a href={`${base}data/`}>Download the data</a> · <a href={`${base}llms.txt`} title="A plain-text guide to this site for language models">For AI: llms.txt</a> · <a href={`${base}data/#licence`}>Licence: data CC BY 4.0, code MIT</a></p> <p>Open source — the scores, explanations and code are on <a href={repo} target="_blank" rel="noopener noreferrer">GitHub</a>. Spotted a mistake or a law that changed? <a href={`${repo}/issues`} target="_blank" rel="noopener noreferrer">Open an issue</a> or send a pull request.</p> </footer> @@ -146,4 +150,6 @@ const schemas = jsonld ? [jsonld].flat() : []; input.setAttribute("aria-activedescendant", li.id); } + // show the shortcut as this machine types it: ⌘K on Apple devices, Ctrl K elsewhere + if (!/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) { document.getElementById("searchKey").textContent = "Ctrl K"; document.getElementById("searchOpen").title = "Search (Ctrl K)"; } function open() { if (dialog.open) return; dialog.showModal(); input.value = ""; render(); input.focus(); load(); } document.getElementById("searchOpen").addEventListener("click", open); @@ -162,4 +168,15 @@ const schemas = jsonld ? [jsonld].flat() : []; })(); + // The menu on narrow screens: the button opens the links as a panel under the top bar. + // It closes on a link, on Escape, and on a click anywhere else. + (function () { + var bar = document.querySelector(".topbar"), btn = document.getElementById("navToggle"); + function set(open) { bar.classList.toggle("nav-open", open); btn.setAttribute("aria-expanded", open ? "true" : "false"); btn.setAttribute("aria-label", open ? "Close menu" : "Menu"); } + btn.addEventListener("click", function () { set(!bar.classList.contains("nav-open")); }); + document.getElementById("siteNav").addEventListener("click", function (e) { if (e.target.closest("a")) set(false); }); + document.addEventListener("keydown", function (e) { if (e.key === "Escape" && bar.classList.contains("nav-open")) { set(false); btn.focus(); } }); + document.addEventListener("click", function (e) { if (bar.classList.contains("nav-open") && !bar.contains(e.target)) set(false); }); + })(); + document.getElementById("themeToggle").addEventListener("click", function () { var root = document.documentElement;
src/lib/survey-client.js +5 −3
@@ -57,5 +57,5 @@ export const forget = () => set(KEY, null); // ── What this browser knows about its own submissions ── -// { items: [{ type: "response" | "observation", id, answers, updated, event, relays }] } +// { items: [{ type: "response" | "observation" | "price", 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: [] }; } } @@ -64,5 +64,7 @@ export const newId = () => toHex(crypto.getRandomValues(new Uint8Array(6))); // ── Events ── -const dTag = (cfg, item) => `${cfg.topic}:${item.type === "response" ? "response" : `place:${item.id}`}`; +// one `d` tag per part: the yearly survey, each place report, each price report +const SLOT = { observation: "place", price: "price" }; +const dTag = (cfg, item) => `${cfg.topic}:${item.type === "response" ? "response" : `${SLOT[item.type]}:${item.id}`}`; // `removed` signs an empty replacement, which overwrites the earlier version on the relays export function sign(cfg, item, removed = false) { @@ -128,5 +130,5 @@ export async function fetchMine(cfg) { 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 }); + out.push({ type: p.type, id: p.type === "response" ? "response" : d.split(/:(?:place|price):/)[1], answers: p.answers, updated: event.created_at, event, relays }); } catch {} }
src/pages/survey/index.astro +23 −18
@@ -39,7 +39,7 @@ 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"}.`; +const waiting = `No country has ${need} ${need === 1 ? "report" : "reports"} yet, which is the least we publish. ${num(results.observations)} place ${results.observations === 1 ? "report" : "reports"} and ${num(results.price_reports)} price ${results.price_reports === 1 ? "report" : "reports"} have come in so far, for ${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.`}> +<Base title={survey.title} description={`${survey.title}: a pseudonymous community survey of how people use, grow and think about cannabis, set against the law in each country.`}> <main class="changes-page survey"> <div class="sv-hero"> @@ -47,5 +47,5 @@ const waiting = `No country has been described by ${need} ${need === 1 ? "person <p class="eyebrow">Culture <span>{survey.edition} edition</span></p> <h1>State of Cannabis</h1> - <p class="lede">An anonymous, yearly survey of how people use, grow and think about cannabis, and of what the places they know are really like. The index measures the law; this measures the life lived under it.</p> + <p class="lede">A yearly, pseudonymous survey in three parts: what people do with cannabis, what they believe about it, and what they have seen in the countries they know. The index measures the law; this measures the life lived under it.</p> <p class="front-go"><a class="go solid" href={`${base}survey/take/`} id="svTake">Take the survey <span aria-hidden="true">→</span></a> <a class="go" href={`${base}culture/`}>About the project</a></p> </section> @@ -58,21 +58,23 @@ const waiting = `No country has been described by ${need} ${need === 1 ? "person )} + {/* everything from the totals down to the country table is data, and carries the watermark */} + <div class="sv-results"> + {results.mock && <div class="sv-watermark" aria-hidden="true"></div>} <dl class="hist-stats"> <div><dt>Respondents</dt><dd>{num(results.respondents)}</dd></div> <div><dt>Countries they live in</dt><dd>{results.countries_lived}</dd></div> - <div><dt>Places described</dt><dd>{num(results.observations)}</dd></div> - <div><dt>Countries described</dt><dd>{results.countries_described}</dd></div> + <div><dt>Place reports</dt><dd>{num(results.observations)}</dd></div> + <div><dt>Price reports</dt><dd>{num(results.price_reports)}</dd></div> + <div><dt>Countries covered</dt><dd>{results.countries_described}</dd></div> </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-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 a country has {need} reports.</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> + <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 it has at least {results.thresholds.territory} reports.</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>} + {/* While the numbers are invented, the word SAMPLE lies across the results (the totals, the + charts and the country table, nothing else) and stays in the bar that follows the reader down. */} <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>} @@ -137,6 +139,6 @@ const waiting = `No country has been described by ${need} ${need === 1 ? "person {p.pts.map((t) => ( <a href={`${base}country/${t.c.slug}/`}> - <circle cx={p.x(t.c.score)} cy={p.y(t[p.id])} r={Math.max(2.5, Math.sqrt(t.n) / 2.2)} style={`--c:${tierColor(t.c.score)}`}> - <title>{`${t.c.name}: index ${t.c.score}, ${p.title.toLowerCase()} ${fmt(t[p.id])} (${t.n} people)`}</title> + <circle cx={p.x(t.c.score)} cy={p.y(t[p.id])} r={Math.max(2.5, Math.sqrt(p.id === "price" ? t.price_n : t.n) / 2.2)} style={`--c:${tierColor(t.c.score)}`}> + <title>{`${t.c.name}: index ${t.c.score}, ${p.title.toLowerCase()} ${fmt(t[p.id])} (${p.id === "price" ? t.price_n : t.n} reports)`}</title> </circle> </a> @@ -152,10 +154,10 @@ const waiting = `No country has been described by ${need} ${need === 1 ? "person <div class="section-head"><div> <h2>Places, as described</h2> - <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> + <p class="sub">What residents and visitors reported about each country, beside its score. Scales run from 1 to 5, and a report counts for more the longer its author has spent there. Prices come from separate price reports and 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> {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> + <thead><tr><th>Country</th><th class="num">Index</th><th class="num">Reports</th><th class="num">Residents</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 <small>reports</small></th></tr></thead> <tbody> {places.map((t) => ( @@ -164,9 +166,10 @@ const waiting = `No country has been described by ${need} ${need === 1 ? "person <td class="num"><span class="sv-score" style={`--c:${tierColor(t.c.score)}`}>{t.c.score}</span></td> <td class="num">{num(t.n)}</td> + <td class="num">{t.n ? `${pct(t.residents, t.n)}%` : "–"}</td> <td class="num">{fmt(t.acceptance)}</td> <td class="num">{fmt(t.enforcement)}</td> <td class="num">{fmt(t.access)}</td> - <td class="num">{pct(t.public.open ?? 0, t.n)}%</td> - <td class="num">{t.price == null ? "–" : `$${t.price.toFixed(1)}`}</td> + <td class="num">{t.acceptance == null ? "–" : `${pct(t.public.open ?? 0, t.n)}%`}</td> + <td class="num">{t.price == null ? "–" : `$${t.price.toFixed(1)}`}{t.price != null && <small class="sv-n"> {t.price_n}</small>}</td> </tr> ))} @@ -183,5 +186,7 @@ const waiting = `No country has been described by ${need} ${need === 1 ? "person </div></div> <dl class="culture-themes"> - <div><dt>Anonymous</dt><dd>No account, no email, no cookie. Answers are signed with a random key made in your browser and kept there, and encrypted so that only you and the project can read them.</dd></div> + <div><dt>Pseudonymous</dt><dd>No account, no email, no cookie. Answers are signed with a random key made in your browser and kept there, and encrypted so that only you and the project can read them. That is strong privacy, not a guarantee of anonymity: a rare combination of answers can still narrow down who gave them.</dd></div> + <div><dt>Three kinds of contribution</dt><dd>A yearly survey about yourself, reports on places you know, and prices you have paid. Each is stored by itself, and the last two can be added to at any time.</dd></div> + <div><dt>One person, one voice?</dt><dd>Not enforced. Anyone can answer more than once, so the survey is counted by key, place reports are weighed by how well their author knows the place, and prices far from the rest are left out.</dd></div> <div><dt>Yours to change</dt><dd>The key stays with you, so you can come back to <a href={`${base}survey/me/`}>your submissions</a>, correct them or delete them. Nobody else can.</dd></div> <div><dt>Counted, not listed</dt><dd>Only counts are published, and only for groups large enough to hide in. Free-text answers are read by the researchers and never published as they stand.</dd></div>
src/pages/survey/me.astro +19 −8
@@ -10,5 +10,5 @@ const base = import.meta.env.BASE_URL; // what the script needs to print an answer in words const label = (q) => ({ text: q.text, type: q.type, options: q.options, low: q.low, high: q.high }); -const questions = Object.fromEntries([...survey.sections.flatMap((s) => s.questions), ...survey.territory.questions].map((q) => [q.id, label(q)])); +const questions = Object.fromEntries([...survey.sections.flatMap((s) => s.questions), ...survey.territory.questions, ...survey.prices.questions].map((q) => [q.id, label(q)])); const names = Object.fromEntries(countries.map((c) => [c.id, `${c.flag} ${c.name}`])); --- @@ -73,4 +73,11 @@ const names = Object.fromEntries(countries.map((c) => [c.id, `${c.flag} ${c.name } + // the three kinds of contribution, as they are named on this page + const KIND = { + response: { label: "Survey", this: "this survey" }, + observation: { label: "Place report", this: "this place", hash: "place" }, + price: { label: "Price report", this: "this price", hash: "price" }, + }; + // ── The drawer: one submission in full ── const shown = new Map(); // row key → { i, title } @@ -80,9 +87,9 @@ const names = Object.fromEntries(countries.map((c) => [c.id, `${c.flag} ${c.name const r = i.past ? null : client.reference(cfg, i); drawerBody.innerHTML = ` - <p class="eyebrow">${i.type === "response" ? "Report" : "Place"} <span>${i.past ?? cfg.edition} edition</span></p> + <p class="eyebrow">${KIND[i.type].label} <span>${i.past ?? cfg.edition} edition</span></p> <h2 class="sv-d-title">${title}</h2> <p class="sv-meta">${where(i)} · changed ${day(i.updated)}</p> ${i.past ? `<p class="sv-small">From the ${i.past} edition, which is closed. It is kept here to read.</p>` - : `<p class="sv-actions"><a class="go solid" href="../take/${i.type === "response" ? "#edit" : `#place-${esc(i.id)}`}">Edit ${i.type === "response" ? "this report" : "this place"} →</a> <button type="button" class="go" data-do="delete" data-id="${esc(i.id)}">Delete</button></p>`} + : `<p class="sv-actions"><a class="go solid" href="../take/${i.type === "response" ? "#edit" : `#${KIND[i.type].hash}-${esc(i.id)}`}">Edit ${KIND[i.type].this} →</a> <button type="button" class="go" data-do="delete" data-id="${esc(i.id)}">Delete</button></p>`} <dl class="sv-d-list">${Object.entries(i.answers).map(([id, v]) => `<div><dt>${esc(Q[id]?.text ?? id)}</dt><dd>${words(id, v)}</dd></div>`).join("")}</dl> ${r ? `<p class="sv-event"><span>Event</span> <code>${r.id}</code>${i.relays > 0 ? ` <a href="${r.url}" target="_blank" rel="noopener noreferrer">Open on njump ↗</a>` : ` <small>not sent yet</small>`}</p>` : ""}`; @@ -107,5 +114,5 @@ const names = Object.fromEntries(countries.map((c) => [c.id, `${c.flag} ${c.name // holds are listed under the current one, newest first, to read but not to change. const all = [...store.items, ...earlier()]; - const reports = all.filter((i) => i.type === "response"), places = all.filter((i) => i.type === "observation"); + const reports = all.filter((i) => i.type === "response"), places = all.filter((i) => i.type === "observation"), prices = all.filter((i) => i.type === "price"); const unsent = store.items.filter((i) => !i.relays).length; // one table row per part; a click opens its answers in the drawer @@ -119,4 +126,6 @@ const names = Object.fromEntries(countries.map((c) => [c.id, `${c.flag} ${c.name const head = (first) => `<div class="sv-rows-head"><span>${first}</span><span>Summary</span><span>Stored on</span><span>Changed</span></div>`; const tie = (p) => Q.tie.options[p.answers.tie] ?? ""; + // "150 CZK for 1 g" + const amount = (p) => `${p.answers.p_price.toLocaleString("en-GB")} ${p.answers.p_currency ?? "USD"} for ${p.answers.p_grams ?? 1} g`; items.innerHTML = ` <p class="sv-actions sv-top"> @@ -125,8 +134,10 @@ const names = Object.fromEntries(countries.map((c) => [c.id, `${c.flag} ${c.name <button type="button" class="go" data-do="delete-all">Delete everything</button></p> <p class="sv-msg" id="meMsg" role="status"></p> - <h2 class="sv-h">Your reports</h2> - ${reports.length ? `<div class="sv-rows">${head("Edition")}${reports.map((r) => row(r, `${r.past ?? cfg.edition} report`, `${n(r)} question${n(r) === 1 ? "" : "s"} answered`)).join("")}</div>` : `<p class="sub">No report for ${cfg.edition} yet.</p>`} - <h2 class="sv-h sv-h-row">Places you described <a class="sv-link" href="../take/#place-new">+ Add a place</a></h2> - ${places.length ? `<div class="sv-rows">${head("Country")}${places.map((p) => row(p, esc(NAMES[p.answers.country] ?? p.answers.country), `${p.past ?? cfg.edition} · ${esc(tie(p))} · ${n(p) - 2} more answer${n(p) - 2 === 1 ? "" : "s"}`)).join("")}</div>` : `<p class="sub">None. <a href="../take/">Add a place →</a></p>`}`; + <h2 class="sv-h">Your yearly survey</h2> + ${reports.length ? `<div class="sv-rows">${head("Edition")}${reports.map((r) => row(r, `${r.past ?? cfg.edition} survey`, `${n(r)} question${n(r) === 1 ? "" : "s"} answered`)).join("")}</div>` : `<p class="sub">No survey for ${cfg.edition} yet. <a href="../take/">Take it →</a></p>`} + <h2 class="sv-h sv-h-row">Places you reported on <a class="sv-link" href="../take/#place-new">+ Add a place</a></h2> + ${places.length ? `<div class="sv-rows">${head("Country")}${places.map((p) => row(p, esc(NAMES[p.answers.country] ?? p.answers.country), `${p.past ?? cfg.edition} · ${esc(tie(p))} · ${Math.max(0, n(p) - 2)} more answer${n(p) - 2 === 1 ? "" : "s"}`)).join("")}</div>` : `<p class="sub">None. <a href="../take/#place-new">Add a place →</a></p>`} + <h2 class="sv-h sv-h-row">Prices you reported <a class="sv-link" href="../take/#price-new">+ Add a price</a></h2> + ${prices.length ? `<div class="sv-rows">${head("Country")}${prices.map((p) => row(p, esc(NAMES[p.answers.p_country] ?? p.answers.p_country), `${p.past ?? cfg.edition} · ${esc(amount(p))}`)).join("")}</div>` : `<p class="sub">None. <a href="../take/#price-new">Add a price →</a></p>`}`; }
src/pages/survey/take.astro +88 −55
@@ -11,5 +11,5 @@ const base = import.meta.env.BASE_URL; const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); --- -<Base title={`Take the survey: ${survey.title}`} description="Answer the State of Cannabis survey: anonymous, no account, about ten minutes. Describe how you use, grow or think about cannabis, and what the countries you know are like."> +<Base title={`Take the survey: ${survey.title}`} description="Answer the State of Cannabis survey: no account, encrypted, about ten minutes. Describe how you use, grow or think about cannabis, and what the countries you know are like."> <main class="changes-page survey sv-take"> <section class="hero hero-sm"> @@ -28,6 +28,8 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); <h2>Before you begin</h2> <ul class="sv-points"> - <li>It takes about ten minutes. Every question except four is optional, and whole sections are skipped when they do not apply to you.</li> - <li>There is no account, no email and no cookie. A draft is kept in this browser only, so you can come back to it.</li> + <li>It takes about ten minutes. Almost every question is optional, and whole sections are skipped when they do not apply to you.</li> + <li>There are three parts: a yearly survey about you, reports on places you know, and prices you know. You can do any of them, and add places and prices later.</li> + <li>There is no account, no email and no cookie, and your answers are stored encrypted under a random key. That makes the survey pseudonymous, not perfectly anonymous: a rare combination of country, age and experience can still narrow down who answered, so leave out anything you would not want guessed.</li> + <li>A draft is kept in this browser only, so you can come back to it.</li> <li>You can change or delete your answers later, from this browser.</li> <li>People who have never used cannabis are as welcome as people who grow it.</li> @@ -46,16 +48,19 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); ))} - <section class="sv-step" data-step="places" data-title={survey.territory.title} hidden> - <h2>{survey.territory.title}</h2> - <p class="sub">{survey.territory.intro}</p> - <div id="svPlaces"></div> - <p><button type="button" class="go" id="svAddPlace">+ Add a place</button></p> - <template id="svPlaceTpl"> - <fieldset class="sv-place"> - <legend>Place <span data-nr></span> <button type="button" class="sv-remove" data-remove>Remove</button></legend> - {survey.territory.questions.map((q) => <Question q={q} />)} - </fieldset> - </template> - </section> + {/* The two repeatable kinds of report: places, then prices. Each block is its own entry. */} + {[["places", "place", survey.territory], ["prices", "price", survey.prices]].map(([step, noun, part]) => ( + <section class="sv-step" data-step={step} data-title={part.title} hidden> + <h2>{part.title}</h2> + <p class="sub">{part.intro}</p> + <div class="sv-blocks" data-holder={step}></div> + <p><button type="button" class="go" data-add={step}>+ Add a {noun}</button></p> + <template data-tpl={step}> + <fieldset class="sv-place" data-kind={step}> + <legend><span class="sv-noun">{noun}</span> <span data-nr></span> <button type="button" class="sv-remove" data-remove>Remove</button></legend> + {part.questions.map((q) => <Question q={q} />)} + </fieldset> + </template> + </section> + ))} <section class="sv-step" data-step="send" data-title="Submit" hidden> @@ -65,6 +70,6 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); <ul class="sv-points"> <li>The first time, your browser makes a random key and keeps it in its own storage. It is a pseudonym: it carries no name, email or account, and nobody can tell whose it is.</li> - <li>Your personal answers and each place you described are signed with that key and encrypted so that only you and this project can read them. They are sent to public <a href="https://nostr.com/" target="_blank" rel="noopener noreferrer">Nostr</a> relays, which store them. A relay, like any server, can see the address you connect from; use a VPN or Tor if that matters to you.</li> - <li>Because the key stays in this browser, you can come back to <a href={`${base}survey/me/`}>your submissions</a> to change or delete them. Everything signed with one key belongs together, so your places are linked to your personal answers, but not to you.</li> + <li>Your survey, each place report and each price are signed with that key and encrypted so that only you and this project can read them. They are sent to public <a href="https://nostr.com/" target="_blank" rel="noopener noreferrer">Nostr</a> relays, which store them. A relay, like any server, can see the address you connect from; use a VPN or Tor if that matters to you.</li> + <li>Because the key stays in this browser, you can come back to <a href={`${base}survey/me/`}>your submissions</a> to change or delete them. Everything signed with one key belongs together, so your places and prices are linked to your survey answers, though not to your name.</li> <li>If you clear this browser's data without backing the key up, your answers stay in the survey and you can no longer change them.</li> </ul> @@ -88,5 +93,11 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); const $$ = (sel, root = form) => [...root.querySelectorAll(sel)]; const steps = $$(".sv-step"); - const places = document.getElementById("svPlaces"); + // The repeatable reports: where their blocks live, what they are stored as, and the question + // that makes a block count (an untouched block is not a report). + const REPEATS = [ + { step: "places", type: "observation", key: "country", noun: "place" }, + { step: "prices", type: "price", key: "p_country", noun: "price" }, + ].map((r) => ({ ...r, holder: form.querySelector(`[data-holder="${r.step}"]`), tpl: form.querySelector(`[data-tpl="${r.step}"]`) })); + const repeat = (step) => REPEATS.find((r) => r.step === step); const error = document.getElementById("svError"); let current = 0; @@ -119,7 +130,10 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); } } - const own = steps.filter((s) => !["start", "places", "send"].includes(s.dataset.step)); + const own = steps.filter((s) => !["start", "places", "prices", "send"].includes(s.dataset.step)); const personal = () => Object.assign({}, ...own.map((s) => read(s))); - const described = () => $$(".sv-place", places).map((el) => ({ id: el.dataset.id, answers: read(el) })).filter((p) => p.answers.country); + const blocksOf = (r) => $$(".sv-place", r.holder); + // every filled-in report, of both kinds, ready to be stored + const reports = (r) => blocksOf(r).map((el) => ({ type: r.type, id: el.dataset.id, answers: read(el) })).filter((x) => x.answers[r.key]); + const allReports = () => REPEATS.flatMap(reports); // ── Conditions: `data-if` holds {question: [options]}; any match shows the element ── @@ -151,6 +165,6 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); error.textContent = ""; if (step.dataset.step === "send") { - const a = Object.keys(personal()).length, p = described().length; - document.getElementById("svSummary").textContent = `You answered ${a} question${a === 1 ? "" : "s"} and described ${p} place${p === 1 ? "" : "s"}.`; + const a = Object.keys(personal()).length, [p, m] = REPEATS.map((r) => reports(r).length); + document.getElementById("svSummary").textContent = `You answered ${a} question${a === 1 ? "" : "s"}, reported on ${p} place${p === 1 ? "" : "s"} and gave ${m} price${m === 1 ? "" : "s"}.`; } drawSteps(); @@ -160,5 +174,6 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); function missing(step) { if (step.dataset.step === "start") return document.getElementById("svAdult").checked ? null : "Please confirm that you are 18 or older."; - const blocks = step.dataset.step === "places" ? $$(".sv-place", places).filter((p) => Object.keys(read(p)).length) : [step]; + const r = repeat(step.dataset.step); + const blocks = r ? blocksOf(r).filter((b) => Object.keys(read(b)).length) : [step]; for (const b of blocks) { const got = read(b); @@ -177,27 +192,43 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); document.getElementById("svBack").addEventListener("click", () => { const on = active(); show(on[on.indexOf(steps[current]) - 1]); }); - // ── Places: a repeatable block ── - let placeCount = 0; - function addPlace(answers, id) { - const node = document.getElementById("svPlaceTpl").content.cloneNode(true); - const n = ++placeCount; - // radio groups must not be shared between two places - for (const i of $$("input[name]", node)) i.name = `${i.name}__${n}`; - for (const l of $$("[for]", node)) l.htmlFor = `${l.htmlFor}__${n}`; … 112 more lines
src/styles/global.css +40 −2
Not shown: a picture, a generated file, or past the length shown per change.
survey/2026.yaml +91 −12
Not shown: a picture, a generated file, or past the length shown per change.
survey/results-2026.json +3457 −2944
Not shown: a picture, a generated file, or past the length shown per change.