feat(dashboard): map latest user locations
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 10m40s

This commit is contained in:
dmg
2026-08-01 19:41:03 -04:00
parent b7c0083647
commit 9116107917
14 changed files with 363 additions and 8 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ The token is displayed once and stored only as a SHA-256 hash.
- PostgreSQL and Drizzle ORM
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
- Admin user search, account management, event exploration, operational metrics, and automatic Discord nickname synchronization
- Admin user search, account management, event exploration, operational metrics, an open-data user-location world map, and automatic Discord nickname synchronization
- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access setting applies
- Deployment-managed Discord guild ID and invite URL
- discord.js bot with `/register` and `/account`
+6 -1
View File
@@ -17,17 +17,22 @@
"@minecraft-account-manager/logging": "*",
"@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*",
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"react": "^19.2.3",
"react-dom": "^19.2.3"
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/d3-geo": "^3.1.1",
"@types/node": "^25.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1",
+41 -3
View File
@@ -1,8 +1,10 @@
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import Link from "next/link";
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
import { db } from "@/lib/database";
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
import { parseUserLocation } from "@/lib/user-location-map";
export const dynamic = "force-dynamic";
@@ -13,7 +15,7 @@ export default async function AdminDashboardPage() {
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
const [registrationRows, [totals], [monthlyActive], riskyActivity, [recentDenials]] = await Promise.all([
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
db
.select({
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
@@ -31,6 +33,25 @@ export default async function AdminDashboardPage() {
gte(ipObservations.observedAt, thirtyDaysAgo),
isNotNull(ipObservations.userId),
)),
db
.selectDistinctOn([ipObservations.userId], {
userId: ipObservations.userId,
name: users.firstName,
discordUsername: users.discordUsername,
classification: ipObservations.classification,
source: ipObservations.source,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.innerJoin(users, eq(users.id, ipObservations.userId))
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(and(
isNotNull(ipObservations.userId),
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.select({
id: ipObservations.id,
@@ -54,6 +75,21 @@ export default async function AdminDashboardPage() {
)),
]);
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
const locations = locationRows.flatMap((row): UserMapLocation[] => {
const parsed = parseUserLocation(row.intelligence);
if (!parsed || !row.userId) return [];
return [{
userId: row.userId,
name: row.name ?? row.discordUsername,
discordUsername: row.discordUsername,
latitude: parsed.latitude,
longitude: parsed.longitude,
location: parsed.label,
classification: row.classification,
source: row.source,
observedAt: row.observedAt,
}];
});
return (
<main className="mx-auto max-w-6xl px-6 py-14">
@@ -63,7 +99,9 @@ export default async function AdminDashboardPage() {
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
</header>
<section aria-label="Key metrics" className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
+6
View File
@@ -58,6 +58,12 @@ body {
transform: translateY(0);
}
svg a:hover .map-marker,
svg a:focus .map-marker {
stroke: var(--ink);
stroke-width: 6px;
}
::selection {
background: var(--accent);
color: var(--panel);
@@ -0,0 +1,39 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UserWorldMap } from "./user-world-map";
describe("UserWorldMap", () => {
it("renders an accessible linked marker, text fallback, and open-data attribution", () => {
const markup = renderToStaticMarkup(<UserWorldMap locations={[{
userId: "11111111-1111-4111-8111-111111111111",
name: "Dani",
discordUsername: "dani",
latitude: 37.4056,
longitude: -122.0775,
location: "Mountain View, California, US",
classification: "clear",
source: "game",
observedAt: new Date("2026-08-01T12:00:00Z"),
}]} unavailableCount={2} />);
expect(markup).toContain('role="group"');
expect(markup).toContain('class="map-marker-target"');
expect(markup).toContain("Latest approximate location for registered users");
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
expect(markup).toContain("Mountain View, California, US");
expect(markup).toContain("Natural Earth, public domain");
expect(markup).toContain("2 without coordinates");
const countryPaths = [...markup.matchAll(/<path d="([^"]+)"/g)].map((match) => match[1] ?? "");
expect(countryPaths.length).toBeGreaterThan(100);
for (const path of countryPaths) {
const subpaths = path.split("M").slice(1);
for (const subpath of subpaths) {
const xCoordinates = [...subpath.matchAll(/(?:^|L)(-?\d+(?:\.\d+)?),/g)].map((match) => Number(match[1]));
for (let index = 1; index < xCoordinates.length; index += 1) {
expect(Math.abs(xCoordinates[index]! - xCoordinates[index - 1]!)).toBeLessThan(500);
}
}
}
});
});
@@ -0,0 +1,84 @@
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>
);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
describe("user location map", () => {
it("extracts a valid approximate location from cached IP intelligence", () => {
expect(parseUserLocation({
classification: "clear",
location: {
city: "Mountain View",
region: "California",
countryCode: "US",
latitude: 37.4056,
longitude: -122.0775,
},
})).toEqual({
latitude: 37.4056,
longitude: -122.0775,
label: "Mountain View, California, US",
});
});
it("rejects missing and out-of-range coordinates", () => {
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
});
it("projects longitude and latitude into an equirectangular SVG", () => {
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
});
});
+41
View File
@@ -0,0 +1,41 @@
type UnknownMap = Record<string, unknown>;
function objectValue(value: unknown): UnknownMap | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as UnknownMap
: null;
}
function coordinate(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
return null;
}
export interface ParsedUserLocation {
latitude: number;
longitude: number;
label: string;
}
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
const intelligence = objectValue(value);
const location = objectValue(intelligence?.location);
if (!location) return null;
const latitude = coordinate(location.latitude);
const longitude = coordinate(location.longitude);
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
const label = [location.city, location.region, location.countryCode ?? location.country]
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
.join(", ");
return { latitude, longitude, label: label || "Approximate location unavailable" };
}
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
return {
x: ((longitude + 180) / 360) * width,
y: ((90 - latitude) / 180) * height,
};
}
+1
View File
@@ -2,6 +2,7 @@
## 2026-08-01
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
+7 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Monitor community account activity
description: Administrators use a server-rendered dashboard to review registrations, monthly activity, denials, and risky networks.
tags: [admin, dashboard, metrics, security, ssr]
timestamp: 2026-08-01T23:10:59Z
timestamp: 2026-08-01T23:32:54Z
story_id: US-018
status: verified
---
@@ -15,6 +15,9 @@ As an administrator, I want an operational dashboard of account and game activit
# Acceptance Criteria
- [x] The administrator landing page is a dashboard rather than a settings form.
- [x] An open-data world map plots each user's latest observation with valid approximate coordinates.
- [x] Map markers link to user records and have an accessible text-table equivalent.
- [x] Natural Earth boundaries are bundled and server-rendered without disclosing map or location requests to a third party.
- [x] The dashboard graphs new registered users by UTC day for the previous 14 days.
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
- [x] Monthly active Minecraft accounts count distinct linked accounts observed in the previous 30 days.
@@ -29,10 +32,13 @@ As an administrator, I want an operational dashboard of account and game activit
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
# Validation
- Missing-day chart behavior is covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
- Coordinate parsing, projection, linked markers, text fallback, and attribution are covered by the user-world-map tests.
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
# Related Stories
+1
View File
@@ -18,6 +18,7 @@ Player account management, administrator navigation, dashboard metrics and chart
- Added semantic `time` elements for audit and security activity timestamps.
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
- Added an accessible title, description, per-point labels, and textual values to the registration chart.
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
- Added explicit new-tab context to the external Discord invite link.
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
+1 -1
View File
@@ -4,7 +4,7 @@
### Web application
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, and dashboard aggregation execute on the server and return rendered HTML.
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the Natural Earth user-location map execute on the server and return rendered HTML. Map boundaries are bundled open data, so rendering does not disclose administrator or user location requests to a map provider.
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
+1
View File
@@ -27,6 +27,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
- The administrator-only location map uses bundled Natural Earth boundaries and approximate cached IP intelligence; it sends no coordinates or map requests to third parties.
- ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
+103 -1
View File
@@ -42,17 +42,22 @@
"@minecraft-account-manager/logging": "*",
"@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*",
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"react": "^19.2.3",
"react-dom": "^19.2.3"
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/d3-geo": "^3.1.1",
"@types/node": "^25.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1",
@@ -2724,6 +2729,16 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2738,6 +2753,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2781,6 +2803,27 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -4084,6 +4127,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4129,6 +4178,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5718,6 +5791,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -8337,6 +8419,20 @@
"node": ">=8.0"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9261,6 +9357,12 @@
"node": ">=0.10.0"
}
},
"node_modules/world-atlas": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",