ソースを参照

feat(stats): add world map markers

Adam 2 ヶ月 前
コミット
621796d8ce

+ 42 - 1
packages/stats/app/src/routes/[lab]/[model].tsx

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

+ 19 - 1
packages/stats/app/src/routes/index.css

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

+ 42 - 1
packages/stats/app/src/routes/index.tsx

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