diff --git a/backend/api/src/stats.ts b/backend/api/src/stats.ts index dcd99ab6..14240428 100644 --- a/backend/api/src/stats.ts +++ b/backend/api/src/stats.ts @@ -10,10 +10,6 @@ let cachedData: any = null let cacheTimestamp: number = 0 const CACHE_DURATION_MS = HOUR_MS -// How many countries the breakdown returns. The about page shows a short ranked list, so a long tail -// would be payload nobody renders; `countryCount` still reports the full spread. -const TOP_COUNTRIES = 8 - // Below this many respondents a field is not published at all. A distribution drawn from a handful of // people is noise, and naming the one member in a rare category is the kind of soft de-anonymisation a // public transparency page should not do. Mirrors the spirit of MIN_COUNTRIES on the frontend. @@ -78,15 +74,13 @@ async function ageDistribution(pg: SupabaseDirectClient): Promise= 18 @@ -192,7 +186,9 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { conversations: conversationCount.count, genderRatio: genderPercentage, genderCounts: genderRatio, - countries: countries.slice(0, TOP_COUNTRIES), + // The full ranked list, not a top-N: the /stats world map colours every country with members, and + // the ranked list under it slices its own head. Still a small payload (one row per country present). + countries, countryCount: countries.length, demographics, } diff --git a/common/src/stats.ts b/common/src/stats.ts index 86e6dbde..4423d669 100644 --- a/common/src/stats.ts +++ b/common/src/stats.ts @@ -52,9 +52,10 @@ export type Stats = { conversations: number genderRatio: Record genderCounts: Record - /** Top countries by profile count, descending. Profiles with no country set are excluded. */ + /** Every country with at least one member, by profile count descending. Profiles with no country set + * are excluded. The full list (not a top-N) so the map can shade every one; consumers slice their own. */ countries: CountryCount[] - /** Distinct countries represented, including any outside the `countries` top-N. */ + /** Distinct countries represented — same as `countries.length`, kept for callers that only need the number. */ countryCount: number /** * Per-field breakdowns of the member base. A field is omitted entirely when too few members answered diff --git a/web/components/widgets/charts.tsx b/web/components/widgets/charts.tsx index 6f34b062..c86b99a0 100644 --- a/web/components/widgets/charts.tsx +++ b/web/components/widgets/charts.tsx @@ -389,9 +389,10 @@ export default function ChartMembers() { void load() }, []) - // Colors from palette + // Colors from palette. The second series is a warm neutral taupe rather than a second loud hue — + // "Completed" is a subset of "Total", so it reads as a quieter companion line, not a rival category. const AMBER = 'rgb(193 127 62)' // primary-500 - const SAGE = 'rgb(107 143 113)' // green-500 + const NEUTRAL = 'rgb(150 132 108)' // warm taupe (ink-600-ish), replaces the old green return (
@@ -425,8 +426,8 @@ export default function ChartMembers() { - - + + @@ -459,17 +460,17 @@ export default function ChartMembers() { } /> - {/* Completed (sage, behind) */} + {/* Completed (warm neutral, behind) */} {/* Total (amber, on top) */} diff --git a/web/components/widgets/stat-donut.tsx b/web/components/widgets/stat-donut.tsx new file mode 100644 index 00000000..6e046a19 --- /dev/null +++ b/web/components/widgets/stat-donut.tsx @@ -0,0 +1,107 @@ +import clsx from 'clsx' +import {ComponentType, SVGProps, useMemo} from 'react' +import {Cell, Pie, PieChart, ResponsiveContainer} from 'recharts' +import {surface} from 'web/components/widgets/surface' +import {useThemeColors} from 'web/hooks/use-theme-colors' + +/** + * A donut for a small categorical or ordinal split (gender, age) — the counterpart to the bar-list + * `DistributionCard`. It exists to break the page's rhythm: a run of identical bar lists reads as + * wallpaper, so the two splits that are naturally few-valued get a different mark entirely. + * + * Identity is carried by the legend (label + count + %), not by colour alone — which is what lets the + * slices stay on the brand's single warm ramp instead of pulling in loud categorical hues. Each segment + * names its own colour token, so the colour follows the entity, never its rank. + */ + +type IconType = ComponentType> + +export type DonutSegment = { + label: string + value: number + /** A Tailwind colour CSS variable name, e.g. `--color-primary-500`. */ + colorVar: string +} + +export function StatDonut({ + title, + subtitle, + icon: Icon, + segments, +}: { + title: string + subtitle: string + icon: IconType + segments: DonutSegment[] +}) { + const data = segments.filter((s) => s.value > 0) + const total = data.reduce((sum, s) => sum + s.value, 0) + + const varNames = useMemo(() => data.map((s) => s.colorVar), [data]) + const colors = useThemeColors(varNames) + // The 2px gaps between slices are drawn in the surface colour so they read as clean cuts, not outlines. + const [surfaceColor] = useThemeColors(SURFACE_VAR) + + if (!total) return null + + return ( +
+
+
+ +
+
+

{title}

+

{subtitle}

+
+
+ +
+
+ + + 1 ? 2 : 0} + stroke={surfaceColor ?? undefined} + strokeWidth={2} + isAnimationActive={false} + > + {data.map((_, i) => ( + + ))} + + + +
+ +
    + {data.map((s, i) => ( +
  • + + + {s.label} + + + {Math.round((s.value / total) * 100)}% + +
  • + ))} +
+
+
+ ) +} + +const SURFACE_VAR = ['--color-canvas-50'] diff --git a/web/components/widgets/world-heatmap.tsx b/web/components/widgets/world-heatmap.tsx new file mode 100644 index 00000000..0e8d2996 --- /dev/null +++ b/web/components/widgets/world-heatmap.tsx @@ -0,0 +1,292 @@ +import clsx from 'clsx' +import {CountryCount} from 'common/stats' +import {geoPath, geoProjection} from 'd3-geo' +import type {Feature, FeatureCollection, Geometry, MultiPoint} from 'geojson' +import {useState} from 'react' +import {feature} from 'topojson-client' +import type {Topology} from 'topojson-specification' +import {surface} from 'web/components/widgets/surface' +import {useT} from 'web/lib/locale' +import worldTopo from 'world-atlas/countries-110m.json' + +/** + * Where members are, as a choropleth heatmap (the counterpart to the ranked-bar `CountrySpread`). + * + * A world map is the one form that shows *reach* at a glance — a bar list ranks the top few, but only a + * map makes "not tied to one country" legible instantly. Fill is a single warm ramp (sequential, the + * right encoding for a magnitude), so it stays on-brand rather than pulling in map-primary blues; the + * top few countries are named directly on the map, and any country is hoverable for its exact count. + * + * Geometry (world-atlas 110m) and the projection are computed once at module load — the extent is fixed + * to the viewBox, so nothing here is per-render. Antarctica is dropped: it's always empty and eats a + * third of the vertical space. + */ + +type CountryFeature = Feature + +// Fixed internal coordinate system; the SVG scales to its container via viewBox. +// +// The projection is Miller cylindrical, built here from its raw formula because d3-geo ships Mercator and +// equirectangular but not Miller. Picking it is what makes the map fill its box without looking wrong: +// • Pseudocylindrical (NaturalEarth, EqualEarth) curves the edges inward toward the poles, pushing +// Alaska a long way from the left border and leaving the corners empty. +// • Equirectangular has straight edges but a *constant* vertical scale, so high-latitude land — Alaska, +// Russia, Greenland — comes out horizontally smeared. +// • Mercator cures the smearing but balloons Greenland and Russia. +// Miller keeps straight vertical meridians, so every latitude spans the full width and Alaska sits against +// the left border with New Zealand against the right, while its vertical scale grows gently toward the +// poles so shapes stay natural. Latitudes are trimmed to the inhabited band, cropping empty polar water. +const LAT_TOP = 84 +const LAT_BOTTOM = -57 +const VIEW_W = 820 +const MARGIN = 6 +const DEG = Math.PI / 180 + +/** Miller's vertical coordinate for a latitude in degrees (its raw y, in projection units). */ +function millerY(latDeg: number) { + return 1.25 * Math.log(Math.tan(Math.PI / 4 + 0.4 * latDeg * DEG)) +} + +function millerRaw(lambda: number, phi: number): [number, number] { + return [lambda, 1.25 * Math.log(Math.tan(Math.PI / 4 + 0.4 * phi))] +} +millerRaw.invert = function (x: number, y: number): [number, number] { + return [x, 2.5 * Math.atan(Math.exp(0.8 * y)) - 0.625 * Math.PI] +} + +// Height follows the window's own aspect (full 360° of longitude against the trimmed latitude band), so +// the map fills the box with no letterboxing in either axis. +const VIEW_H = Math.round( + ((VIEW_W - 2 * MARGIN) * (millerY(LAT_TOP) - millerY(LAT_BOTTOM))) / (2 * Math.PI) + 2 * MARGIN, +) + +// The four corners of that window. Fitting to these (rather than to the country shapes) is what makes the +// map fill the box exactly: the shapes' own bounds are useless here because Fiji and Russia straddle the +// antimeridian and each report a full-width bounding box. +const WINDOW: MultiPoint = { + type: 'MultiPoint', + coordinates: [ + [-179.999, LAT_TOP], + [179.999, LAT_TOP], + [179.999, LAT_BOTTOM], + [-179.999, LAT_BOTTOM], + ], +} + +// How many countries the ranked list under the map names before collapsing the rest into "+N more". +const TOP_LIST = 16 + +const topology = worldTopo as unknown as Topology +const collection = feature(topology, topology.objects.countries) as FeatureCollection< + Geometry, + {name: string} +> + +const FEATURES: CountryFeature[] = collection.features.filter( + (f) => f.properties.name !== 'Antarctica', +) + +const projection = geoProjection(millerRaw).fitExtent( + [ + [MARGIN, MARGIN], + [VIEW_W - MARGIN, VIEW_H - MARGIN], + ], + WINDOW, +) +const pathGen = geoPath(projection) + +const SHAPES = FEATURES.map((f) => ({ + name: f.properties.name, + d: pathGen(f) ?? '', + centroid: pathGen.centroid(f) as [number, number], +})) + +// The stored `profiles.country` is an English display name; world-atlas uses its own short forms for a +// handful of countries. Map our name → the atlas name for the common mismatches. Everything else matches +// directly. Keys and the atlas lookup are both diacritic-stripped + lowercased, so accents never matter. +const NAME_ALIASES: Record = { + 'united states': 'united states of america', + usa: 'united states of america', + us: 'united states of america', + 'czech republic': 'czechia', + 'democratic republic of the congo': 'dem. rep. congo', + 'republic of the congo': 'congo', + 'bosnia and herzegovina': 'bosnia and herz.', + 'dominican republic': 'dominican rep.', + 'central african republic': 'central african rep.', + 'south sudan': 's. sudan', + 'equatorial guinea': 'eq. guinea', + 'solomon islands': 'solomon is.', + 'ivory coast': "cote d'ivoire", + eswatini: 'eswatini', + 'north macedonia': 'macedonia', +} + +function normalize(name: string) { + return name.normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toLowerCase() +} + +const SHAPE_BY_NORM = new Map(SHAPES.map((s) => [normalize(s.name), s.name])) + +// Colour for a country with no members — a neutral land tone, deliberately outside the amber ramp so +// that even a single member (the lightest ramp step) reads as clearly "someone is here" against it. +const EMPTY_VAR = '--color-canvas-300' + +// Sequential amber ramp, light → dark. It starts at a *saturated* step (primary-300), not a near-white +// one, precisely so 1 member is visibly amber against the empty-land tone — the difference between nobody +// and one person has to show. Referenced as CSS vars so the fills track the theme toggle without any JS. +const RAMP = [ + '--color-primary-300', + '--color-primary-400', + '--color-primary-500', + '--color-primary-600', + '--color-primary-700', +] + +function rampVar(count: number, max: number): string { + if (count <= 0) return EMPTY_VAR + // sqrt so a long tail of 1-member countries doesn't all collapse onto the lightest step. + const t = Math.sqrt(count / max) + const i = Math.min(RAMP.length - 1, Math.max(0, Math.ceil(t * RAMP.length) - 1)) + return RAMP[i] +} + +export function WorldHeatmap({ + countries, + countryCount, +}: { + countries?: CountryCount[] + countryCount?: number +}) { + const t = useT() + const [hover, setHover] = useState<{name: string; count: number; x: number; y: number} | null>( + null, + ) + + const rows = countries ?? [] + // Resolve each data row to an atlas shape name, then sum onto it (two data spellings could collide). + const countByShape = new Map() + for (const row of rows) { + const norm = normalize(row.country) + const shapeName = SHAPE_BY_NORM.get(NAME_ALIASES[norm] ?? norm) + if (!shapeName) continue + countByShape.set(shapeName, (countByShape.get(shapeName) ?? 0) + row.count) + } + + const max = Math.max(1, ...Array.from(countByShape.values())) + const total = countryCount ?? rows.length + + // The ranked list under the map — the country names and counts live here now, not on the map itself. + const ranked = [...rows].sort((a, b) => b.count - a.count) + const shownList = ranked.slice(0, TOP_LIST) + const remaining = ranked.length - shownList.length + + return ( +
+
+
+

