--- 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(EU27_LIST); const ALSO_IN_EUROPE = new Set(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'); ---

AT Protocol across Europe

The AT Protocol community across {nf(shownCount)} European countries, each account counted once.

{deduped ? 'Three main numbers' : 'Two main numbers'}

Placed in a country {nf(confirmedTotal)} accounts with a country signal we trust
{deduped && (
European, country unknown {nf(infraOnly ?? 0)} accounts on European PDSes, that no country claims
)}
Modelled total {nf(estimate)} people, European-language communities
{deduped && (

The first two are separate evidence for the same thing, and they do not overlap: together {' '}{nf(euTotal)} accounts 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.

)} {estimate > 0 && (
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%.
)} {hasInfra && (
{!deduped && (
On European infrastructure {nf(europeanInfra!.total)} accounts on European PDSes
)}

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.

)}

How we count

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.

By country

{[{ h: 'European Union', rows: euRows }, { h: 'Also in Europe', rows: alsoRows }].map((group) => group.rows.length > 0 && ( <>

{group.h}

{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 ( ); })}
Country Confirmed Estimate Survey* Community
{countryName(c.country, lang)} {nf(c.confirmed)} {weak && (  ⚠ )} {c.estimate > 0 ? `~${nf(c.estimate)}` : ·} {reach ? ~{nf(reach.users)} : ·} {community ? ( {community.name} ) : cities.length ? ( cities.map((x, i) => ( <>{i > 0 && ', '}{x.name} )) ) : ( none yet )}
), )} {otherRows.length > 0 && (

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.

)}

Confirmed is the floor: accounts we can place in the country from a deliberate signal, each counted once. Estimate is our modelled, language-weighted total (same method as the aggregate above; a country page shows how). Survey* 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.

⚠ marks countries whose domain is widely registered for English word-play rather than as a statement about the country (.es, .it, .at, .se, .is). 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 .es handles, almost none of them Spanish.

{/* 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. */}

Communities

Where a community already exists we link to it, so you can find your people. Some cover a whole country, others a single city.

Countries

    {nationalCommunities.map((c) => (
  • {c.name} {countryName(c.country, lang)}
  • ))}

Cities

    {cityCommunities.map((c) => (
  • {c.name} {c.city}, {countryName(c.country, lang)}
  • ))}

Updated {europeStats.generatedAt.slice(0, 10)}. Method: {europeStats.method.attribution}. {' '}{nf(europeStats.contested)} accounts were contested and are counted in no country.