NYC Subway
496 stations · 26 routes · MTA data
transit-map/transit-map.tsx
The map itself: projection, pan/zoom, semantic-zoom labels, rendering. · 591 lines · 18 KB
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { select } from "d3-selection";
import {
zoom,
zoomIdentity,
zoomTransform,
type ZoomBehavior,
type ZoomTransform,
} from "d3-zoom";
import { computeVisibleLabels, type LabelCandidate } from "./labels";
import { createProjection, polylineToPath, type Projection } from "./projection";
import type {
LonLat,
TransitMapAppearance,
TransitMapHandle,
TransitMapProps,
TransitRoute,
TransitStation,
} from "./types";
/*
* Themeable via CSS custom properties (shadcn-style). Every var has a
* fallback, so the component works standalone; consumers retheme by defining
* the variables on :root / .dark:
* --map-water, --map-land, --map-shore, --map-label, --map-label-halo,
* --map-station-fill, --map-station-stroke, --map-selection
*/
const VAR = {
water: "var(--map-water, #cfdde6)",
land: "var(--map-land, #f6f4ef)",
shore: "var(--map-shore, #b8ccd8)",
label: "var(--map-label, #3d3d3d)",
halo: "var(--map-label-halo, rgba(255,255,255,0.9))",
stationFill: "var(--map-station-fill, #ffffff)",
stationStroke: "var(--map-station-stroke, #4a4a4a)",
selection: "var(--map-selection, #0a84ff)",
};
interface ProjectedStation extends TransitStation {
x: number;
y: number;
/** Resolved importance — from data, or derived from interchange size. */
rank: number;
}
const LABEL_ZOOM_STEP = 1.12;
const BASE_FONT_PX = 11.5;
/** Bubble radius in world units per tier, before zoom and user scaling. */
const TIER_RADIUS: Record<number, number> = { 1: 5.4, 2: 4.7, 3: 4.1, 4: 3.6, 5: 3.2 };
/** Same shape as the data pipeline's floor, for data that ships no tiers. */
function tierFromRouteCount(routeCount: number): number {
if (routeCount >= 8) return 1;
if (routeCount >= 5) return 2;
if (routeCount >= 3) return 3;
if (routeCount >= 2) return 4;
return 5;
}
const DEFAULT_APPEARANCE: Required<Omit<TransitMapAppearance, "fontFamily">> = {
labelScale: 1,
stationScale: 1,
lineScale: 1,
labelDensity: 1,
};
export const TransitMap = forwardRef<TransitMapHandle, TransitMapProps>(
function TransitMap(
{
stations,
routes,
land,
className,
selectedStationId = null,
onSelectStation,
highlightedRouteIds = null,
appearance,
minZoom = 0.85,
maxZoom = 60,
showControls = true,
},
ref,
) {
const {
labelScale,
stationScale,
lineScale,
labelDensity,
} = { ...DEFAULT_APPEARANCE, ...appearance };
const fontFamily = appearance?.fontFamily;
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
const [transform, setTransform] = useState<ZoomTransform | null>(null);
/* ------------------------- projected geometry ------------------------- */
const projection: Projection = useMemo(() => {
const pts: LonLat[] = stations.map((s) => [s.lon, s.lat]);
return createProjection(pts);
}, [stations]);
// Routes are counted per complex so a hub split across several station
// rows (Times Sq, Atlantic Av) is ranked as the one hub it really is.
const rankById = useMemo(() => {
const routesByComplex = new Map<string, Set<string>>();
for (const s of stations) {
const key = s.complexId ?? s.id;
let set = routesByComplex.get(key);
if (!set) routesByComplex.set(key, (set = new Set()));
for (const r of s.routes) set.add(r);
}
return new Map(
stations.map((s) => [
s.id,
s.tier ?? tierFromRouteCount(routesByComplex.get(s.complexId ?? s.id)!.size),
]),
);
}, [stations]);
const projectedStations: ProjectedStation[] = useMemo(
() =>
stations.map((s) => {
const [x, y] = projection.toWorld([s.lon, s.lat]);
return { ...s, x, y, rank: rankById.get(s.id)! };
}),
[stations, projection, rankById],
);
const routePaths = useMemo(
() =>
routes.map((r) => ({
route: r,
d: r.shapes
.map((shape) => polylineToPath(shape.map((p) => projection.toWorld(p))))
.join(""),
})),
[routes, projection],
);
const landPath = useMemo(() => {
if (!land) return null;
return land
.map((ring) => polylineToPath(ring.map((p) => projection.toWorld(p))) + "Z")
.join("");
}, [land, projection]);
const routeById = useMemo(
() => new Map(routes.map((r) => [r.id, r])),
[routes],
);
// One label per complex, from the platform serving the most routes — that
// is the name riders know it by ("42 St-Bryant Pk", not its "5 Av" platform).
const primaryStationIds = useMemo(() => {
const best = new Map<string, { id: string; routes: number; nameLength: number }>();
for (const s of stations) {
const key = s.complexId ?? s.id;
const current = best.get(key);
if (
!current ||
s.routes.length > current.routes ||
(s.routes.length === current.routes && s.name.length > current.nameLength)
) {
best.set(key, { id: s.id, routes: s.routes.length, nameLength: s.name.length });
}
}
return new Set([...best.values()].map((b) => b.id));
}, [stations]);
const labelCandidates: LabelCandidate[] = useMemo(
() =>
projectedStations
.filter((s) => primaryStationIds.has(s.id))
.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, tier: s.rank })),
[projectedStations, primaryStationIds],
);
/* ----------------------------- pan / zoom ----------------------------- */
// Scale at which the whole network fits the container.
const fitK = useMemo(() => {
if (!size) return 1;
return (
0.94 *
Math.min(size.w / projection.worldWidth, size.h / projection.worldHeight)
);
}, [size, projection]);
const zoomBehavior = useMemo<ZoomBehavior<SVGSVGElement, unknown>>(
() => zoom<SVGSVGElement, unknown>(),
[],
);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) setSize({ w: width, h: height });
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Programmatic camera animation. d3-transition would normally drive this,
// but it registers itself via an import side effect that bundlers with
// sideEffects-based tree-shaking drop, so we animate with rAF ourselves:
// zoom scale interpolates geometrically, the world-space center linearly.
const animRef = useRef<number | null>(null);
const cancelAnim = useCallback(() => {
if (animRef.current !== null) cancelAnimationFrame(animRef.current);
animRef.current = null;
}, []);
useEffect(() => cancelAnim, [cancelAnim]);
const initializedRef = useRef(false);
useEffect(() => {
const svg = svgRef.current;
if (!svg || !size) return;
const sel = select(svg);
zoomBehavior
.scaleExtent([fitK * minZoom, fitK * maxZoom])
.translateExtent([
[-projection.worldWidth * 0.25, -projection.worldHeight * 0.25],
[projection.worldWidth * 1.25, projection.worldHeight * 1.25],
])
.on("zoom", (e: { transform: ZoomTransform; sourceEvent: Event | null }) => {
// A user gesture takes over from any programmatic animation.
if (e.sourceEvent) cancelAnim();
setTransform(e.transform);
});
sel.call(zoomBehavior);
if (!initializedRef.current) {
initializedRef.current = true;
const t = zoomIdentity
.translate(
(size.w - projection.worldWidth * fitK) / 2,
(size.h - projection.worldHeight * fitK) / 2,
)
.scale(fitK);
sel.call(zoomBehavior.transform, t);
}
}, [size, fitK, minZoom, maxZoom, projection, zoomBehavior, cancelAnim]);
const animateTo = useCallback(
(target: ZoomTransform, duration: number) => {
const svg = svgRef.current;
if (!svg || !size) return;
cancelAnim();
// rAF is paused in hidden tabs — jump straight to the target there.
if (typeof document !== "undefined" && document.hidden) {
select(svg).call(zoomBehavior.transform, target);
return;
}
const start = zoomTransform(svg);
const cx0 = (size.w / 2 - start.x) / start.k;
const cy0 = (size.h / 2 - start.y) / start.k;
const cx1 = (size.w / 2 - target.x) / target.k;
const cy1 = (size.h / 2 - target.y) / target.k;
const t0 = performance.now();
const ease = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const step = (now: number) => {
const p = Math.min(1, (now - t0) / duration);
const e = ease(p);
const k = start.k * Math.pow(target.k / start.k, e);
const cx = cx0 + (cx1 - cx0) * e;
const cy = cy0 + (cy1 - cy0) * e;
const tr = zoomIdentity
.translate(size.w / 2 - cx * k, size.h / 2 - cy * k)
.scale(k);
select(svg).call(zoomBehavior.transform, tr);
animRef.current = p < 1 ? requestAnimationFrame(step) : null;
};
animRef.current = requestAnimationFrame(step);
},
[size, cancelAnim, zoomBehavior],
);
const clampK = useCallback(
(k: number) => Math.max(fitK * minZoom, Math.min(fitK * maxZoom, k)),
[fitK, minZoom, maxZoom],
);
const zoomBy = useCallback(
(factor: number) => {
const svg = svgRef.current;
if (!svg || !size) return;
const cur = zoomTransform(svg);
const k = clampK(cur.k * factor);
const cx = (size.w / 2 - cur.x) / cur.k;
const cy = (size.h / 2 - cur.y) / cur.k;
animateTo(
zoomIdentity.translate(size.w / 2 - cx * k, size.h / 2 - cy * k).scale(k),
250,
);
},
[size, clampK, animateTo],
);
const flyTo = useCallback(
(center: LonLat, zoomLevel = 12) => {
if (!size) return;
const [x, y] = projection.toWorld(center);
const k = clampK(fitK * zoomLevel);
const t = zoomIdentity
.translate(size.w / 2, size.h / 2)
.scale(k)
.translate(-x, -y);
animateTo(t, 650);
},
[size, fitK, projection, clampK, animateTo],
);
useImperativeHandle(
ref,
(): TransitMapHandle => ({
flyTo,
flyToStation(stationId, zoomLevel = 12) {
const s = stations.find((st) => st.id === stationId);
if (s) flyTo([s.lon, s.lat], zoomLevel);
},
zoomIn() {
zoomBy(1.5);
},
zoomOut() {
zoomBy(1 / 1.5);
},
reset() {
if (!size) return;
const t = zoomIdentity
.translate(
(size.w - projection.worldWidth * fitK) / 2,
(size.h - projection.worldHeight * fitK) / 2,
)
.scale(fitK);
animateTo(t, 650);
},
}),
[flyTo, zoomBy, animateTo, stations, size, fitK, projection],
);
/* ------------------------------ rendering ------------------------------ */
const k = transform?.k ?? fitK;
const z = k / fitK; // effective zoom: 1 = whole network in view
const fontPx = BASE_FONT_PX * labelScale;
// Labels are recomputed per quantized zoom step, not per frame.
const labelBucket = Math.round(Math.log(Math.max(z, 0.01)) / Math.log(LABEL_ZOOM_STEP));
const visibleLabels = useMemo(() => {
const bucketZ = Math.pow(LABEL_ZOOM_STEP, labelBucket);
return computeVisibleLabels(labelCandidates, fitK * bucketZ, bucketZ, {
fontPx,
density: labelDensity,
});
}, [labelCandidates, fitK, labelBucket, fontPx, labelDensity]);
const highlightSet = useMemo(
() =>
highlightedRouteIds && highlightedRouteIds.length > 0
? new Set(highlightedRouteIds)
: null,
[highlightedRouteIds],
);
const handleBackgroundClick = useCallback(() => {
onSelectStation?.(null);
}, [onSelectStation]);
const stationById = useMemo(
() => new Map(projectedStations.map((s) => [s.id, s])),
[projectedStations],
);
const selected = selectedStationId ? stationById.get(selectedStationId) : null;
const lineWidth = ((z < 1.4 ? 2.1 : z < 4 ? 2.8 : 3.4) * lineScale) / k;
const content = useMemo(() => {
if (!transform) return null;
const dotR = (s: ProjectedStation) => {
const base = TIER_RADIUS[s.rank] ?? TIER_RADIUS[5];
return ((z < 1.4 ? base * 0.62 : base) * stationScale) / k;
};
const stationDim = (s: ProjectedStation) =>
highlightSet !== null && !s.routes.some((r) => highlightSet.has(r));
return (
<g>
{landPath && (
<path
d={landPath}
fill={VAR.land}
stroke={VAR.shore}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)}
<g fill="none" strokeLinecap="round" strokeLinejoin="round">
{routePaths.map(({ route, d }) => (
<path
key={route.id}
d={d}
stroke={route.color}
strokeWidth={lineWidth}
opacity={highlightSet === null || highlightSet.has(route.id) ? 0.96 : 0.12}
/>
))}
</g>
<g>
{projectedStations.map((s) => {
const r = dotR(s);
const interchange = s.routes.length >= 2;
const stroke = interchange
? VAR.stationStroke
: routeById.get(s.routes[0])?.color ?? VAR.stationStroke;
return (
<circle
key={s.id}
cx={s.x}
cy={s.y}
r={r}
fill={VAR.stationFill}
stroke={stroke}
strokeWidth={r * 0.55}
opacity={stationDim(s) ? 0.25 : 1}
style={{ cursor: onSelectStation ? "pointer" : undefined }}
onClick={
onSelectStation
? (e) => {
e.stopPropagation();
onSelectStation(s);
}
: undefined
}
>
<title>{s.name}</title>
</circle>
);
})}
</g>
<g
fontSize={fontPx / k}
fontFamily={fontFamily}
fontWeight={500}
fill={VAR.label}
stroke={VAR.halo}
strokeWidth={(fontPx * 0.26) / k}
paintOrder="stroke"
style={{ pointerEvents: "none", userSelect: "none" }}
>
{projectedStations.map((s) =>
visibleLabels.has(s.id) || s.id === selectedStationId ? (
<text
key={s.id}
x={s.x + (dotR(s) + (fontPx * 0.4) / k)}
y={s.y}
dominantBaseline="middle"
fontWeight={s.rank <= 2 ? 600 : 500}
opacity={stationDim(s) ? 0.3 : 1}
>
{s.name}
</text>
) : null,
)}
</g>
{selected && (
<circle
cx={selected.x}
cy={selected.y}
r={9 / k}
fill="none"
stroke={VAR.selection}
strokeWidth={2.5 / k}
/>
)}
</g>
);
}, [
transform,
k,
z,
landPath,
routePaths,
lineWidth,
projectedStations,
routeById,
visibleLabels,
highlightSet,
selectedStationId,
selected,
onSelectStation,
fontPx,
fontFamily,
stationScale,
]);
return (
<div
ref={containerRef}
className={className}
style={{ position: "relative", overflow: "hidden" }}
>
<svg
ref={svgRef}
width="100%"
height="100%"
style={{ display: "block", touchAction: "none", background: VAR.water }}
onClick={handleBackgroundClick}
role="img"
aria-label="Transit map"
>
{transform && (
<g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
{content}
</g>
)}
</svg>
{showControls && (
<MapControls
onZoomIn={() => zoomBy(1.5)}
onZoomOut={() => zoomBy(1 / 1.5)}
/>
)}
</div>
);
},
);
function MapControls({
onZoomIn,
onZoomOut,
}: {
onZoomIn: () => void;
onZoomOut: () => void;
}) {
const btn: React.CSSProperties = {
width: 36,
height: 36,
display: "grid",
placeItems: "center",
fontSize: 18,
lineHeight: 1,
cursor: "pointer",
border: "none",
background: "transparent",
color: "inherit",
};
return (
<div
style={{
position: "absolute",
right: 12,
bottom: 12,
display: "flex",
flexDirection: "column",
borderRadius: 10,
overflow: "hidden",
background: "var(--map-control-bg, var(--card, #fff))",
color: "var(--map-control-fg, var(--card-foreground, #333))",
boxShadow: "0 2px 10px rgba(0,0,0,0.18)",
}}
>
<button type="button" aria-label="Zoom in" style={btn} onClick={onZoomIn}>
+
</button>
<div style={{ height: 1, background: "var(--border, rgba(0,0,0,0.1))" }} />
<button type="button" aria-label="Zoom out" style={btn} onClick={onZoomOut}>
−
</button>
</div>
);
}
export type { TransitMapProps, TransitMapHandle, TransitStation, TransitRoute };