+ {t('stats.countries.title', 'Where members are')} +

+

+ {total > 1 + ? t('stats.countries.reach', '{count} countries and counting', {count: total}) + : t('stats.countries.subtitle', 'Compass is not tied to one city or one country.')} +

+
+ {/* Compact sequential legend: light = few, dark = many. */} +
+ + Fewer + +
+ {RAMP.map((v) => ( + + ))} +
+ More +
+
+ + {/* Bled out to the card's edges (cancelling its horizontal padding) so the map really does span the + full width. The overlay tooltip is positioned inside this same box, so it scales with the map. */} +
+ + {SHAPES.map((s) => { + const count = countByShape.get(s.name) ?? 0 + return ( + 0 && 'cursor-default')} + onMouseEnter={ + count > 0 + ? () => + setHover({ + name: s.name, + count, + x: (s.centroid[0] / VIEW_W) * 100, + y: (s.centroid[1] / VIEW_H) * 100, + }) + : undefined + } + onMouseLeave={count > 0 ? () => setHover(null) : undefined} + /> + ) + })} + + + {hover && ( +
+
{hover.name}
+
+ {hover.count === 1 + ? t('stats.countries.member_one', '1 member') + : t('stats.countries.members', '{count} members', {count: hover.count})} +
+
+ )} +
+ + {/* Country names + counts, below the map. Each swatch matches that country's fill on the map, so + the list and the map read as one object; the tail collapses into a "+N more". */} + {!!shownList.length && ( +
+ {shownList.map((r) => { + const norm = normalize(r.country) + const resolved = SHAPE_BY_NORM.get(NAME_ALIASES[norm] ?? norm) + const fillVar = resolved ? rampVar(r.count, max) : EMPTY_VAR + return ( + + + {r.country} + + {r.count.toLocaleString()} + + + ) + })} + {remaining > 0 && ( + + {t('stats.countries.remaining', '+ {count} more countries', {count: remaining})} + + )} +
+ )} +
+ ) +} diff --git a/web/hooks/use-theme-colors.ts b/web/hooks/use-theme-colors.ts new file mode 100644 index 00000000..3c7c56b5 --- /dev/null +++ b/web/hooks/use-theme-colors.ts @@ -0,0 +1,40 @@ +import {useEffect, useState} from 'react' + +/** + * Resolves Tailwind colour CSS variables (e.g. `--color-primary-500`) to concrete `rgb(r g b)` strings, + * and re-resolves when the theme flips — the app stamps `data-theme` / a class on ``. + * + * Why it exists: recharts writes fills as SVG *presentation attributes*, which do not evaluate `var()`. + * So a chart can't just reference `rgb(var(--color-primary-500))` the way normal CSS can; it needs the + * value read out of the cascade. Raw SVG we render ourselves can use `var()` inline and doesn't need this. + * + * Runs entirely in an effect (no `getComputedStyle` on the server); components that use it for chart + * fills should be client-only anyway. Before the first effect it returns `null`s, so guard on that. + */ +export function useThemeColors(varNames: string[]): (string | null)[] { + const key = varNames.join(',') + const [colors, setColors] = useState<(string | null)[]>(() => varNames.map(() => null)) + + useEffect(() => { + const names = key.split(',') + const resolve = () => { + const styles = getComputedStyle(document.documentElement) + setColors( + names.map((n) => { + const v = styles.getPropertyValue(n).trim() + return v ? `rgb(${v})` : null + }), + ) + } + resolve() + // The theme toggle mutates rather than firing an event, so watch the attributes it touches. + const observer = new MutationObserver(resolve) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-theme'], + }) + return () => observer.disconnect() + }, [key]) + + return colors +} diff --git a/web/package.json b/web/package.json index 06820cd3..21133f39 100644 --- a/web/package.json +++ b/web/package.json @@ -49,6 +49,7 @@ "@vercel/og": "0.5.20", "clsx": "2.1.1", "compressorjs": "1.1.1", + "d3-geo": "3.1.1", "dayjs": "1.11.19", "firebase": "11.1.0", "heic2any": "0.0.4", @@ -66,7 +67,9 @@ "react-icons": "5.5.0", "react-markdown": "10.1.0", "recharts": "3.2.1", - "tailwindcss": "^3.4" + "tailwindcss": "^3.4", + "topojson-client": "3.1.0", + "world-atlas": "2.0.2" }, "devDependencies": { "@capacitor/android": "7.4.4", @@ -82,11 +85,12 @@ "@types/node": "20.19.35", "@types/react": "19.2.3", "@types/react-dom": "19.2.3", + "@types/topojson-client": "3.1.5", "autoprefixer": "10.4.27", "concurrently": "8.2.2", "cross-env": "7.0.3", "next-sitemap": "4.2.3", - "postcss": "8.5.6", + "postcss": "8.5.10", "tsc-files": "1.1.3" } } diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 3ff72476..f4b4f38c 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -1,59 +1,57 @@ import { AcademicCapIcon, ArrowTrendingUpIcon, - BoltIcon, + BuildingLibraryIcon, CakeIcon, CalendarDaysIcon, - ChatBubbleOvalLeftIcon, - CheckCircleIcon, - ClipboardDocumentListIcon, - EnvelopeIcon, + ChatBubbleLeftRightIcon, FingerPrintIcon, FlagIcon, GlobeAltIcon, - HandRaisedIcon, HeartIcon, LanguageIcon, LinkIcon, MagnifyingGlassIcon, - QuestionMarkCircleIcon, - ScaleIcon, + PuzzlePieceIcon, SunIcon, - UsersIcon, + UserGroupIcon, } from '@heroicons/react/24/outline' import clsx from 'clsx' -import {type DemographicField, type Stats} from 'common/stats' -import {ComponentType, ReactNode, SVGProps, useEffect, useState} from 'react' +import {type DemographicField, type Distribution, type Stats} from 'common/stats' +import dynamic from 'next/dynamic' +import {ComponentType, SVGProps, useEffect, useState} from 'react' import {SectionLabel} from 'web/components/about/section' import {PageBase} from 'web/components/page-base' import {SEO} from 'web/components/SEO' import ChartMembers from 'web/components/widgets/charts' -import {CountrySpread, MIN_COUNTRIES} from 'web/components/widgets/country-spread' +import {MIN_COUNTRIES} from 'web/components/widgets/country-spread' import {Reveal} from 'web/components/widgets/reveal' import {DistributionCard} from 'web/components/widgets/stat-distribution' -import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface' +import {DonutSegment, StatDonut} from 'web/components/widgets/stat-donut' +import {eyebrow, Section, surface} from 'web/components/widgets/surface' import {api} from 'web/lib/api' import {useT} from 'web/lib/locale' import {getCount} from 'web/lib/supabase/users' +// The map pulls in the world-atlas geometry (~100KB) plus d3-geo; code-split it so it never weighs on the +// initial load, and never runs on the server — it's a client-only visual on an already client-fetched page. +const WorldHeatmap = dynamic( + () => import('web/components/widgets/world-heatmap').then((m) => m.WorldHeatmap), + { + ssr: false, + loading: () => ( +
+
+
+
+ ), + }, +) + // ─── Types ──────────────────────────────────────────────────────────────────── type IconType = ComponentType> -interface StatCardProps { - value: string | number | null | undefined - label: string - icon: IconType - accent?: 'amber' | 'sage' | 'muted' -} - -interface StatGroupProps { - title: string - /** Optional block sharing the group's row — the right column on large screens. */ - aside?: ReactNode - children: ReactNode -} - // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { @@ -62,66 +60,72 @@ function formatNumber(n: number): string { return n.toLocaleString() } -// ─── Stat Card ──────────────────────────────────────────────────────────────── +// ─── Hero band ────────────────────────────────────────────────────────────────── -function StatCard({value, label, icon: Icon, accent = 'amber'}: StatCardProps) { - if (value === null || value === undefined || value === 0) return null +/** + * The headline numbers as one dark, rule-divided band rather than a row of identical tiles — the tiles + * were the start of the "wall of same block" the rest of the page also fights. A single feature surface + * with an amber glow reads as an opening statement; it appears exactly once, so it never becomes texture. + */ +function HeroBand({ + members, + active, + countries, + discussions, + messages, +}: { + members: number | null + active: number | null + countries: number | null | undefined + discussions: number | null + messages: number | null | undefined +}) { + const t = useT() + const items = [ + {value: members, label: t('stats.highlight.members', 'Members')}, + {value: active, label: t('stats.highlight.active', 'Active this month')}, + { + value: countries && countries > 1 ? countries : undefined, + label: t('stats.highlight.countries', 'Countries'), + }, + {value: discussions, label: t('stats.discussions', 'Discussions')}, + {value: messages, label: t('stats.messages', 'Messages')}, + ].filter((i) => !!i.value) - const formatted = typeof value === 'number' ? formatNumber(value) : value - - const accentClasses = { - amber: 'text-primary-600', - sage: 'text-green-600', - muted: 'text-ink-700', - } + if (!items.length) return null return ( -
-
- -
- +
- {formatted} + aria-hidden + className="pointer-events-none absolute -right-16 -top-20 h-64 w-64 rounded-full bg-primary-500/20 blur-3xl" + /> +
+ {items.map((item, i) => ( +
0 && 'min-w-[92px]', + )} + > +
+ {typeof item.value === 'number' ? formatNumber(item.value) : item.value} +
+
{item.label}
+
+ ))}
- -

