feat(stats): add world map markers

This commit is contained in:
Adam 2026-06-12 13:19:50 -05:00
parent ba2455ecc7
commit 621796d8ce
No known key found for this signature in database
GPG Key ID: 9CB48779AF150E75
3 changed files with 103 additions and 3 deletions

View File

@ -5,7 +5,7 @@ import { geoEquirectangular, geoPath } from "d3-geo"
import { scaleSqrt } from "d3-scale"
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
import { feature, mesh } from "topojson-client"
import countriesTopologySource from "world-atlas/countries-110m.json?raw"
import countriesTopologySource from "world-atlas/countries-50m.json?raw"
import {
getStatsModelData,
type CountryEntry,
@ -89,6 +89,7 @@ const worldPath = geoPath(worldProjection)
const worldCountryPaths = worldCountries.features.map((country) => ({
id: String(country.id ?? "").padStart(3, "0"),
path: worldPath(country) ?? "",
marker: geoCountryMarker(country),
}))
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
@ -587,6 +588,7 @@ function GeoWorldMap(props: {
return (
<path
d={country.path}
data-country-id={country.id}
data-has-data={entry() ? "true" : undefined}
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
@ -606,6 +608,37 @@ function GeoWorldMap(props: {
}}
</For>
</g>
<g data-slot="geo-country-markers">
<For each={worldCountryPaths}>
{(country) => {
const entry = () => props.countryById.get(country.id)
return (
<Show when={country.marker && entry() ? country.marker : undefined}>
{(marker) => (
<circle
cx={marker().x}
cy={marker().y}
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
aria-hidden="true"
onPointerEnter={() => {
const item = entry()
if (!item) return
props.onActiveCountryChange(item.country)
}}
onClick={() => {
const item = entry()
if (!item) return
props.onActiveCountryChange(item.country)
}}
/>
)}
</Show>
)
}}
</For>
</g>
<path data-slot="geo-borders" d={worldBorderPath} aria-hidden="true" />
</svg>
)
@ -734,6 +767,14 @@ function countryNumericId(country: string) {
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
}
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
const bounds = worldPath.bounds(country)
const [x, y] = worldPath.centroid(country)
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
return { x, y }
}
function formatCountryName(country: string) {
const code = country.toUpperCase()
if (code === "ZZ") return "Unknown"

View File

@ -2156,6 +2156,23 @@
opacity: 1;
}
[data-page="stats"] [data-slot="geo-country-markers"] circle {
fill: var(--stats-accent);
stroke: var(--stats-bg);
stroke-width: 1.1px;
opacity: var(--geo-country-opacity);
cursor: pointer;
transition:
fill 140ms ease,
opacity 140ms ease,
r 140ms ease;
}
[data-page="stats"] [data-slot="geo-country-markers"] circle[data-active="true"] {
fill: color-mix(in srgb, var(--stats-accent) 70%, var(--stats-text));
opacity: 1;
}
[data-page="stats"] [data-slot="geo-borders"] {
fill: none;
stroke: var(--stats-line-strong);
@ -2249,7 +2266,7 @@
background: var(--stats-layer);
color: var(--stats-muted);
font-size: 11px;
line-height: 1;
line-height: 1.25;
text-align: left;
transition:
border-color 120ms ease,
@ -2286,6 +2303,7 @@
overflow: hidden;
color: var(--stats-text);
font-weight: 600;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -5,7 +5,7 @@ import { geoEquirectangular, geoPath } from "d3-geo"
import { scaleSqrt } from "d3-scale"
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
import { feature, mesh } from "topojson-client"
import countriesTopologySource from "world-atlas/countries-110m.json?raw"
import countriesTopologySource from "world-atlas/countries-50m.json?raw"
import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url"
import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url"
import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url"
@ -102,6 +102,7 @@ const worldPath = geoPath(worldProjection)
const worldCountryPaths = worldCountries.features.map((country) => ({
id: String(country.id ?? "").padStart(3, "0"),
path: worldPath(country) ?? "",
marker: geoCountryMarker(country),
}))
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
@ -1287,6 +1288,7 @@ function GeoWorldMap(props: {
return (
<path
d={country.path}
data-country-id={country.id}
data-has-data={entry() ? "true" : undefined}
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
@ -1306,6 +1308,37 @@ function GeoWorldMap(props: {
}}
</For>
</g>
<g data-slot="geo-country-markers">
<For each={worldCountryPaths}>
{(country) => {
const entry = () => props.countryById.get(country.id)
return (
<Show when={country.marker && entry() ? country.marker : undefined}>
{(marker) => (
<circle
cx={marker().x}
cy={marker().y}
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
aria-hidden="true"
onPointerEnter={() => {
const item = entry()
if (!item) return
props.onActiveCountryChange(item.country)
}}
onClick={() => {
const item = entry()
if (!item) return
props.onActiveCountryChange(item.country)
}}
/>
)}
</Show>
)
}}
</For>
</g>
<path data-slot="geo-borders" d={worldBorderPath} aria-hidden="true" />
</svg>
)
@ -1352,6 +1385,14 @@ function countryNumericId(country: string) {
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
}
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
const bounds = worldPath.bounds(country)
const [x, y] = worldPath.centroid(country)
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
return { x, y }
}
function formatCountryName(country: string) {
const code = country.toUpperCase()
if (code === "ZZ") return "Unknown"