diff --git a/src/components/pages/CommunitySizePage.astro b/src/components/pages/CommunitySizePage.astro index 80c686a..0e4c726 100644 --- a/src/components/pages/CommunitySizePage.astro +++ b/src/components/pages/CommunitySizePage.astro @@ -8,6 +8,7 @@ import { nlStats, languageSignal, activeSources, overlapCount, summedSources, hasRange, type NlStatsSource } from '../../data/nl-stats'; import CommunityFacts from './CommunityFacts.astro'; import { getScope } from '../../config/scopes'; +import { roughEstimate } from '../../lib/rough'; // "See all of Europe" points at the .eu aggregate's /stats, NOT a local /stats: the stats route is // aggregate-only, so a same-domain link would 404 on atproto.nl. .eu is English-only (no locale prefix). @@ -37,7 +38,7 @@ const summed = summedSources; const exclusive = nlStats.identified.exclusiveBySource ?? null; const bskyM = Math.round(est.blueskyActive / 1_000_000); const dutchAccounts = Math.round((est.blueskyActive * est.dutchPostSharePct) / 100); -const dutchRounded = nf(Math.round(dutchAccounts / 10_000) * 10_000); +const dutchRounded = roughEstimate(dutchAccounts); const windowH = Math.round(est.windowMinutes / 60); const updated = new Date(nlStats.generatedAt).toLocaleDateString(dateLocale, { day: '2-digit', @@ -63,8 +64,8 @@ const t = { : 'AT Protocol has no country field. Accounts carry no location, and neither the identity records nor the profile records say where someone lives. So we do two things instead: we count the Dutch accounts we can point to, and we model how many there probably are in total.', nerdy: isNl ? 'Tijd om nerdy te worden met cijfers!' : 'Time to get nerdy with numbers!', signpost: isNl - ? `Kort antwoord: minstens ${nf(floor)}, waarschijnlijk rond de ${nf(est.modelledNl)}.` - : `Short answer: at least ${nf(floor)}, probably around ${nf(est.modelledNl)}.`, + ? `Kort antwoord: minstens ${nf(floor)}, waarschijnlijk rond de ${roughEstimate(est.modelledNl)}.` + : `Short answer: at least ${nf(floor)}, probably around ${roughEstimate(est.modelledNl)}.`, updated: isNl ? `Bijgewerkt ${updated}` : `Updated ${updated}`, allEurope: isNl ? 'Bekijk heel Europa' : 'See all of Europe', countedLabel: isNl @@ -74,8 +75,8 @@ const t = { modelledLabel: isNl ? 'Gemodelleerd totaal' : 'Modelled total', modelledSub: isNl ? 'geschat' : 'estimated', proportionCap: isNl - ? `De hele balk is het gemodelleerde totaal. Het gevulde deel toont de DIDs die we vandaag bevestigd hebben, terwijl de metingen doorlopen: ${nf(floor)} van de ~${nf(est.modelledNl)}, ongeveer ${confirmedPct}%.` - : `The whole bar is the modelled total. The filled part shows the DIDs we have confirmed today, while measurements are ongoing: ${nf(floor)} of ~${nf(est.modelledNl)}, about ${confirmedPct}%.`, + ? `De hele balk is het gemodelleerde totaal. Het gevulde deel toont de DIDs die we vandaag bevestigd hebben, terwijl de metingen doorlopen: ${nf(floor)} van de ~${roughEstimate(est.modelledNl)}, ongeveer ${confirmedPct}%.` + : `The whole bar is the modelled total. The filled part shows the DIDs we have confirmed today, while measurements are ongoing: ${nf(floor)} of ~${roughEstimate(est.modelledNl)}, about ${confirmedPct}%.`, proportionGrowth: isNl ? `Dat percentage stijgt naarmate we langer meten, maar de 100% halen we nooit.` : `That share climbs the longer we measure, though it will never reach 100%.`, @@ -244,9 +245,7 @@ const caveatConclusion = isNl - + {roughEstimate(est.modelledNl)} { @@ -372,7 +371,7 @@ const caveatConclusion = isNl as "tiny". This is a diagram of the arithmetic, not a chart of it. -->
  1. - {nf(est.blueskyActive)} + {roughEstimate(est.blueskyActive)} {isNl ? 'Bluesky-accounts' : 'Bluesky accounts'}
  2. @@ -382,7 +381,7 @@ const caveatConclusion = isNl
  3. - ~{nf(est.modelledNl)} + ~{roughEstimate(est.modelledNl)} {isNl ? 'in Nederland' : 'in the Netherlands'}
