Add StatDonut, WorldHeatmap, and support for full country lists on stats page

- Introduced `StatDonut` and `WorldHeatmap` components for improved data visualization.
- Enhanced API to provide full country lists instead of a top-N subset for accurate map shading.
- Removed hardcoded country truncation logic in favor of dynamic slicing in the frontend.
- Updated age grouping logic to consolidate older age ranges and simplify backend queries.
- Refactored stats page layout to reduce redundancy and improve clarity of data presentation.
This commit is contained in:
MartinBraquet
2026-07-24 02:57:51 +02:00
parent 5645007ff8
commit 95d217fb61
9 changed files with 826 additions and 303 deletions

View File

@@ -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<Distribution |
when age < 25 then '1824'
when age < 35 then '2534'
when age < 45 then '3544'
when age < 55 then '4554'
else '55+'
else '45+'
end as bucket,
case
when age < 25 then 1
when age < 35 then 2
when age < 45 then 3
when age < 55 then 4
else 5
else 4
end as ord
from profiles
where age is not null and age >= 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,
}

View File

@@ -52,9 +52,10 @@ export type Stats = {
conversations: number
genderRatio: Record<string, number>
genderCounts: Record<string, number>
/** 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

View File

@@ -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 (
<div>
@@ -425,8 +426,8 @@ export default function ChartMembers() {
<stop offset="95%" stopColor={AMBER} stopOpacity={0} />
</linearGradient>
<linearGradient id="gradSage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={SAGE} stopOpacity={0.14} />
<stop offset="95%" stopColor={SAGE} stopOpacity={0} />
<stop offset="5%" stopColor={NEUTRAL} stopOpacity={0.14} />
<stop offset="95%" stopColor={NEUTRAL} stopOpacity={0} />
</linearGradient>
</defs>
@@ -459,17 +460,17 @@ export default function ChartMembers() {
<Legend content={<CustomLegend />} />
{/* Completed (sage, behind) */}
{/* Completed (warm neutral, behind) */}
<Area
type="monotone"
dataKey="profilesCompletedCreations"
name={t('stats.with_bio', 'Completed')}
stroke={SAGE}
stroke={NEUTRAL}
strokeWidth={2}
strokeDasharray="5 3"
fill="url(#gradSage)"
dot={false}
activeDot={{r: 4, fill: SAGE, stroke: 'rgb(247 244 239)', strokeWidth: 2}}
activeDot={{r: 4, fill: NEUTRAL, stroke: 'rgb(247 244 239)', strokeWidth: 2}}
/>
{/* Total (amber, on top) */}

View File

