fix(map): group collocated user markers
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: `<span aria-hidden="true">${group.count}</span>`,
|
||||
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();
|
||||
|
||||
@@ -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(/<text[^>]*>2<\/text>/);
|
||||
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list" open="">');
|
||||
expect(markup).toContain("Mountain View, California, US");
|
||||
expect(markup).toContain("World overview");
|
||||
expect(markup).toContain("Interactive OpenStreetMap");
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -50,23 +52,39 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{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 (
|
||||
<a aria-label={`${user.nickname}, ${user.location}, last seen ${user.observedAt.toISOString()}`} className="map-marker-link" 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">
|
||||
<title>{user.nickname} · {user.location} · {user.classification}</title>
|
||||
<a aria-label={label} className="map-marker-link" href={group.count === 1 ? `/admin/users/${firstUser.userId}` : "#map-location-list"} key={group.key}>
|
||||
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
|
||||
<title>{label}</title>
|
||||
</circle>
|
||||
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3" />
|
||||
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r={markerRadius} stroke="var(--panel)" strokeWidth="3" />
|
||||
{group.count > 1 && <text aria-hidden="true" dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" fontWeight="700" pointerEvents="none" textAnchor="middle" x={x} y={y}>{group.count}</text>}
|
||||
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
|
||||
<rect fill="var(--ink)" height="28" rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
|
||||
<text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" textAnchor="middle" x={tooltipX + tooltipWidth / 2} y={tooltipY + 14}>{user.nickname}</text>
|
||||
<rect fill="var(--ink)" height={tooltipHeight} rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
|
||||
{group.nicknames.map((nickname, index) => {
|
||||
const column = Math.floor(index / tooltipRows);
|
||||
const row = index % tooltipRows;
|
||||
return <text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" key={`${nickname}-${index}`} textAnchor="middle" x={tooltipX + tooltipColumnWidth * (column + 0.5)} y={tooltipY + 14 + row * 18}>{nickname}</text>;
|
||||
})}
|
||||
</g>
|
||||
</a>
|
||||
);
|
||||
@@ -77,7 +95,7 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
|
||||
</MapViewToggle>
|
||||
<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">
|
||||
<details className="mt-5 border-t border-line pt-4" id="map-location-list" open={locationGroups.some((group) => group.count > 1)}>
|
||||
<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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -33,6 +33,32 @@ export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||
return { latitude, longitude, label: label || "Approximate location unavailable" };
|
||||
}
|
||||
|
||||
export function groupMapLocations<T extends {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
nickname: string;
|
||||
}>(locations: T[]) {
|
||||
const grouped = new Map<string, { latitude: number; longitude: number; locations: T[] }>();
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user