From aa0b757814e664691691cfb7d600882c0f8692e0 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 21:24:49 -0400 Subject: [PATCH] fix(map): group collocated user markers --- apps/web/src/app/globals.css | 13 ++++ apps/web/src/components/map-view-toggle.tsx | 74 +++++++++++++++---- .../src/components/user-world-map.test.tsx | 15 ++++ apps/web/src/components/user-world-map.tsx | 44 +++++++---- apps/web/src/lib/user-location-map.test.ts | 24 +++++- apps/web/src/lib/user-location-map.ts | 26 +++++++ design/log.md | 1 + design/us-018-admin-dashboard.md | 8 +- docs/accessibility.md | 1 + docs/security-review.md | 1 + 10 files changed, 176 insertions(+), 31 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5b7e7e4..760b65e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -74,6 +74,19 @@ svg a:focus .map-marker { opacity: 1; } +.map-user-cluster { + display: grid !important; + place-items: center; + border: 3px solid var(--panel); + border-radius: 999px; + background: var(--accent); + color: var(--panel); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; + box-shadow: 0 0 0 1px var(--ink); +} + ::selection { background: var(--accent); color: var(--panel); diff --git a/apps/web/src/components/map-view-toggle.tsx b/apps/web/src/components/map-view-toggle.tsx index e4904d9..8658540 100644 --- a/apps/web/src/components/map-view-toggle.tsx +++ b/apps/web/src/components/map-view-toggle.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; +import { groupMapLocations } from "@/lib/user-location-map"; import type { UserMapLocation } from "./user-world-map"; export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) { @@ -40,33 +41,76 @@ function InteractiveMap({ locations }: { locations: UserMapLocation[] }) { }).addTo(map); const bounds: [number, number][] = []; - for (const user of locations) { - const marker = leaflet.circleMarker([user.latitude, user.longitude], { - radius: 8, - color: "#eee8d8", - weight: 3, - fillColor: "#a32f1b", - fillOpacity: 1, - }).addTo(map); + for (const group of groupMapLocations(locations)) { + const firstUser = group.locations[0]!; + const isGrouped = group.count > 1; + const marker = isGrouped + ? leaflet.marker([group.latitude, group.longitude], { + icon: leaflet.divIcon({ + className: "map-user-cluster", + html: ``, + iconAnchor: [18, 18], + iconSize: [36, 36], + }), + keyboard: true, + }).addTo(map) + : leaflet.circleMarker([group.latitude, group.longitude], { + radius: 8, + color: "#eee8d8", + weight: 3, + fillColor: "#a32f1b", + fillOpacity: 1, + }).addTo(map); const tooltip = document.createElement("span"); - tooltip.textContent = `${user.nickname} · ${user.location}`; + tooltip.textContent = isGrouped + ? `${group.count} users · ${group.nicknames.join(" · ")}` + : `${firstUser.nickname} · ${firstUser.location}`; marker.bindTooltip(tooltip, { direction: "top" }); - const userPath = `/admin/users/${user.userId}`; - marker.on("click", () => window.location.assign(userPath)); + + if (isGrouped) { + const popup = document.createElement("div"); + const heading = document.createElement("strong"); + heading.textContent = `${group.count} users near ${firstUser.location}`; + popup.append(heading); + const list = document.createElement("ul"); + for (const user of group.locations) { + const item = document.createElement("li"); + const link = document.createElement("a"); + link.href = `/admin/users/${user.userId}`; + link.textContent = user.nickname; + item.append(link); + list.append(item); + } + popup.append(list); + marker.bindPopup(popup); + } else { + marker.on("click", () => window.location.assign(`/admin/users/${firstUser.userId}`)); + } + const element = marker.getElement(); - element?.setAttribute("aria-label", `${user.nickname}, ${user.location}`); - element?.setAttribute("role", "link"); + const label = isGrouped + ? `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}` + : `${firstUser.nickname}, ${firstUser.location}`; + element?.setAttribute("aria-label", label); + element?.setAttribute("role", isGrouped ? "button" : "link"); element?.setAttribute("tabindex", "0"); + if (isGrouped) { + element?.setAttribute("aria-haspopup", "dialog"); + element?.setAttribute("aria-expanded", "false"); + marker.on("popupopen", () => element?.setAttribute("aria-expanded", "true")); + marker.on("popupclose", () => element?.setAttribute("aria-expanded", "false")); + } element?.addEventListener("focus", () => marker.openTooltip()); element?.addEventListener("blur", () => marker.closeTooltip()); element?.addEventListener("keydown", (event) => { const keyboardEvent = event as KeyboardEvent; if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") { keyboardEvent.preventDefault(); - window.location.assign(userPath); + if (isGrouped) marker.openPopup(); + else window.location.assign(`/admin/users/${firstUser.userId}`); } }); - bounds.push([user.latitude, user.longitude]); + bounds.push([group.latitude, group.longitude]); } if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 }); cleanup = () => map.remove(); diff --git a/apps/web/src/components/user-world-map.test.tsx b/apps/web/src/components/user-world-map.test.tsx index 6d984f4..e5e996a 100644 --- a/apps/web/src/components/user-world-map.test.tsx +++ b/apps/web/src/components/user-world-map.test.tsx @@ -15,6 +15,17 @@ describe("UserWorldMap", () => { classification: "clear", source: "game", observedAt: new Date("2026-08-01T12:00:00Z"), + }, { + userId: "22222222-2222-4222-8222-222222222222", + name: "Alex", + discordUsername: "alex", + nickname: "Alex (AlexMC)", + latitude: 37.4057, + longitude: -122.0774, + location: "Mountain View, California, US", + classification: "clear", + source: "web", + observedAt: new Date("2026-08-01T13:00:00Z"), }]} unavailableCount={2} />); expect(markup).toContain('role="group"'); @@ -22,6 +33,10 @@ describe("UserWorldMap", () => { expect(markup).toContain("Latest approximate location for registered users"); expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"'); expect(markup).toContain("Dani (Steve)"); + expect(markup).toContain("Alex (AlexMC)"); + expect(markup).toContain("2 users near Mountain View, California, US"); + expect(markup).toMatch(/]*>2<\/text>/); + expect(markup).toContain('
'); expect(markup).toContain("Mountain View, California, US"); expect(markup).toContain("World overview"); expect(markup).toContain("Interactive OpenStreetMap"); diff --git a/apps/web/src/components/user-world-map.tsx b/apps/web/src/components/user-world-map.tsx index 0d9df18..2af8b15 100644 --- a/apps/web/src/components/user-world-map.tsx +++ b/apps/web/src/components/user-world-map.tsx @@ -4,6 +4,7 @@ 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; @@ -27,6 +28,7 @@ export interface UserMapLocation { } export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) { + const locationGroups = groupMapLocations(locations); return (
@@ -50,23 +52,39 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM })} - {locations.map((user) => { - const projected = projection([user.longitude, user.latitude]); + {locationGroups.map((group) => { + const projected = projection([group.longitude, group.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])); - const tooltipWidth = Math.min(260, Math.max(110, user.nickname.length * 8 + 24)); + 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 tooltipY = y > 46 ? y - 38 : y + 18; + 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 ( - - - {user.nickname} · {user.location} · {user.classification} + + + {label} - + + {group.count > 1 && } ); @@ -77,7 +95,7 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM

Map boundaries: Natural Earth, public domain

-
+
group.count > 1)}> View accessible location list
diff --git a/apps/web/src/lib/user-location-map.test.ts b/apps/web/src/lib/user-location-map.test.ts index 017b58b..75d9806 100644 --- a/apps/web/src/lib/user-location-map.test.ts +++ b/apps/web/src/lib/user-location-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseUserLocation, projectWorldPoint } from "./user-location-map"; +import { groupMapLocations, parseUserLocation, projectWorldPoint } from "./user-location-map"; describe("user location map", () => { it("extracts a valid approximate location from cached IP intelligence", () => { @@ -24,6 +24,28 @@ describe("user location map", () => { expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull(); }); + it("groups users sharing approximate coordinates without hiding their identities", () => { + const groups = groupMapLocations([ + { userId: "one", nickname: "Dani (Steve)", latitude: 37.4056, longitude: -122.0775 }, + { userId: "two", nickname: "Alex (AlexMC)", latitude: 37.4057, longitude: -122.0774 }, + { userId: "three", nickname: "Sam (Notch)", latitude: 51.5, longitude: -0.12 }, + ]); + + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ count: 2, nicknames: ["Alex (AlexMC)", "Dani (Steve)"] }); + expect(groups[0]?.locations.map((location) => location.userId)).toEqual(["one", "two"]); + expect(groups[1]).toMatchObject({ count: 1, nicknames: ["Sam (Notch)"] }); + }); + + it("normalizes signed zero and the antimeridian before grouping", () => { + const groups = groupMapLocations([ + { nickname: "West", latitude: -0.004, longitude: 180 }, + { nickname: "East", latitude: 0.004, longitude: -180 }, + ]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ count: 2, key: "0:-180", latitude: 0, longitude: -180 }); + }); + 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 index 08b3454..223fdfe 100644 --- a/apps/web/src/lib/user-location-map.ts +++ b/apps/web/src/lib/user-location-map.ts @@ -33,6 +33,32 @@ export function parseUserLocation(value: unknown): ParsedUserLocation | null { return { latitude, longitude, label: label || "Approximate location unavailable" }; } +export function groupMapLocations(locations: T[]) { + const grouped = new Map(); + for (const location of locations) { + const roundedLatitude = Number(location.latitude.toFixed(2)); + const latitude = roundedLatitude === 0 ? 0 : roundedLatitude; + const roundedLongitude = Number(location.longitude.toFixed(2)); + const longitude = Math.abs(roundedLongitude) === 180 ? -180 : roundedLongitude; + const key = `${latitude}:${longitude}`; + const group = grouped.get(key); + if (group) group.locations.push(location); + else grouped.set(key, { latitude, longitude, locations: [location] }); + } + return [...grouped.entries()].map(([key, group]) => ({ + key, + latitude: group.latitude, + longitude: group.longitude, + count: group.locations.length, + nicknames: [...group.locations.map((location) => location.nickname)].sort((left, right) => left.localeCompare(right)), + locations: group.locations, + })); +} + export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) { return { x: ((longitude + 180) / 360) * width, diff --git a/design/log.md b/design/log.md index 7e70a51..1793886 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-08-02 +* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links. * **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections. * **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work. diff --git a/design/us-018-admin-dashboard.md b/design/us-018-admin-dashboard.md index d4d7e6a..47a0936 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 daily activity, confirmed connections, locations, denials, and risky networks. tags: [admin, dashboard, metrics, security, maps, ssr] -timestamp: 2026-08-02T00:12:32Z +timestamp: 2026-08-02T01:23:21Z story_id: US-018 status: verified --- @@ -19,6 +19,10 @@ As an administrator, I want an operational dashboard of account and game activit - [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview. - [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution. - [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent. +- [x] Users sharing approximate coordinates render as one grouped marker with a visible count in both map views. +- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location. +- [x] Interactive grouped markers open a popup with links to every corresponding user record. +- [x] Single-user markers retain their direct nickname tooltip and user-record link. - [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels. - [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 accounts with a confirmed Velocity post-login connection in the previous 30 days. @@ -40,7 +44,7 @@ As an administrator, I want an operational dashboard of account and game activit # Validation - Missing-day chart behavior and per-user VPN collapsing are 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. +- Coordinate parsing, normalized location grouping, projection, count badges, complete grouped tooltips, 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 3ac3a3e..8328ee2 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -19,6 +19,7 @@ Player account management, administrator navigation, dashboard metrics and chart - Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer. - Added an accessible title, description, date labels, per-point labels, and textual values to the daily-active-user chart. - Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations. +- Collocated users share a visible count badge; hover and focus tooltips announce every nickname, while interactive grouped markers expose per-user popup links. - Added keyboard-operable tabs for the server-rendered overview and opt-in interactive OpenStreetMap view. - Added explicit new-tab context to the external Discord invite link. - Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures. diff --git a/docs/security-review.md b/docs/security-review.md index ce0cef0..fcac942 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -28,6 +28,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut - 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 map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin. +- Grouped-map popup labels and links are created with DOM `textContent` and server-rendered React escaping rather than interpolated HTML. - 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.