@@ -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<SVGProps<SVGSVGElement>>
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 (
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
<Icon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold leading-tight text-ink-900">{title}</h3>
<p className="text-xs text-ink-500">{subtitle}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative h-[128px] w-[128px] flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={44}
outerRadius={63}
startAngle={90}
endAngle={-270}
paddingAngle={data.length > 1 ? 2 : 0}
stroke={surfaceColor ?? undefined}
strokeWidth={2}
isAnimationActive={false}
>
{data.map((_, i) => (
<Cell key={i} fill={colors[i] ?? 'transparent'} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<ul className="min-w-0 flex-1 space-y-2">
{data.map((s, i) => (
<li key={s.label} className="flex items-center gap-2.5">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{background: colors[i] ?? 'transparent'}}
/>
<span className="min-w-0 flex-1 truncate text-[13px] text-ink-700" title={s.label}>
{s.label}
</span>
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-900">
{Math.round((s.value / total) * 100)}%
</span>
</li>
))}
</ul>
</div>
</div>
)
}
const SURFACE_VAR = ['--color-canvas-50']

View File

@@ -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<Geometry, {name: string}>
// 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<string, string> = {
'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<string, number>()
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 (
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
<div className="mb-1 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-bold leading-tight text-ink-900">
{t('stats.countries.title', 'Where members are')}
</h3>
<p className="text-xs text-ink-500">
{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.')}
</p>
</div>
{/* Compact sequential legend: light = few, dark = many. */}
<div className="hidden items-center gap-1.5 sm:flex">
<span className="text-[10px] font-medium uppercase tracking-wide text-ink-500">
Fewer
</span>
<div className="flex overflow-hidden rounded-full ring-1 ring-canvas-200">
{RAMP.map((v) => (
<span key={v} className="h-2.5 w-4" style={{background: `rgb(var(${v}))`}} />
))}
</div>
<span className="text-[10px] font-medium uppercase tracking-wide text-ink-500">More</span>
</div>
</div>
{/* 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. */}
<div className="relative -mx-5 mt-2 w-auto sm:-mx-6">
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="h-auto w-full"
role="img"
aria-label={t('stats.countries.title', 'Where members are')}
>
{SHAPES.map((s) => {
const count = countByShape.get(s.name) ?? 0
return (
<path
key={s.name}
d={s.d}
style={{fill: `rgb(var(${rampVar(count, max)}))`}}
stroke="rgb(var(--color-canvas-50))"
strokeWidth={0.5}
className={clsx(count > 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}
/>
)
})}
</svg>
{hover && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-lg bg-canvas-950 px-2.5 py-1.5 text-center shadow-lg"
style={{left: `${hover.x}%`, top: `${hover.y}%`}}
>
<div className="text-xs font-bold leading-tight text-white">{hover.name}</div>
<div className="text-[11px] font-semibold tabular-nums text-primary-400">
{hover.count === 1
? t('stats.countries.member_one', '1 member')
: t('stats.countries.members', '{count} members', {count: hover.count})}
</div>
</div>
)}
</div>
{/* 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 && (
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2 border-t border-canvas-200/70 pt-4">
{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 (
<span key={r.country} className="inline-flex items-center gap-1.5 text-[13px]">
<span
className="h-2.5 w-2.5 rounded-[3px] ring-1 ring-inset ring-black/5"
style={{background: `rgb(var(${fillVar}))`}}
/>
<span className="text-ink-700">{r.country}</span>
<span className="font-bold tabular-nums text-ink-900">
{r.count.toLocaleString()}
</span>
</span>
)
})}
{remaining > 0 && (
<span className="text-[13px] text-ink-500">
{t('stats.countries.remaining', '+ {count} more countries', {count: remaining})}
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -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 `<html>`.
*
* 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 <html> 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
}

View File

@@ -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"
}
}

View File

@@ -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: () => (
<div className={clsx(surface, 'h-full min-h-[320px] animate-pulse p-6')}>
<div className="h-4 w-40 rounded bg-canvas-200" />
<div className="mt-6 h-[240px] rounded-xl bg-canvas-100" />
</div>
),
},
)
// ─── Types ────────────────────────────────────────────────────────────────────
type IconType = ComponentType<SVGProps<SVGSVGElement>>
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 (
<div className={clsx(surface, surfaceHover, 'group p-5 sm:p-6')}>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
<Icon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<div className="relative overflow-hidden rounded-3xl bg-canvas-950 px-6 py-8 sm:px-10 sm:py-10">
<div
className={clsx(
'mb-2 text-3xl font-black leading-none tracking-tight',
accentClasses[accent],
)}
>
{formatted}
aria-hidden
className="pointer-events-none absolute -right-16 -top-20 h-64 w-64 rounded-full bg-primary-500/20 blur-3xl"
/>
<div className="relative flex flex-wrap gap-x-10 gap-y-7 sm:gap-x-14">
{items.map((item, i) => (
<div
key={item.label}
className={clsx(
i === 0 && 'sm:border-r sm:border-white/10 sm:pr-14',
i > 0 && 'min-w-[92px]',
)}
>
<div
className={clsx(
'font-black leading-none tracking-tight tabular-nums text-primary-400',
i === 0 ? 'text-5xl sm:text-6xl' : 'text-3xl sm:text-4xl',
)}
>
{typeof item.value === 'number' ? formatNumber(item.value) : item.value}
</div>
<div className={clsx(eyebrow, 'mt-2.5 text-white/55')}>{item.label}</div>
</div>
))}
</div>
<p className="text-xs font-semibold uppercase leading-tight tracking-wide text-ink-500">
{label}
</p>
</div>
)
}
// ─── Stat Group ───────────────────────────────────────────────────────────────
function StatGroup({title, aside, children}: StatGroupProps) {
return (
<>
<SectionLabel>{title}</SectionLabel>
{/* 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. */}
<div className={clsx('grid gap-3 sm:gap-4', aside && 'items-start lg:grid-cols-2')}>
<div
className={clsx(
'grid grid-cols-2 gap-3 sm:gap-4',
aside ? 'md:grid-cols-2' : 'md:grid-cols-3 lg:grid-cols-4',
)}
>
{children}
</div>
{aside}
</div>
</>
)
}
// ─── 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 (
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
<div className="mb-4 flex items-center gap-2.5">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
<Icon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
</div>
<h3 className="text-sm font-bold text-ink-900">{title}</h3>
</div>
<dl className="space-y-3">
{shown.map((r) => (
<div
key={r.label}
className="flex items-baseline justify-between gap-3 border-t border-canvas-200/70 pt-3 first:border-0 first:pt-0"
>
<dt className="text-sm text-ink-600">{r.label}</dt>
<dd className="text-lg font-black tabular-nums text-primary-600">
{formatNumber(r.value as number)}
</dd>
</div>
))}
</dl>
</div>
)
}
/**
* 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<string, number | null>
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 (
<div
className={clsx(
'grid grid-cols-2 gap-3 sm:gap-4',
items.length >= 4 ? 'lg:grid-cols-4' : 'sm:grid-cols-3',
)}
>
{items.map((item, i) => {
const Icon = item.icon
return (
<Reveal key={item.label} delay={i * 60}>
<div className="relative h-full overflow-hidden rounded-2xl border-[1.5px] border-canvas-900 bg-canvas-950 p-5 sm:p-6">
<div
aria-hidden
className="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-primary-500/10 blur-2xl"
/>
<div className="relative mb-4 flex h-9 w-9 items-center justify-center rounded-lg bg-canvas-900">
<Icon className="h-[18px] w-[18px] text-primary-400" strokeWidth={1.8} />
</div>
<div
className={clsx(
'relative mb-2 text-3xl font-black leading-none tracking-tight sm:text-4xl',
item.accent === 'sage' ? 'text-green-500' : 'text-primary-400',
)}
>
{typeof item.value === 'number' ? formatNumber(item.value) : item.value}
</div>
<p className="relative text-xs font-semibold uppercase tracking-wide text-white/60">
{item.label}
</p>
</div>
<Section>
<SectionLabel>{t('stats.group.activity', 'Activity & participation')}</SectionLabel>
<div
className={clsx(
'grid grid-cols-1 gap-3 sm:gap-4',
panels.length >= 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2',
)}
>
{panels.map((p, i) => (
<Reveal key={p.title} delay={i * 70}>
<ActivityPanel icon={p.icon} title={p.title} rows={p.rows} />
</Reveal>
)
})}
</div>
))}
</div>
</Section>
)
}
// ─── 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<typeof useT>,
): 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 (
<Section>
@@ -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%.',
)}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3">
{/* 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 && (
<Reveal>
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
<CountrySpread countries={countries} countryCount={countryCount} />
</div>
</Reveal>
)}
{cards.map((c, i) => (
<Reveal key={c.field} delay={((i + (hasCountries ? 1 : 0)) % 3) * 70}>
<DistributionCard
field={c.field}
title={t(`stats.demographics.field.${c.field}`, c.title)}
icon={c.icon}
dist={demographics[c.field]}
{/* Feature block: a full-width map, then the two donuts side by side — three different marks
before the bar lists, so the section never opens on a grid of identical cards. */}
{hasCountries && (
<Reveal>
<WorldHeatmap countries={countries} countryCount={countryCount} />
</Reveal>
)}
<div className={clsx('grid gap-3 sm:grid-cols-2 sm:gap-4', hasCountries && 'mt-3 sm:mt-4')}>
{!!gender.length && (
<Reveal delay={80}>
<StatDonut
title={t('stats.demographics.field.gender', 'Gender')}
subtitle={t('stats.donut.shared', '{count} shared', {
count: demographics.gender?.base ?? 0,
})}
icon={UserGroupIcon}
segments={gender}
/>
</Reveal>
)}
{!!age.length && (
<Reveal delay={140}>
<StatDonut
title={t('stats.demographics.field.age', 'Age')}
subtitle={t('stats.donut.shared', '{count} shared', {
count: demographics.age?.base ?? 0,
})}
icon={CalendarDaysIcon}
segments={age}
/>
</Reveal>
)}
</div>
{/* Masonry bar lists. CSS columns give each card its own height. */}
<div className="mt-3 gap-3 [column-fill:_balance] sm:mt-4 sm:gap-4 sm:columns-2 lg:columns-3">
{barCards.map((c, i) => (
<div key={c.field} className="mb-3 break-inside-avoid sm:mb-4">
<Reveal delay={(i % 3) * 60}>
<DistributionCard
field={c.field}
title={t(`stats.demographics.field.${c.field}`, c.title)}
icon={c.icon}
dist={demographics[c.field]}
/>
</Reveal>
</div>
))}
</div>
</Section>
@@ -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 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
{Array.from({length: 6}).map((_, i) => (
<div key={i} className={clsx(surface, 'animate-pulse p-6')}>
<div className="mb-4 h-9 w-9 rounded-lg bg-canvas-200" />
<div className="mb-2 h-8 w-20 rounded bg-canvas-200" />
<div className="h-3 w-24 rounded bg-canvas-200" />
</div>
))}
<div className="rounded-3xl bg-canvas-950 px-6 py-8 sm:px-10 sm:py-10">
<div className="flex flex-wrap gap-x-14 gap-y-7">
{Array.from({length: 4}).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-10 w-24 rounded bg-white/10" />
<div className="mt-3 h-3 w-16 rounded bg-white/10" />
</div>
))}
</div>
</div>
)}
{!loading && (
<>
{/* ── Hero highlight band ── */}
<HighlightRow
{/* ── Hero band ── */}
<HeroBand
members={data.profiles}
active={data.active_members}
messages={statsData?.messages}
countries={statsData?.countryCount}
discussions={data.private_user_message_channels}
messages={statsData?.messages}
/>
{/* ── Who's on Compass (gender, countries + demographic breakdowns) ── */}
{/* ── Who's on Compass (map, donuts + demographic breakdowns) ── */}
{statsData && <Demographics stats={statsData} />}
{/* ── Growth chart ── */}
{/* ── Growth over time ── */}
<Section>
<SectionLabel>{t('stats.group.growth', 'Growth over time')}</SectionLabel>
<ChartCard />
</Section>
{/* ── Conversations ── */}
<Section>
<StatGroup title={t('stats.group.conversations', 'Conversations')}>
<StatCard
value={data.private_user_message_channels}
label={t('stats.discussions', 'Discussions')}
icon={ChatBubbleOvalLeftIcon}
accent="amber"
/>
<StatCard
value={statsData?.messages}
label={t('stats.messages', 'Messages')}
icon={EnvelopeIcon}
accent="sage"
/>
</StatGroup>
</Section>
{/* ── Compatibility ── */}
<Section>
<StatGroup title={t('stats.group.compatibility', 'Compatibility')}>
<StatCard
value={data.compatibility_prompts}
label={t('stats.compatibility_prompts', 'Prompts Created')}
icon={QuestionMarkCircleIcon}
accent="amber"
/>
<StatCard
value={data.compatibility_answers}
label={t('stats.prompts_answered', 'Prompts Answered')}
icon={CheckCircleIcon}
accent="sage"
/>
</StatGroup>
</Section>
{/* ── Democracy ── */}
<Section>
<StatGroup title={t('stats.group.democracy', 'Democracy')}>
<StatCard
value={data.votes}
label={t('stats.proposals', 'Proposals')}
icon={ClipboardDocumentListIcon}
accent="amber"
/>
<StatCard
value={data.vote_results}
label={t('stats.votes', 'Votes Cast')}
icon={HandRaisedIcon}
accent="sage"
/>
</StatGroup>
</Section>
{/* ── Activity & participation ── */}
<ActivitySection data={data} messages={statsData?.messages} />
</>
)}
</div>

112
yarn.lock
View File

@@ -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"