- {user.nickname} · {user.location} · {user.classification}
+
+
+ {label}
-
+
+ {group.count > 1 && {group.count}}
-
- {user.nickname}
+
+ {group.nicknames.map((nickname, index) => {
+ const column = Math.floor(index / tooltipRows);
+ const row = index % tooltipRows;
+ return {nickname};
+ })}
);
@@ -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.