feat(stats): add world map markers
This commit is contained in:
parent
ba2455ecc7
commit
621796d8ce
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user