diff --git a/src/lib/rough.ts b/src/lib/rough.ts index 490ca7d..d0d69c2 100644 --- a/src/lib/rough.ts +++ b/src/lib/rough.ts @@ -1,10 +1,16 @@ // Round a modelled estimate to a rough magnitude for display. // // A model output should never imply single-account precision: "~942.001" reads as a census -// when it is a language-weighted guess. So headline estimates are shown to two significant -// figures with an M/K suffix ("~3M", "~940K"), which also matches the "~3M" headline on -// /stats. Small communities (< 1000) keep their real number — at that scale the digits are -// not false precision and the count deserves to be seen. +// when it is a language-weighted guess. But whole millions ("~3M") throw away a digit the +// model does resolve, and are inconsistent with the "~940K" national figures. So estimates +// are shown to a consistent TWO significant figures with an M/K suffix: a single-digit +// magnitude keeps one decimal ("2.8M", "6.1K", "3.0M"), ten or more shows none ("12M", +// "62K", "940K"). Two is the honest ceiling for a number we label "least certain"; three +// ("2.98M") would overclaim precision we have not measured. A period decimal (not a comma) +// because the abbreviated M/K form reads internationally, where "2,8M" invites a "28M" misread. +// +// Small communities (< 1000) keep their real number — at that scale the digits are not false +// precision and the count deserves to be seen. // // Returns the bare figure without a leading "~"; callers add the tilde where they want it, // so a styled tilde element or an "around"/"roughly" lead-in stays in the caller's hands. @@ -12,9 +18,11 @@ export function roughEstimate(n: number, locale = 'nl-NL'): string { if (!Number.isFinite(n) || n <= 0) return ''; if (n < 1000) return n.toLocaleString(locale); const digits = Math.floor(Math.log10(n)); - const factor = Math.pow(10, Math.max(0, digits - 1)); // keep two significant figures + const factor = Math.pow(10, Math.max(0, digits - 1)); // round to two significant figures const r = Math.round(n / factor) * factor; - const dec = locale === 'nl-NL' ? ',' : '.'; - const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(1)).replace('.', dec); - return r >= 1_000_000 ? `${fmt(r / 1_000_000)}M` : `${fmt(r / 1000)}K`; + const unit = r >= 1_000_000 ? 1_000_000 : 1000; + const suffix = r >= 1_000_000 ? 'M' : 'K'; + const v = r / unit; + const s = v < 10 ? v.toFixed(1) : String(Math.round(v)); + return `${s}${suffix}`; } diff --git a/src/pages/[...lang]/stats.astro b/src/pages/[...lang]/stats.astro index cc182db..160717e 100644 --- a/src/pages/[...lang]/stats.astro +++ b/src/pages/[...lang]/stats.astro @@ -12,6 +12,7 @@ import { EU27 as EU27_LIST, ALSO_IN_EUROPE as ALSO_LIST } from '../../data/europ import EuropeFacts from '../../components/pages/EuropeFacts.astro'; import EuroskyCta from '../../components/EuroskyCta.astro'; import countrylessEstimateRaw from '../../data/countryless-estimate.json'; +import { roughEstimate } from '../../lib/rough'; // 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. @@ -98,7 +99,7 @@ const cityCommunities = existingCommunities.filter((c) => c.scope === 'city'); )}
Modelled estimate - {Math.round(estimate / 1e6)}M + {roughEstimate(estimate)} a rough model of European-language accounts, our least certain number
@@ -114,8 +115,8 @@ const cityCommunities = existingCommunities.filter((c) => c.scope === 'city'); {estimate > 0 && (
The whole bar is the modelled total, the filled part is what we can identify today: - {' '}{nf(euTotal)} of ~{nf(estimate)}, about {estPct}%. + {' '}{nf(euTotal)} of ~{roughEstimate(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%. @@ -215,7 +216,7 @@ const cityCommunities = existingCommunities.filter((c) => c.scope === 'city'); ? <>{nf(c.active)}{' '}{Math.round((c.active / c.total) * 100)}% : ·} )} - {c.estimate > 0 ? `~${nf(c.estimate)}` : ·} + {c.estimate > 0 ? `~${roughEstimate(c.estimate)}` : ·} {reach ? ~{nf(reach.users)} : ·}