Files
minecraft-account-manager/apps/web/src/components/user-world-map.tsx
T
dmg 24808b0f8c
CI / validate (push) Successful in 5m39s
Release / release (push) Successful in 7m15s
fix(dashboard): show enriched network details
2026-08-02 08:05:57 -04:00

118 lines
8.4 KiB
TypeScript

import type { FeatureCollection } from "geojson";
import type { GeometryCollection, Topology } from "topojson-specification";
import { geoEquirectangular, geoPath } from "d3-geo";
import { feature } from "topojson-client";
import countriesTopologyJson from "world-atlas/countries-110m.json";
import Link from "next/link";
import { groupMapLocations } from "@/lib/user-location-map";
import { MapViewToggle } from "./map-view-toggle";
const WIDTH = 1_000;
const HEIGHT = 500;
const topology = countriesTopologyJson as unknown as Topology<{ countries: GeometryCollection }>;
const countries = feature(topology, topology.objects.countries) as FeatureCollection;
const projection = geoEquirectangular().fitExtent([[1, 1], [WIDTH - 1, HEIGHT - 1]], { type: "Sphere" });
const countryPath = geoPath(projection);
export interface UserMapLocation {
userId: string;
name: string;
discordUsername: string;
nickname: string;
latitude: number;
longitude: number;
location: string;
classification: string;
networkProvider: string | null;
networkAsn: string | null;
connectionType: string | null;
proxy: boolean | null;
source: string;
observedAt: Date;
}
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
const locationGroups = groupMapLocations(locations);
return (
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
</div>
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
</div>
<MapViewToggle locations={locations}>
<div className="mt-3 overflow-hidden border border-line bg-[#b9d4d1]">
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
<title id="user-world-map-title">Latest approximate location for registered users</title>
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
{countries.features.map((country, index) => {
const path = countryPath(country);
return path ? <path d={path} key={country.id ?? index} /> : null;
})}
</g>
<g>
{locationGroups.map((group) => {
const projected = projection([group.longitude, group.latitude]);
if (!projected) return null;
const x = Math.min(WIDTH - 16, Math.max(16, projected[0]));
const y = Math.min(HEIGHT - 16, Math.max(16, projected[1]));
const markerRadius = group.count > 1 ? 13 : 7;
const longestNickname = Math.max(...group.nicknames.map((nickname) => nickname.length));
const tooltipColumns = Math.ceil(group.nicknames.length / 10);
const tooltipRows = Math.ceil(group.nicknames.length / tooltipColumns);
const tooltipWidth = Math.min(WIDTH - 8, Math.max(110, longestNickname * 8 + 24) * tooltipColumns);
const tooltipColumnWidth = tooltipWidth / tooltipColumns;
const tooltipHeight = tooltipRows * 18 + 10;
const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2));
const preferredTooltipY = y > tooltipHeight + 18 ? y - tooltipHeight - 12 : y + 18;
const tooltipY = Math.min(HEIGHT - tooltipHeight - 4, Math.max(4, preferredTooltipY));
const firstUser = group.locations[0]!;
const label = group.count === 1
? `${firstUser.nickname}, ${firstUser.location}`
: `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`;
return (
<a aria-label={label} className="map-marker-link" href={group.count === 1 ? `/admin/users/${firstUser.userId}` : "#map-location-list"} key={group.key}>
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
<title>{label}</title>
</circle>
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r={markerRadius} stroke="var(--panel)" strokeWidth="3" />
{group.count > 1 && <text aria-hidden="true" dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" fontWeight="700" pointerEvents="none" textAnchor="middle" x={x} y={y}>{group.count}</text>}
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
<rect fill="var(--ink)" height={tooltipHeight} rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
{group.nicknames.map((nickname, index) => {
const column = Math.floor(index / tooltipRows);
const row = index % tooltipRows;
return <text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" key={`${nickname}-${index}`} textAnchor="middle" x={tooltipX + tooltipColumnWidth * (column + 0.5)} y={tooltipY + 14 + row * 18}>{nickname}</text>;
})}
</g>
</a>
);
})}
</g>
</svg>
</div>
</MapViewToggle>
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
<details className="mt-5 border-t border-line pt-4" id="map-location-list" open={locationGroups.some((group) => group.count > 1)}>
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
<caption className="sr-only">Latest approximate registered-user locations and enriched network details</caption>
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Connection</th><th className="p-3" scope="col">Proxy/VPN</th><th className="p-3" scope="col">Risk</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
<tbody className="divide-y divide-line">
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3"><span className="block">{user.networkProvider ?? "Unknown"}</span>{user.networkAsn && <span className="mt-1 block font-mono text-[9px] text-muted">{user.networkAsn}</span>}</td><td className="p-3">{user.connectionType ?? "Unknown"}</td><td className="p-3 font-mono font-bold uppercase">{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
{!locations.length && <tr><td className="py-6 text-muted" colSpan={8}>No user observations currently include valid coordinates.</td></tr>}
</tbody>
</table>
</div>
</details>
</section>
);
}