feat(stats): add world map markers
This commit is contained in:
@@ -5,7 +5,7 @@ import { geoEquirectangular, geoPath } from "d3-geo"
|
|||||||
import { scaleSqrt } from "d3-scale"
|
import { scaleSqrt } from "d3-scale"
|
||||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
||||||
import { feature, mesh } from "topojson-client"
|
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 {
|
import {
|
||||||
getStatsModelData,
|
getStatsModelData,
|
||||||
type CountryEntry,
|
type CountryEntry,
|
||||||
@@ -89,6 +89,7 @@ const worldPath = geoPath(worldProjection)
|
|||||||
const worldCountryPaths = worldCountries.features.map((country) => ({
|
const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||||
id: String(country.id ?? "").padStart(3, "0"),
|
id: String(country.id ?? "").padStart(3, "0"),
|
||||||
path: worldPath(country) ?? "",
|
path: worldPath(country) ?? "",
|
||||||
|
marker: geoCountryMarker(country),
|
||||||
}))
|
}))
|
||||||
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||||
|
|
||||||
@@ -587,6 +588,7 @@ function GeoWorldMap(props: {
|
|||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
d={country.path}
|
d={country.path}
|
||||||
|
data-country-id={country.id}
|
||||||
data-has-data={entry() ? "true" : undefined}
|
data-has-data={entry() ? "true" : undefined}
|
||||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||||
@@ -606,6 +608,37 @@ function GeoWorldMap(props: {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</g>
|
</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" />
|
<path data-slot="geo-borders" d={worldBorderPath} aria-hidden="true" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
@@ -734,6 +767,14 @@ function countryNumericId(country: string) {
|
|||||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
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) {
|
function formatCountryName(country: string) {
|
||||||
const code = country.toUpperCase()
|
const code = country.toUpperCase()
|
||||||
if (code === "ZZ") return "Unknown"
|
if (code === "ZZ") return "Unknown"
|
||||||
|
|||||||
@@ -2156,6 +2156,23 @@
|
|||||||
opacity: 1;
|
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"] {
|
[data-page="stats"] [data-slot="geo-borders"] {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--stats-line-strong);
|
stroke: var(--stats-line-strong);
|
||||||
@@ -2249,7 +2266,7 @@
|
|||||||
background: var(--stats-layer);
|
background: var(--stats-layer);
|
||||||
color: var(--stats-muted);
|
color: var(--stats-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1;
|
line-height: 1.25;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition:
|
transition:
|
||||||
border-color 120ms ease,
|
border-color 120ms ease,
|
||||||
@@ -2286,6 +2303,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--stats-text);
|
color: var(--stats-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { geoEquirectangular, geoPath } from "d3-geo"
|
|||||||
import { scaleSqrt } from "d3-scale"
|
import { scaleSqrt } from "d3-scale"
|
||||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
||||||
import { feature, mesh } from "topojson-client"
|
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 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 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"
|
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) => ({
|
const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||||
id: String(country.id ?? "").padStart(3, "0"),
|
id: String(country.id ?? "").padStart(3, "0"),
|
||||||
path: worldPath(country) ?? "",
|
path: worldPath(country) ?? "",
|
||||||
|
marker: geoCountryMarker(country),
|
||||||
}))
|
}))
|
||||||
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||||
|
|
||||||
@@ -1287,6 +1288,7 @@ function GeoWorldMap(props: {
|
|||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
d={country.path}
|
d={country.path}
|
||||||
|
data-country-id={country.id}
|
||||||
data-has-data={entry() ? "true" : undefined}
|
data-has-data={entry() ? "true" : undefined}
|
||||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||||
@@ -1306,6 +1308,37 @@ function GeoWorldMap(props: {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</g>
|
</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" />
|
<path data-slot="geo-borders" d={worldBorderPath} aria-hidden="true" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
@@ -1352,6 +1385,14 @@ function countryNumericId(country: string) {
|
|||||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
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) {
|
function formatCountryName(country: string) {
|
||||||
const code = country.toUpperCase()
|
const code = country.toUpperCase()
|
||||||
if (code === "ZZ") return "Unknown"
|
if (code === "ZZ") return "Unknown"
|
||||||
|
|||||||
Reference in New Issue
Block a user