85 lines
5.4 KiB
TypeScript
85 lines
5.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";
|
|
|
|
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;
|
|
latitude: number;
|
|
longitude: number;
|
|
location: string;
|
|
classification: string;
|
|
source: string;
|
|
observedAt: Date;
|
|
}
|
|
|
|
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
|
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>
|
|
|
|
<div className="mt-6 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>
|
|
{locations.map((user) => {
|
|
const projected = projection([user.longitude, user.latitude]);
|
|
if (!projected) return null;
|
|
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
|
|
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
|
|
return (
|
|
<a aria-label={`${user.name}, ${user.location}, last seen ${user.observedAt.toISOString()}`} href={`/admin/users/${user.userId}`} key={user.userId}>
|
|
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke" />
|
|
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3">
|
|
<title>{user.name} · {user.location} · {user.classification}</title>
|
|
</circle>
|
|
</a>
|
|
);
|
|
})}
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
<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">
|
|
<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-[680px] border-collapse text-left text-xs">
|
|
<caption className="sr-only">Latest approximate registered-user locations</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">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.name}</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 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={5}>No user observations currently include valid coordinates.</td></tr>}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
);
|
|
}
|