- {label} -

) } -// ─── Stat Group ─────────────────────────────────────────────────────────────── - -function StatGroup({title, aside, children}: StatGroupProps) { - return ( - <> - {title} - {/* With an `aside`, the cards give up half the width and drop to a 2×2 block beside it on large - screens; without one they keep the full-width 4-up row. The aside stacks underneath below - `lg` rather than squeezing — its country bars need the horizontal room. */} -
-
- {children} -
- {aside} -
- - ) -} - -// ─── Chart wrapper ──────────────────────────────────────────────────────────── +// ─── Growth chart ──────────────────────────────────────────────────────────── function ChartCard() { const t = useT() @@ -145,126 +149,179 @@ function ChartCard() { ) } -// ─── Hero highlight band ──────────────────────────────────────────────────────── +// ─── Activity panel ────────────────────────────────────────────────────────── -function HighlightRow({ - members, - active, - messages, - countries, +/** + * A small labelled list of related counts (conversations, compatibility, democracy). Replaces three + * near-identical two-card sections with three compact panels in one row — same information, a fraction of + * the vertical run, and no more repeated card grid at the foot of the page. + */ +function ActivityPanel({ + icon: Icon, + title, + rows, }: { - members: number | null - active: number | null + icon: IconType + title: string + rows: {label: string; value: number | null | undefined}[] +}) { + const shown = rows.filter((r) => !!r.value) + if (!shown.length) return null + return ( +
+
+
+ +
+

