Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375---import Base from '../../layouts/Base.astro';import { localePaths, localeHref } from '../../i18n/paths';import { getScope } from '../../config/scopes';import { europeStats, byConfirmed, confirmedTotal, isWeakTldCountry, countryName, communityFor, citiesFor, europeanInfra, infraCountryless, europeanConfirmedTotal,} from '../../data/europe-stats';import { externalReachFor } from '../../data/external-reach';import { existingCommunities } from '../../data/communities';import { EU27 as EU27_LIST, ALSO_IN_EUROPE as ALSO_LIST } from '../../data/europe-regions';import EuropeFacts from '../../components/pages/EuropeFacts.astro';import EuroskyCta from '../../components/EuroskyCta.astro';
// Aggregate-only. On .nl and .be getStaticPaths returns nothing, so the route is not built at// all — no dead link, no empty page, no redirect.export const getStaticPaths = () => getScope(import.meta.env.PUBLIC_SITE_SCOPE).aggregate ? localePaths() : [];
const { lang } = Astro.props;const nf = (n: number) => n.toLocaleString('en-GB').replace(/,/g, '.');
const withData = byConfirmed.filter((c) => c.total > 0);const estimate = europeStats.peopleEstimate ?? 0;
// Accounts on a European PDS are confirmed European; they just have no country. Counting them// only in a side note understated Europe by tens of thousands, so they are a number of their own// — but only once the pipeline can tell us how many of them a country already claims. Until then// (infraCountryless === null) the page keeps the old two-number split rather than guess an// overlap of zero and publish a total that double-counts.const infraOnly = infraCountryless;const hasInfra = !!europeanInfra && europeanInfra.total > 0;const deduped = hasInfra && infraOnly !== null;const euTotal = deduped ? europeanConfirmedTotal : confirmedTotal;
// Confirmed floor as a share of the modelled estimate, for the proportion bar (small by design).const estPct = estimate > 0 ? Math.round((euTotal / estimate) * 100) : 0;const pctCountry = estimate > 0 ? (confirmedTotal / estimate) * 100 : 0;const pctInfra = estimate > 0 && deduped ? ((infraOnly ?? 0) / estimate) * 100 : 0;
// The directory lists EU members first, then the substantive non-EU European communities (incl.// Ukraine). The rest of the non-EU tail — Russia/Belarus and micro-states, almost all weak-TLD// noise — is left off the table (matching what the homepage map already excludes), with a footnote// for honesty. Shared with the homepage map (src/data/europe-regions.ts) so the table + map agree.const EU27 = new Set<string>(EU27_LIST);const ALSO_IN_EUROPE = new Set<string>(ALSO_LIST);const euRows = withData.filter((c) => EU27.has(c.country));const alsoRows = withData.filter((c) => ALSO_IN_EUROPE.has(c.country));const otherRows = withData.filter((c) => !EU27.has(c.country) && !ALSO_IN_EUROPE.has(c.country));const otherConfirmed = otherRows.reduce((a, c) => a + c.confirmed, 0);const shownCount = euRows.length + alsoRows.length;
// Split the directory into country-wide and single-city communities so the two never blur (a city// community must never read as standing in for its whole country).const nationalCommunities = existingCommunities.filter((c) => c.scope === 'national');const cityCommunities = existingCommunities.filter((c) => c.scope === 'city');---
<Base lang={lang} title="AT Protocol across Europe" description={`AT Protocol accounts across ${shownCount} European countries, each account counted once.`}> <section class="hero"> <h1 class="page-title">AT Protocol across Europe</h1> <p class="lede">The AT Protocol community across {nf(shownCount)} European countries, each account counted once.</p> </section>
<section class="section" id="numbers"> <h2>{deduped ? 'Three main numbers' : 'Two main numbers'}</h2> <div class:list={['two-numbers', deduped && 'three']}> <div class="num"> <span class="num-kind">Placed in a country</span> <span class="figure">{nf(confirmedTotal)}</span> <span class="num-sub">accounts with a country signal we trust</span> </div> {deduped && ( <div class="num"> <span class="num-kind">European, country unknown</span> <span class="figure infra-fig">{nf(infraOnly ?? 0)}</span> <span class="num-sub">accounts on European PDSes, that no country claims</span> </div> )} <div class="num"> <span class="num-kind">Modelled total</span> <span class="figure"><span class="tilde" aria-hidden="true">~</span>{nf(estimate)}</span> <span class="num-sub">people, European-language communities</span> </div> </div> {deduped && ( <p class="prose small sum-note"> The first two are separate evidence for the same thing, and they do not overlap: together {' '}<strong>{nf(euTotal)} accounts</strong> are confirmed European. Of the {' '}{nf(europeanInfra!.total)} accounts on European PDSes, {' '}{nf(europeanInfra!.placedInCountry ?? 0)} already appear under a country above and are counted there, not twice. </p> )} {estimate > 0 && ( <figure class="proportion"> <div class="proportion-track" role="img" aria-label={deduped ? `${nf(confirmedTotal)} placed in a country and ${nf(infraOnly ?? 0)} on European infrastructure, of about ${nf(estimate)} modelled` : `${nf(confirmedTotal)} confirmed of about ${nf(estimate)} modelled`}> <div class="proportion-fill" style={`width:${Math.max(pctCountry, 0.5)}%`}></div> {deduped && pctInfra > 0 && ( <div class="proportion-fill infra-seg" style={`width:${Math.max(pctInfra, 0.5)}%`}></div> )} </div> <figcaption> The whole bar is the modelled total; the filled part is what we can confirm today: {' '}{nf(euTotal)} of ~{nf(estimate)}, about {estPct}%. {deduped && <> The darker segment is the {nf(infraOnly ?? 0)} we can place in Europe but not in a country.</>} That share climbs the longer we measure, though it never reaches 100%. </figcaption> </figure> )}
{hasInfra && ( <div class="infra"> {!deduped && ( <div class="num"> <span class="num-kind">On European infrastructure</span> <span class="figure infra-fig">{nf(europeanInfra!.total)}</span> <span class="num-sub">accounts on European PDSes</span> </div> )} <p class="prose small infra-note"> Running on a European PDS is a deliberate choice for European data sovereignty. It tells us an account is European, but not which country, so these accounts appear under no country in the table below. {' '}Most accounts are still on Bluesky's US hosting. <EuroskyCta lang="en" /> </p> </div> )} </section>
<section class="section"> <h2>How we count</h2> <p class="prose"> AT Protocol has no country field, so we only count people who left a trace we can read: a country domain in their handle, a location in their profile, a community verifier, a curated list. Each account is counted once. Where signals disagree we place it nowhere rather than guess, so these numbers add up instead of overlapping. Language is never evidence: most European languages cross borders, so posting in German says nothing about living in Germany. </p> </section>
<section class="section"> <h2>By country</h2> {[{ h: 'European Union', rows: euRows }, { h: 'Also in Europe', rows: alsoRows }].map((group) => group.rows.length > 0 && ( <> <h3 class="ct-group-h">{group.h}</h3> <div class="table-scroll"> <table class="country-table"> <thead> <tr> <th scope="col">Country</th> <th scope="col" class="num">Confirmed</th> <th scope="col" class="num">Estimate</th> <th scope="col" class="num">Survey<sup>*</sup></th> <th scope="col">Community</th> </tr> </thead> <tbody> {group.rows.map((c) => { const community = communityFor(c.country); const cities = citiesFor(c.country); const weak = isWeakTldCountry(c.country) && c.total > c.confirmed; const reach = externalReachFor(c.country); return ( <tr> <th scope="row"> <a href={localeHref(lang, `/stats/${c.country}/`)}>{countryName(c.country, lang)}</a> </th> <td class="num"> {nf(c.confirmed)} {weak && ( <span class="weak-flag" title="Its country domain is often used for English word-play, so most matches are not evidence of the country"> ⚠</span> )} </td> <td class="num est">{c.estimate > 0 ? `~${nf(c.estimate)}` : <span class="none">·</span>}</td> <td class="num survey">{reach ? <a href={reach.url} rel="noopener" title={`${reach.org} survey (self-reported reach)`}>~{nf(reach.users)}</a> : <span class="none">·</span>}</td> <td> {community ? ( <a href={community.url} rel="noopener">{community.name}</a> ) : cities.length ? ( cities.map((x, i) => ( <>{i > 0 && ', '}<a href={x.url} rel="noopener">{x.name}</a></> )) ) : ( <a class="invite" href={localeHref(lang, `/stats/${c.country}/`)}>none yet</a> )} </td> </tr> ); })} </tbody> </table> </div> </> ), )} {otherRows.length > 0 && ( <p class="prose small"> Plus {nf(otherConfirmed)} accounts in {nf(otherRows.length)} other European countries with only weak or very small signals (and non-EU states like Russia and Belarus), not listed here. They are still counted in the totals above. </p> )} <p class="prose small"> <strong>Confirmed</strong> is the floor: accounts we can place in the country from a deliberate signal, each counted once. <strong>Estimate</strong> is our modelled, language-weighted total (same method as the aggregate above; a country page shows how). <strong>Survey*</strong> is an external, self-reported population estimate where a citable national one exists (Norway: Kantar/NRK; Germany: ARD/ZDF-Medienstudie). A survey measures reach, not accounts we can place, so it runs far higher than either of ours; each links to its source and method. </p> <p class="prose small"> ⚠ marks countries whose domain is widely registered for English word-play rather than as a statement about the country (<code>.es</code>, <code>.it</code>, <code>.at</code>, <code>.se</code>, <code>.is</code>). Those matches are counted but ranked low, so a country can show a small number here and a much larger raw match count. Spain is the clearest case: about 9.800 <code>.es</code> handles, almost none of them Spanish. </p> </section>
{/* Aggregate graphs (creation cohorts / PDS split / lexicon usage). Renders nothing until the EU-facts scan lands in europe-facts.json, then fills in on the next 6h deploy. */} <EuropeFacts />
<section class="section"> <h2>Communities</h2> <p class="prose"> Where a community already exists we link to it, so you can find your people. Some cover a whole country, others a single city. </p> <div class="community-split"> <div class="community-group"> <h3 class="cl-group-h">Countries</h3> <ul class="community-list"> {nationalCommunities.map((c) => ( <li> <a class="cl-name" href={c.url} rel="noopener">{c.name}</a> <span class="cl-country">{countryName(c.country, lang)}</span> </li> ))} </ul> </div> <div class="community-group"> <h3 class="cl-group-h">Cities</h3> <ul class="community-list"> {cityCommunities.map((c) => ( <li> <a class="cl-name" href={c.url} rel="noopener">{c.name}</a> <span class="cl-country">{c.city}, {countryName(c.country, lang)}</span> </li> ))} </ul> </div> </div> </section>
<p class="prose small updated"> Updated {europeStats.generatedAt.slice(0, 10)}. Method: {europeStats.method.attribution}. {' '}{nf(europeStats.contested)} accounts were contested and are counted in no country. </p></Base>
<style> .hero .lede { font-size: 1.1875rem; line-height: 1.5; max-width: 34em; margin-top: var(--space-4); } .prose { max-width: 42em; line-height: 1.6; }
.two-numbers { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-6); margin: var(--space-4) 0 var(--space-6); } .two-numbers.three { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); } @media (max-width: 860px) { .two-numbers.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 560px) { .two-numbers { grid-template-columns: 1fr; gap: var(--space-4); } } @media (max-width: 560px) { .two-numbers.three { grid-template-columns: 1fr; } } .sum-note { margin: calc(var(--space-6) * -1 + var(--space-2)) 0 var(--space-5); max-width: 46em; } .num { display: flex; flex-direction: column; gap: var(--space-1); } .num-kind { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); } .num .figure { font-family: var(--mono); font-weight: 700; font-size: clamp(2rem, 6vw, 3rem); line-height: 1; color: var(--inkstrong); } .num:last-child .figure { color: var(--accent); } .tilde { opacity: 0.7; } .num-sub { font-size: 0.8125rem; color: var(--muted); } .proportion { margin: 0; } /* Flex, not stacked blocks: with two segments the second must sit beside the first, not under it. One segment behaves exactly as before. */ .proportion-track { display: flex; background: var(--rule); border-radius: 999px; height: 10px; overflow: hidden; } .proportion-fill { height: 100%; background: var(--accent); border-radius: 999px; } /* The two segments read as one filled run, so only the outer edges stay round. */ .proportion-fill:has(+ .infra-seg) { border-top-right-radius: 0; border-bottom-right-radius: 0; } .infra-seg { background: var(--brand); border-top-left-radius: 0; border-bottom-left-radius: 0; } .proportion figcaption { margin-top: var(--space-2); font-size: 0.8125rem; color: var(--muted); line-height: 1.5; max-width: 42em; }
.infra { margin-top: var(--space-6); padding: var(--space-4) var(--space-5); border: 1px solid var(--rule); border-radius: 10px; } .infra .num { margin-bottom: var(--space-3); } .infra-fig { color: var(--brand); } .infra-note { margin: 0; max-width: 46em; }
.ct-group-h { font-family: var(--mono); font-size: 0.8125rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); font-weight: 500; margin: var(--space-6) 0 0; } .ct-group-h:first-of-type { margin-top: var(--space-4); } /* Five columns can overflow narrow screens; let the table scroll rather than squash. */ .table-scroll { overflow-x: auto; margin: var(--space-2) 0 var(--space-4); } .country-table { width: 100%; border-collapse: collapse; font-size: 0.9375rem; } .country-table th, .country-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--rule); } .country-table thead th { font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); font-weight: 500; border-bottom-width: 2px; } .country-table tbody th { font-weight: 500; } .country-table a { color: inherit; text-decoration: none; } .country-table a:hover { color: var(--accent); } /* The country name is a link to that country's detail page — make it read as one at rest (accent + underline), since plain-text styling had people not realising it was clickable. */ .country-table th[scope='row'] a { color: var(--accent); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; text-decoration-color: color-mix(in oklab, var(--accent) 45%, transparent); } .country-table th[scope='row'] a:hover { text-decoration-color: var(--accent); } /* The card `.num` above is display:flex; scope-leaked onto table cells it stacked the columns. Force real table cells back so Confirmed/Estimate/Survey sit side by side. */ .country-table .num { display: table-cell; vertical-align: baseline; text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; } /* Confirmed is the number we stand behind; estimate + survey are secondary, so mute them. */ .country-table .est { color: var(--muted); } .country-table .survey a { color: var(--accent); text-decoration: none; } .country-table .survey a:hover { text-decoration: underline; } .country-table .none { color: var(--muted); opacity: 0.5; } .weak-flag { cursor: help; } .invite { color: var(--muted); font-style: italic; }
.community-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-6); margin-top: var(--space-4); } @media (max-width: 640px) { .community-split { grid-template-columns: 1fr; gap: var(--space-4); } } .cl-group-h { font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); font-weight: 500; margin: 0 0 var(--space-1); padding-bottom: var(--space-2); border-bottom: 2px solid var(--rule); } .community-list { list-style: none; padding: 0; margin: var(--space-4) 0 0; } .community-group .community-list { margin-top: 0; } .community-list li { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--rule); } .cl-name { font-weight: 600; color: var(--inkstrong); text-decoration: none; } .cl-name:hover { color: var(--accent); } .cl-country { font-family: var(--mono); font-size: 0.8125rem; color: var(--muted); }
.small { font-size: 0.8125rem; color: var(--muted); } .updated { margin-top: var(--space-8); }</style>