diff --git a/README.md b/README.md index 6521bc9..40a92e4 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/apps/web/package.json b/apps/web/package.json index a89c24b..cb99e05 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/admin/(console)/page.tsx b/apps/web/src/app/admin/(console)/page.tsx index 70700e2..b851166 100644 --- a/apps/web/src/app/admin/(console)/page.tsx +++ b/apps/web/src/app/admin/(console)/page.tsx @@ -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`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 (
@@ -63,7 +99,9 @@ export default async function AdminDashboardPage() {

Live, server-rendered registration, activity, and network-risk signals from the account registry.

-
+ + +
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 20970ce..4293ce6 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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); diff --git a/apps/web/src/components/user-world-map.test.tsx b/apps/web/src/components/user-world-map.test.tsx new file mode 100644 index 0000000..42bdf53 --- /dev/null +++ b/apps/web/src/components/user-world-map.test.tsx @@ -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(); + + 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(/ 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); + } + } + } + }); +}); diff --git a/apps/web/src/components/user-world-map.tsx b/apps/web/src/components/user-world-map.tsx new file mode 100644 index 0000000..7e1a4c1 --- /dev/null +++ b/apps/web/src/components/user-world-map.tsx @@ -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 ( +
+
+
+

Latest known location

+

Community world

+
+

{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.

+
+ +
+ + Latest approximate location for registered users + An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows. + + + + {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 ( + + + + {user.name} · {user.location} · {user.classification} + + + ); + })} + + +
+

Map boundaries: Natural Earth, public domain

+ +
+ View accessible location list +
+ + + + + {locations.map((user) => )} + {!locations.length && } + +
Latest approximate registered-user locations
UserLocationNetworkSourceLast observed
{user.name}@{user.discordUsername}{user.location}{user.classification}{user.source}
No user observations currently include valid coordinates.
+
+
+
+ ); +} diff --git a/apps/web/src/lib/user-location-map.test.ts b/apps/web/src/lib/user-location-map.test.ts new file mode 100644 index 0000000..017b58b --- /dev/null +++ b/apps/web/src/lib/user-location-map.test.ts @@ -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 }); + }); +}); diff --git a/apps/web/src/lib/user-location-map.ts b/apps/web/src/lib/user-location-map.ts new file mode 100644 index 0000000..08b3454 --- /dev/null +++ b/apps/web/src/lib/user-location-map.ts @@ -0,0 +1,41 @@ +type UnknownMap = Record; + +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, + }; +} diff --git a/design/log.md b/design/log.md index 5bfe96a..fb2c025 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/us-018-admin-dashboard.md b/design/us-018-admin-dashboard.md index 0b754ed..9ec379f 100644 --- a/design/us-018-admin-dashboard.md +++ b/design/us-018-admin-dashboard.md @@ -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 diff --git a/docs/accessibility.md b/docs/accessibility.md index 6b44d42..acdb4ee 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 68da67f..8d29287 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/security-review.md b/docs/security-review.md index 0fe3a03..a60fa8f 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -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. diff --git a/package-lock.json b/package-lock.json index d360431..0a66d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",