{title}

+
+
+ {shown.map((r) => ( +
+
{r.label}
+
+ {formatNumber(r.value as number)} +
+
+ ))} +
+
+ ) +} + +/** + * The three activity panels as a row, with empty ones dropped entirely rather than left as blank grid + * cells — a young platform may have votes but no messages yet, and a hole in the row reads as a bug. + */ +function ActivitySection({ + data, + messages, +}: { + data: Record messages: number | null | undefined - countries: number | null | undefined }) { const t = useT() - const items: { - value: number | null | undefined - label: string - icon: IconType - accent: 'amber' | 'sage' - }[] = [ + const panels = [ { - value: members, - label: t('stats.highlight.members', 'Total Members'), - icon: UsersIcon, - accent: 'amber' as const, + icon: ChatBubbleLeftRightIcon, + title: t('stats.group.conversations', 'Conversations'), + rows: [ + {label: t('stats.discussions', 'Discussions'), value: data.private_user_message_channels}, + {label: t('stats.messages', 'Messages'), value: messages}, + ], }, { - value: active, - label: t('stats.highlight.active', 'Active (last month)'), - icon: BoltIcon, - accent: 'sage' as const, + icon: PuzzlePieceIcon, + title: t('stats.group.compatibility', 'Compatibility'), + rows: [ + { + label: t('stats.compatibility_prompts', 'Prompts created'), + value: data.compatibility_prompts, + }, + {label: t('stats.prompts_answered', 'Prompts answered'), value: data.compatibility_answers}, + ], }, { - value: messages, - label: t('stats.highlight.messages', 'Messages sent'), - icon: EnvelopeIcon, - accent: 'amber' as const, + icon: BuildingLibraryIcon, + title: t('stats.group.democracy', 'Democracy'), + rows: [ + {label: t('stats.proposals', 'Proposals'), value: data.votes}, + {label: t('stats.votes', 'Votes cast'), value: data.vote_results}, + ], }, - { - value: countries && countries > 1 ? countries : undefined, - label: t('stats.highlight.countries', 'Countries'), - icon: GlobeAltIcon, - accent: 'sage' as const, - }, - ].filter((i) => !!i.value) + ].filter((p) => p.rows.some((r) => !!r.value)) - if (!items.length) return null + if (!panels.length) return null return ( -
= 4 ? 'lg:grid-cols-4' : 'sm:grid-cols-3', - )} - > - {items.map((item, i) => { - const Icon = item.icon - return ( - -
-
-
- -
-
- {typeof item.value === 'number' ? formatNumber(item.value) : item.value} -
-

- {item.label} -

-
+
+ {t('stats.group.activity', 'Activity & participation')} +
= 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2', + )} + > + {panels.map((p, i) => ( + + - ) - })} -
+ ))} +
+ ) } // ─── Demographics ─────────────────────────────────────────────────────────────── /** - * The order and chrome of the "Who's on Compass" cards. The data (and whether a field appears at all) - * comes from the `stats` payload — a field the backend withheld for too few respondents simply has no - * entry here, and its `DistributionCard` renders nothing. Gender leads (with the country card beside it, - * inserted separately below), then the rest run roughly most-universal first: nearly everyone has an age - * and an education; a language or relationship-status breakdown is a deeper cut. + * The bar-list breakdowns, in a masonry rather than a fixed grid. Their natural heights differ (education + * has five rows, languages seven, "looking for" three), so a rigid grid would pad them all to the tallest + * in the row — the exact uniformity that made the section read as wallpaper. CSS columns let each card + * keep its own height and pack organically. Gender, age and countries are pulled out above as a donut / + * donut / map trio, so the wall is broken before the bar lists even begin. */ -const DEMOGRAPHIC_CARDS: {field: DemographicField; title: string; icon: IconType}[] = [ - {field: 'gender', title: 'Gender', icon: ScaleIcon}, - {field: 'age', title: 'Age', icon: CalendarDaysIcon}, - {field: 'pref_relation_styles', title: 'Looking for', icon: MagnifyingGlassIcon}, - {field: 'orientation', title: 'Orientation', icon: HeartIcon}, - {field: 'relationship_status', title: 'Relationship status', icon: LinkIcon}, - {field: 'ethnicity', title: 'Ethnicity', icon: GlobeAltIcon}, +const BAR_CARDS: {field: DemographicField; title: string; icon: IconType}[] = [ {field: 'education_level', title: 'Education', icon: AcademicCapIcon}, {field: 'political_beliefs', title: 'Politics', icon: FlagIcon}, {field: 'religion', title: 'Religion & spirituality', icon: SunIcon}, - {field: 'diet', title: 'Diet', icon: CakeIcon}, {field: 'mbti', title: 'Personality (MBTI)', icon: FingerPrintIcon}, + {field: 'diet', title: 'Diet', icon: CakeIcon}, + {field: 'orientation', title: 'Orientation', icon: HeartIcon}, + {field: 'ethnicity', title: 'Ethnicity', icon: GlobeAltIcon}, + {field: 'pref_relation_styles', title: 'Looking for', icon: MagnifyingGlassIcon}, + {field: 'relationship_status', title: 'Relationship status', icon: LinkIcon}, {field: 'languages', title: 'Languages spoken', icon: LanguageIcon}, ] -// This section absorbed what used to be the standalone "Community" block: the gender split and the -// country spread are just two more ways of answering "who's on Compass", so they sit in the same grid as -// the profile-field breakdowns rather than in a section of their own. The bare member/active/endorsement -// counts that lived there are dropped — the hero band above already carries the headline totals. +// Gender is folded to man / woman / other for the donut: three slices read at a glance where the full +// gender vocabulary would not, and "other" is exact (base − men − women) regardless of how the tail was +// truncated. Colour follows the entity, on the brand's warm ramp; the legend carries identity. +function genderSegments( + dist: Distribution | undefined, + t: ReturnType, +): DonutSegment[] { + if (!dist) return [] + const male = dist.items.find((i) => i.value === 'male')?.count ?? 0 + const female = dist.items.find((i) => i.value === 'female')?.count ?? 0 + const other = Math.max(0, dist.base - male - female) + return [ + {label: t('stats.gender.women', 'Women'), value: female, colorVar: '--color-primary-500'}, + {label: t('stats.gender.men', 'Men'), value: male, colorVar: '--color-primary-300'}, + {label: t('stats.gender.other', 'Other'), value: other, colorVar: '--color-ink-500'}, + ].sort((a, b) => b.value - a.value) +} + +// Age keeps its buckets in age order (not by size) and rides a light→dark amber ramp — an ordinal ramp +// for an ordinal field, so "older" reads as "deeper" without a legend having to say so. +const AGE_RAMP = [ + '--color-primary-200', + '--color-primary-300', + '--color-primary-400', + '--color-primary-600', +] + +function ageSegments(dist: Distribution | undefined): DonutSegment[] { + if (!dist) return [] + return dist.items.map((it, i) => ({ + label: it.value, + value: it.count, + colorVar: AGE_RAMP[Math.min(i, AGE_RAMP.length - 1)], + })) +} + function Demographics({stats}: {stats: Stats}) { const t = useT() const {demographics, countries, countryCount} = stats - const cards = DEMOGRAPHIC_CARDS.filter((c) => demographics[c.field]) + const barCards = BAR_CARDS.filter((c) => demographics[c.field]) const hasCountries = (countries?.length ?? 0) >= MIN_COUNTRIES - if (!cards.length && !hasCountries) return null + const gender = genderSegments(demographics.gender, t) + const age = ageSegments(demographics.age) + + if (!barCards.length && !hasCountries && !gender.length && !age.length) return null return (
@@ -275,26 +332,54 @@ function Demographics({stats}: {stats: Stats}) { 'Self-reported by members, shown as a share of everyone who answered. Multi-choice fields can add up to more than 100%.', )}

