From 421a33fb83200510d452bc438eca97f1e2308e73 Mon Sep 17 00:00:00 2001 From: Guido Jansen Date: Mon, 14 Sep 2026 09:49:46 +0200 Subject: [PATCH] Enrich national /community-size pages with a sources breakdown The .be/.no community-size pages now mirror the .nl content structure as far as per-country data allows: estimate, how we estimate, the confirmed floor broken down by the signal that placed each account (country domain, verifier, Sifa location, company domain, etc., from europe-stats bySignal), what the estimate can't see, and a last-updated line. Localized en/nl/fr/nb. No per-country facts graphs or source-overlap detail (that data is NL-only). --- .../pages/NationalEstimatePage.astro | 129 ++++++++++++++++-- 1 file changed, 118 insertions(+), 11 deletions(-) diff --git a/src/components/pages/NationalEstimatePage.astro b/src/components/pages/NationalEstimatePage.astro index 7258232..d4aa9a7 100644 --- a/src/components/pages/NationalEstimatePage.astro +++ b/src/components/pages/NationalEstimatePage.astro @@ -1,10 +1,11 @@ --- -// Lean /community-size for a national scope other than .nl (.be, .no). The rich Dutch page -// (CommunitySizePage) rests on NL-only feeders; here we have the language-weighted estimate and -// the attributed floor from europe-stats, so this tells the same honest story in short: a modelled -// estimate, the count we can actually stand behind, and what the estimate cannot see. +// /community-size for a national scope other than .nl (.be, .no). The rich Dutch page +// (CommunitySizePage) rests on NL-only feeders; here we build the same content structure from what +// europe-stats gives every country: the language-weighted estimate, the attributed floor broken +// down by the signal that placed each account, and what the estimate cannot see. No per-country +// facts graphs or source-overlap detail (that data is NL-only) — the rest mirrors the .nl page. import { getScope } from '../../config/scopes'; -import { statFor, countryName, type Lang } from '../../data/europe-stats'; +import { statFor, countryName, europeStats, type Lang } from '../../data/europe-stats'; import { useTranslations } from '../../i18n/ui'; interface Props { @@ -18,19 +19,79 @@ const country = countryName(cc, lang); const s = statFor(cc); const nf = (n: number) => n.toLocaleString('nl-NL'); const estimate = s?.estimate ?? 0; -const confirmed = s?.total ?? 0; +const confirmed = s?.confirmed ?? 0; + +// The attributed floor by signal, strongest first, for the sources breakdown. Each account is +// counted once under its strongest signal, so these sum to the confirmed floor. +const SIGNAL_ORDER = ['strongTld', 'weakTld', 'verifier', 'member', 'sifaLocation', 'companyDomain', 'starterpack']; +const bySignal = s?.bySignal ?? {}; +const signalRows = SIGNAL_ORDER + .filter((k) => (bySignal[k] ?? 0) > 0) + .map((k) => ({ key: k, count: bySignal[k] })) + .sort((a, b) => b.count - a.count); +const signalMax = signalRows.reduce((m, r) => Math.max(m, r.count), 0) || 1; + +// Localised labels + one-line descriptions for each signal. .eu is English-only; .be adds fr, .no +// adds nb, both fall back to en. +type Sig = { l: string; d: string }; +const SIGNAL_COPY: Record> = { + en: { + strongTld: { l: 'Country domain', d: `A country-code handle (for example ending in .${cc}).` }, + weakTld: { l: 'Country domain (low confidence)', d: 'A country-code handle whose domain is often used for word-play, so it is counted but ranked low.' }, + verifier: { l: 'Community verifier', d: "On a country community's verifier list." }, + member: { l: 'Member record', d: 'Joined the community here from one of our sites.' }, + sifaLocation: { l: 'Sifa location', d: 'A location in the country on their Sifa profile.' }, + companyDomain: { l: 'Company domain', d: 'Works at a company with a domain in the country (via Sifa).' }, + starterpack: { l: 'Starter pack', d: 'Listed in a curated starter pack for the country.' }, + }, + nl: { + strongTld: { l: 'Landdomein', d: `Een handle met landcode (bijvoorbeeld eindigend op .${cc}).` }, + weakTld: { l: 'Landdomein (lage zekerheid)', d: 'Een handle met landcode waarvan het domein vaak voor woordgrapjes wordt gebruikt, dus meegeteld maar laag gerangschikt.' }, + verifier: { l: 'Community-verifier', d: 'Op de verifier-lijst van een landcommunity.' }, + member: { l: 'Member-record', d: 'Werd hier lid van de community via een van onze sites.' }, + sifaLocation: { l: 'Sifa-locatie', d: 'Een locatie in het land op hun Sifa-profiel.' }, + companyDomain: { l: 'Bedrijfsdomein', d: 'Werkt bij een bedrijf met een domein in het land (via Sifa).' }, + starterpack: { l: 'Starterpack', d: 'Staat in een samengesteld starterpack voor het land.' }, + }, + fr: { + strongTld: { l: 'Domaine national', d: `Un handle en domaine national (par exemple se terminant par .${cc}).` }, + weakTld: { l: 'Domaine national (faible confiance)', d: "Un handle en domaine national souvent utilisé pour des jeux de mots, donc compté mais classé bas." }, + verifier: { l: 'Vérificateur communautaire', d: "Sur la liste de vérification d'une communauté nationale." }, + member: { l: 'Enregistrement de membre', d: "A rejoint la communauté ici depuis l'un de nos sites." }, + sifaLocation: { l: 'Localisation Sifa', d: 'Une localisation dans le pays sur leur profil Sifa.' }, + companyDomain: { l: "Domaine d'entreprise", d: 'Travaille dans une entreprise avec un domaine dans le pays (via Sifa).' }, + starterpack: { l: 'Starter pack', d: 'Figure dans un starter pack organisé pour le pays.' }, + }, + nb: { + strongTld: { l: 'Landdomene', d: `Et håndtak med landkode (for eksempel som slutter på .${cc}).` }, + weakTld: { l: 'Landdomene (lav sikkerhet)', d: 'Et håndtak med landkode der domenet ofte brukes til ordspill, så det telles men rangeres lavt.' }, + verifier: { l: 'Fellesskapsverifikator', d: 'På verifiseringslisten til et landfellesskap.' }, + member: { l: 'Medlemsoppføring', d: 'Ble med i fellesskapet her fra ett av nettstedene våre.' }, + sifaLocation: { l: 'Sifa-plassering', d: 'En plassering i landet på Sifa-profilen deres.' }, + companyDomain: { l: 'Bedriftsdomene', d: 'Jobber i et selskap med et domene i landet (via Sifa).' }, + starterpack: { l: 'Starterpakke', d: 'Oppført i en kuratert starterpakke for landet.' }, + }, +}; +const sig = (k: string): Sig => (SIGNAL_COPY[lang] ?? SIGNAL_COPY.en)[k] ?? SIGNAL_COPY.en[k]; // Per-locale prose, kept inline (a handful of strings) rather than in the global catalogue. -type Copy = { lead: string; howH: string; how: string; floorH: string; floor: string; limitsH: string; limits: string; all: string }; +type Copy = { + lead: string; howH: string; how: string; floorH: string; floor: string; + sourcesH: string; sourcesLead: string; limitsH: string; limits: string; + updated: string; all: string; +}; const COPY: Record Copy> = { en: (c, est, conf) => ({ lead: `Roughly ${est} people are active on and around AT Protocol in ${c}.`, howH: 'How we estimate this', how: `AT Protocol has no country field, so we cannot count directly. We measure posting activity per language across Europe, weight each language to the countries where its speakers live, and calibrate the whole model to the Dutch figure on atproto.nl. Language only feeds the estimate; it never confirms that an account belongs to ${c}.`, floorH: 'What we can stand behind', - floor: `${conf} accounts we can place in ${c} for certain, from a deliberate signal: a country domain, a declared location, a community verifier, or a member record. That is the floor; the real number is higher, which is what the estimate models.`, + floor: `${conf} accounts we can place in ${c} for certain, each from a deliberate signal. The real number is higher, which is what the estimate models.`, + sourcesH: 'Where these accounts come from', + sourcesLead: `Those ${conf} accounts by the signal that placed each one. Every account is counted once, under its strongest signal.`, limitsH: 'What it cannot see', limits: `People who post mainly in English are invisible to a language model, so an English-first account in ${c} is not captured, which is part of why the real number sits above the confirmed floor.`, + updated: 'Updated', all: 'See all of Europe', }), nl: (c, est, conf) => ({ @@ -38,9 +99,12 @@ const COPY: Record Copy> = { howH: 'Hoe we dit schatten', how: `AT Protocol heeft geen landveld, dus we kunnen niet direct tellen. We meten postactiviteit per taal in heel Europa, wegen elke taal naar de landen waar de sprekers wonen, en ijken het model op het Nederlandse getal op atproto.nl. Taal voedt alleen de schatting; het bevestigt nooit dat een account bij ${c} hoort.`, floorH: 'Wat we zeker weten', - floor: `${conf} accounts kunnen we met zekerheid aan ${c} koppelen, via een bewust signaal: een landdomein, een opgegeven locatie, een community-verifier of een member-record. Dat is de ondergrens; het echte aantal ligt hoger, en dat is wat de schatting modelleert.`, + floor: `${conf} accounts kunnen we met zekerheid aan ${c} koppelen, elk via een bewust signaal. Het echte aantal ligt hoger, en dat is wat de schatting modelleert.`, + sourcesH: 'Waar deze accounts vandaan komen', + sourcesLead: `Die ${conf} accounts, gerangschikt naar het signaal dat ze plaatste. Elk account telt één keer, onder zijn sterkste signaal.`, limitsH: 'Wat het niet ziet', limits: `Wie vooral in het Engels post is onzichtbaar voor een taalmodel, dus een Engelstalig account in ${c} telt niet mee. Mede daardoor ligt het echte aantal boven de bevestigde ondergrens.`, + updated: 'Bijgewerkt', all: 'Bekijk heel Europa', }), fr: (c, est, conf) => ({ @@ -48,9 +112,12 @@ const COPY: Record Copy> = { howH: 'Comment nous estimons', how: `AT Protocol n'a pas de champ pays, donc nous ne pouvons pas compter directement. Nous mesurons l'activité par langue à travers l'Europe, pondérons chaque langue selon les pays où vivent ses locuteurs, et calibrons le modèle sur le chiffre néerlandais d'atproto.nl. La langue ne nourrit que l'estimation ; elle ne confirme jamais qu'un compte appartient à la ${c}.`, floorH: 'Ce que nous pouvons affirmer', - floor: `${conf} comptes que nous pouvons rattacher à la ${c} avec certitude, via un signal délibéré : un domaine national, une localisation déclarée, un vérificateur communautaire ou un enregistrement de membre. C'est le plancher ; le chiffre réel est plus élevé, et c'est ce que l'estimation modélise.`, + floor: `${conf} comptes que nous pouvons rattacher à la ${c} avec certitude, chacun via un signal délibéré. Le chiffre réel est plus élevé, et c'est ce que l'estimation modélise.`, + sourcesH: "D'où viennent ces comptes", + sourcesLead: `Ces ${conf} comptes, selon le signal qui a placé chacun. Chaque compte est compté une fois, sous son signal le plus fort.`, limitsH: `Ce qu'elle ne voit pas`, limits: `Les personnes qui publient surtout en anglais sont invisibles pour un modèle linguistique, donc un compte anglophone en ${c} n'est pas capté. C'est en partie pourquoi le chiffre réel dépasse le plancher confirmé.`, + updated: 'Mis à jour', all: `Voir toute l'Europe`, }), nb: (c, est, conf) => ({ @@ -58,14 +125,18 @@ const COPY: Record Copy> = { howH: 'Hvordan vi anslår dette', how: `AT Protocol har ikke et landfelt, så vi kan ikke telle direkte. Vi måler aktivitet per språk i hele Europa, vekter hvert språk mot landene der talerne bor, og kalibrerer modellen mot det nederlandske tallet på atproto.nl. Språk mater bare anslaget; det bekrefter aldri at en konto hører til ${c}.`, floorH: 'Hva vi kan stå for', - floor: `${conf} kontoer kan vi plassere i ${c} med sikkerhet, fra et bevisst signal: et landdomene, en oppgitt plassering, en fellesskapsverifikator eller en medlemsoppføring. Det er gulvet; det reelle tallet er høyere, og det er det anslaget modellerer.`, + floor: `${conf} kontoer kan vi plassere i ${c} med sikkerhet, hver fra et bevisst signal. Det reelle tallet er høyere, og det er det anslaget modellerer.`, + sourcesH: 'Hvor disse kontoene kommer fra', + sourcesLead: `De ${conf} kontoene, etter signalet som plasserte hver enkelt. Hver konto telles én gang, under sitt sterkeste signal.`, limitsH: 'Hva det ikke ser', limits: `De som poster mest på engelsk er usynlige for en språkmodell, så en engelskspråklig konto i ${c} fanges ikke opp. Det er noe av grunnen til at det reelle tallet ligger over det bekreftede gulvet.`, + updated: 'Oppdatert', all: 'Se hele Europa', }), }; const c = (COPY[lang] ?? COPY.en)(country, '~' + nf(estimate), nf(confirmed)); const p = lang === 'en' ? '' : `/${lang}`; +const updatedAt = (europeStats.generatedAt ?? '').slice(0, 10); ---
@@ -76,15 +147,36 @@ const p = lang === 'en' ? '' : `/${lang}`;

{c.howH}

{c.how}

+

{c.floorH}

{c.floor}

+ + {signalRows.length > 0 && ( +
+

{c.sourcesH}

+

{c.sourcesLead}

+
+ {signalRows.map((r) => ( +
+ {sig(r.key).l} + + {nf(r.count)} + {sig(r.key).d} +
+ ))} +
+
+ )} +

{c.limitsH}

{c.limits}

+ {updatedAt &&

{c.updated} {updatedAt}.

} +

{c.all}

@@ -96,4 +188,19 @@ const p = lang === 'en' ? '' : `/${lang}`; .cs-block { margin-bottom: var(--space-8); } .cs-block h2 { font-size: 1.25rem; text-transform: none; letter-spacing: 0; margin-bottom: var(--space-2); } .cs-block p { line-height: 1.6; color: var(--ink); max-width: 40em; } + + .sig-bars { margin-top: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); } + .sig-row { + display: grid; + grid-template-columns: minmax(9rem, 12rem) minmax(4rem, 1fr) auto; + align-items: center; + gap: var(--space-2) var(--space-3); + } + .sig-label { font-weight: 500; } + .sig-track { background: var(--rule); border-radius: 999px; height: 8px; overflow: hidden; } + .sig-fill { display: block; height: 100%; background: var(--accent); border-radius: 999px; } + .sig-num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; } + .sig-desc { grid-column: 1 / -1; font-size: 0.875rem; color: var(--muted); margin: 0; } + + .cs-updated { font-size: 0.8125rem; color: var(--muted); margin-top: calc(-1 * var(--space-4)); margin-bottom: var(--space-8); } -- knotmirror 0.0.1