fix(map): group collocated user markers
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user