-
- {/* Countries is a raw-count bar list rather than a percentage split (the full population per - country isn't in the payload), so it keeps `CountrySpread` instead of a `DistributionCard` — - wrapped in the same surface so it reads as one more block in the grid. */} - {hasCountries && ( - -
- -
-
- )} - {cards.map((c, i) => ( - - + + + )} +
+ {!!gender.length && ( + + + )} + {!!age.length && ( + + + + )} +
+ + {/* Masonry bar lists. CSS columns give each card its own height. */} +
+ {barCards.map((c, i) => ( +
+ + + +
))}
@@ -314,9 +399,7 @@ export default function Stats() { const tables = [ 'profiles', 'active_members', - 'bookmarked_searches', 'private_user_message_channels', - 'profile_comments', 'compatibility_prompts', 'compatibility_answers', 'votes', @@ -324,7 +407,7 @@ export default function Stats() { ] as const const [settled, statsResult] = await Promise.allSettled([ - Promise.allSettled(tables.map((t) => getCount(t))), + Promise.allSettled(tables.map((tbl) => getCount(tbl))), api('stats', {}), ]) @@ -374,89 +457,40 @@ export default function Stats() { {/* ── Loading skeleton ── */} {loading && ( -
- {Array.from({length: 6}).map((_, i) => ( -
-
-
-
-
- ))} +
+
+ {Array.from({length: 4}).map((_, i) => ( +
+
+
+
+ ))} +
)} {!loading && ( <> - {/* ── Hero highlight band ── */} - - {/* ── Who's on Compass (gender, countries + demographic breakdowns) ── */} + {/* ── Who's on Compass (map, donuts + demographic breakdowns) ── */} {statsData && } - {/* ── Growth chart ── */} + {/* ── Growth over time ── */}
{t('stats.group.growth', 'Growth over time')}
- {/* ── Conversations ── */} -
- - - - -
- - {/* ── Compatibility ── */} -
- - - - -
- - {/* ── Democracy ── */} -
- - - - -
+ {/* ── Activity & participation ── */} + )}
diff --git a/yarn.lock b/yarn.lock index 0b070817..dea327a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6020,6 +6020,21 @@ dependencies: "@types/node" "*" +"@types/topojson-client@3.1.5": + version "3.1.5" + resolved "https://registry.yarnpkg.com/@types/topojson-client/-/topojson-client-3.1.5.tgz#3fdbcd52161db8747a071b1d0f635ac700cf599d" + integrity sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw== + dependencies: + "@types/geojson" "*" + "@types/topojson-specification" "*" + +"@types/topojson-specification@*": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/topojson-specification/-/topojson-specification-1.0.5.tgz#bf0009b2e0debb2d97237b124c00b9ea92570375" + integrity sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ== + dependencies: + "@types/geojson" "*" + "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -7598,6 +7613,11 @@ comma-separated-tokens@^2.0.0: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== +commander@2, commander@^2.19.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + commander@6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" @@ -7628,11 +7648,6 @@ commander@^13.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-13.1.0.tgz#776167db68c78f38dcce1f9b8d7b8b9a488abf46" integrity sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw== -commander@^2.19.0: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -8184,7 +8199,7 @@ csv-parse@^5.0.4: resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.6.0.tgz#219beace2a3e9f28929999d2aa417d3fb3071c7f" integrity sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q== -"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: +"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@^3.1.6: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== @@ -8206,6 +8221,13 @@ d3-ease@^3.0.1: resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== +d3-geo@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.1.tgz#6027cf51246f9b2ebd64f99e01dc7c3364033a4d" + integrity sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q== + dependencies: + d3-array "2.5.0 - 3" + "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" @@ -14736,15 +14758,20 @@ pg-cloudflare@^1.3.0: resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz#386035d4bfcf1a7045b026f8b21acf5353f14d65" integrity sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ== +pg-cloudflare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" + integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A== + pg-connection-string@^2.11.0: version "2.11.0" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.11.0.tgz#5dca53ff595df33ba9db812e181b19909866d10b" integrity sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ== -pg-connection-string@^2.12.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.12.0.tgz#4084f917902bb2daae3dc1376fe24ac7b4eaccf2" - integrity sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ== +pg-connection-string@^2.14.0: + version "2.14.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" + integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== pg-connection-string@^2.9.1: version "2.9.1" @@ -14781,10 +14808,10 @@ pg-pool@^3.11.0: resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.11.0.tgz#adf9a6651a30c839f565a3cc400110949c473d69" integrity sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w== -pg-pool@^3.13.0: - version "3.13.0" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.13.0.tgz#416482e9700e8f80c685a6ae5681697a413c13a3" - integrity sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA== +pg-pool@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" + integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== pg-promise@12.6.1: version "12.6.1" @@ -14811,10 +14838,10 @@ pg-protocol@^1.11.0: resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.11.0.tgz#2502908893edaa1e8c0feeba262dd7b40b317b53" integrity sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g== -pg-protocol@^1.13.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.13.0.tgz#fdaf6d020bca590d58bb991b4b16fc448efe0511" - integrity sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w== +pg-protocol@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.15.0.tgz#758f6c0679cc0bbf4938603b7597703f333180c0" + integrity sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ== pg-query-stream@4.12.0: version "4.12.0" @@ -14848,17 +14875,17 @@ pg@8.18.0: pg-cloudflare "^1.3.0" pg@8.x: - version "8.20.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.20.0.tgz#1a274de944cb329fd6dd77a6d371a005ba6b136d" - integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA== + version "8.22.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.22.0.tgz#55ca3975026180c6dced6eec3a20a844c2dc9237" + integrity sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA== dependencies: - pg-connection-string "^2.12.0" - pg-pool "^3.13.0" - pg-protocol "^1.13.0" + pg-connection-string "^2.14.0" + pg-pool "^3.14.0" + pg-protocol "^1.15.0" pg-types "2.2.0" pgpass "1.0.5" optionalDependencies: - pg-cloudflare "^1.3.0" + pg-cloudflare "^1.4.0" pg@^8.11.3: version "8.16.3" @@ -15044,7 +15071,16 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@8.5.6, postcss@^8.4.47: +postcss@8.5.10: + version "8.5.10" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" + integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +postcss@^8.4.47: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -15360,9 +15396,9 @@ prosemirror-menu@^1.2.4: prosemirror-state "^1.0.0" prosemirror-model@1.x, prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4: - version "1.25.4" - resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c" - integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA== + version "1.25.11" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.11.tgz#2ac01dce5176094a52cc1b889c20f3849563aba9" + integrity sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ== dependencies: orderedmap "^2.0.0" @@ -15794,9 +15830,9 @@ react-icons@5.5.0: integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw== react-is@^16.13.1, react-is@^17.0.1, react-is@^18.0.0, react-is@^19.0.0: - version "19.2.4" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.4.tgz#a080758243c572ccd4a63386537654298c99d135" - integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA== + version "19.2.8" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.8.tgz#09826f9fbc187bc668e3e5c62edc001f804d5018" + integrity sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ== react-markdown@10.1.0: version "10.1.0" @@ -17762,6 +17798,13 @@ toidentifier@1.0.1, toidentifier@~1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +topojson-client@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99" + integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw== + dependencies: + commander "2" + touch@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" @@ -18770,6 +18813,11 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== +world-atlas@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/world-atlas/-/world-atlas-2.0.2.tgz#4bca4a578f3a413bd409c3f07f2054fe7375824f" + integrity sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ== + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"