Skip to content

Commit 875fc02

Browse files
ryan-williamsclaude
andcommitted
NycFlowMap: all-sector geographic flow map (v1)
First geographic visualization of NYC-wide CBD-bound flows. One ribbon per (sector, mode) routed through hand-coded representative entry paths in `lib/nyc-paths.ts`: - NJ: existing GeoSankey paths reused (Lincoln, Holland, Amtrak/NJT, PATH up + dn, ferry corridor). - Queens: Queensboro Bridge, Queens-Midtown Tunnel, an aggregate subway tunnel (E/F/M/N/Q/R/W/7), LIRR via the Steinway / 63rd-St tubes. - Brooklyn: Brooklyn / Manhattan / Williamsburg bridges, Hugh L. Carey Tunnel, an aggregate subway tunnel. - Staten Island: SI Ferry to Whitehall. - 60th-Street: 3 north–south corridors (W / mid / E avenues) plus the matching crosstown subway aggregates. Component reuses geo-sankey's `ribbonArrow` + `pxToHalfDeg` + `flowFillPaint` for ribbon rendering. Each sector is labeled at the arrow tip of its largest mode; per-sector anchor placements (SECTOR_ANCHORS) fan labels outward so they don't pile up over Lower Manhattan. Direction + time-period toggles work; year fixed at latest. Also fixes a subtle maplibre-gl init quirk: in strict-mode + vite the map captures a stale container size and basemap tiles never start loading. Drive `map.resize()` + `map.triggerRepaint()` from a ResizeObserver and a 200 ms poll until `isSourceLoaded('carto')` turns true. Path geometry for subway lines is intentionally lumped into single aggregate ribbons per sector — per-line geometry is in `specs/nyc-flow-map.md`'s v2 phase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 198dff9 commit 875fc02

3 files changed

Lines changed: 521 additions & 0 deletions

File tree

www/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import PeakChart from './components/PeakChart'
1616
import ModeShareChart from './components/ModeShareChart'
1717
import GeoSankey from './components/GeoSankey'
1818
import NycBubbleChart from './components/NycBubbleChart'
19+
import NycFlowMap from './components/NycFlowMap'
1920
import { SDTooltipRenderer } from './components/Tooltip'
2021
import './App.scss'
2122

@@ -81,6 +82,9 @@ function App() {
8182
</section>
8283
</>}
8384
{showNyc && <>
85+
<section id="nyc-flow-map">
86+
<NycFlowMap appendixIii={appendixIii} vehicles={vehicles} />
87+
</section>
8488
<section id="nyc-bubble">
8589
<NycBubbleChart appendixIii={appendixIii} vehicles={vehicles} />
8690
</section>

www/src/components/NycFlowMap.tsx

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
// Geographic Sankey of CBD-bound flows from all NYMTC sectors (NJ, Queens,
2+
// Brooklyn, Staten Island, 60th-Street boundary). MVP: one ribbon per
3+
// (sector, mode) routed through representative entry paths defined in
4+
// `lib/nyc-paths.ts`. See `specs/nyc-flow-map.md` for the bigger picture
5+
// (subway-line detail, ferry network, drilldowns) — deferred for v2.
6+
7+
import { useEffect, useMemo, useRef, useState } from 'react'
8+
import MapGL, { Source, Layer, Marker } from 'react-map-gl/maplibre'
9+
import type { MapRef } from 'react-map-gl/maplibre'
10+
import { SECTOR_LABELS } from '../lib/nyc-types'
11+
import 'maplibre-gl/dist/maplibre-gl.css'
12+
import { useUrlState, codeParam } from 'use-prms'
13+
import { pxToHalfDeg, ribbonArrow, flowFillPaint } from 'geo-sankey'
14+
import type { LatLon } from 'geo-sankey'
15+
import type { CrossingRecord, Direction, TimePeriod } from '../lib/types'
16+
import { type AppendixIIIRecord, type NycMode, type Sector, NYC_MODE_ORDER } from '../lib/nyc-types'
17+
import { buildNycRecords } from '../lib/nyc-data'
18+
import { ENTRY_PATHS, SECTOR_MODE_PATHS } from '../lib/nyc-paths'
19+
import { DEFAULT_SCHEME } from '../lib/colors'
20+
import Toggle, { type ToggleOption } from './Toggle'
21+
22+
const REF_LAT = 40.74
23+
const ARROW_WING = 1.6
24+
const ARROW_LEN = 0.6
25+
26+
const MODE_COLORS: Record<NycMode, string> = {
27+
Auto: DEFAULT_SCHEME.mode.Autos,
28+
Bus: DEFAULT_SCHEME.mode.Bus,
29+
Subway: DEFAULT_SCHEME.mode.PATH,
30+
Rail: DEFAULT_SCHEME.mode.Rail,
31+
Ferry: DEFAULT_SCHEME.mode.Ferries,
32+
}
33+
34+
// Per-sector label placement: anchors fan labels outward from Manhattan so
35+
// they don't pile up at the cluster of Lower-Manhattan arrow tips.
36+
const SECTOR_ANCHORS: Record<Sector, { anchor: 'left' | 'right' | 'top' | 'bottom'; offset: [number, number] }> = {
37+
nj: { anchor: 'right', offset: [-10, 0] }, // label sits W of NJ Manhattan portals (over Hudson)
38+
queens: { anchor: 'left', offset: [10, 0] }, // E (toward Queens)
39+
brooklyn: { anchor: 'left', offset: [10, 8] }, // E + small S, away from MB/BB tips
40+
'60th_street': { anchor: 'bottom', offset: [0, -8] }, // pushed N into uptown
41+
staten_island: { anchor: 'top', offset: [0, 8] }, // pushed S toward the harbor
42+
roosevelt_island: { anchor: 'top', offset: [0, 8] },
43+
}
44+
45+
// Map view encompasses all entry points: NJ shore on the W, LIC / Queens on
46+
// the NE, the four Brooklyn-side bridges/tunnels on the S, SI ferry to the
47+
// SW, and 60th St inflows along the top of the Manhattan grid.
48+
function defaultView(): { lat: number; lng: number; zoom: number } {
49+
const W = typeof window !== 'undefined' ? window.innerWidth : 1400
50+
// Centered over Manhattan to fit NJ ↔ Queens horizontally and 60th St ↔
51+
// Lower Manhattan vertically. SI Ferry is visible but its St-George end
52+
// intentionally extends out the bottom — the trace direction reads.
53+
if (W <= 768) return { lat: 40.730, lng: -73.985, zoom: 11.0 }
54+
return { lat: 40.730, lng: -73.985, zoom: 11.85 }
55+
}
56+
57+
const dirParam = codeParam<Direction>('entering', [
58+
['entering', 'njny'], ['leaving', 'nynj'],
59+
])
60+
const timeParam = codeParam<TimePeriod>('peak_1hr', [
61+
['peak_1hr', '1h'], ['peak_period', '3h'], ['24hr', '1d'],
62+
])
63+
const DIR_OPTIONS: ToggleOption<Direction>[] = [
64+
{ value: 'entering', label: 'Entering', tooltip: 'Entering CBD' },
65+
{ value: 'leaving', label: 'Leaving', tooltip: 'Leaving CBD' },
66+
]
67+
const TIME_OPTIONS: ToggleOption<TimePeriod>[] = [
68+
{ value: 'peak_1hr', label: '1hr' },
69+
{ value: 'peak_period', label: '3hr' },
70+
{ value: '24hr', label: 'day' },
71+
]
72+
73+
type Props = {
74+
appendixIii: AppendixIIIRecord[]
75+
vehicles: CrossingRecord[]
76+
}
77+
78+
export default function NycFlowMap({ appendixIii, vehicles }: Props) {
79+
const [direction, setDirection] = useUrlState('d', dirParam)
80+
const [timePeriod, setTimePeriod] = useUrlState('t', timeParam)
81+
82+
const mapRef = useRef<MapRef>(null)
83+
// `useState(defaultView)` evaluated the initializer twice under strict
84+
// mode and was producing two different objects (different `window` reads),
85+
// which left MapGL in a never-loaded state. Compute once at mount.
86+
const initialView = useMemo(defaultView, [])
87+
const [mapView, setMapView] = useState(initialView)
88+
const [hoveredKey, setHoveredKey] = useState<string | null>(null)
89+
90+
const records = useMemo(() => buildNycRecords(appendixIii, vehicles), [appendixIii, vehicles])
91+
92+
// Maplibre sometimes captures the container's size before layout settles
93+
// (strict-mode double mount + the tall page above) and ends up stuck on
94+
// a black canvas because basemap tiles never start loading. Drive
95+
// `resize` + `triggerRepaint` from a ResizeObserver AND poll a few times
96+
// until the carto basemap source reports loaded.
97+
const containerRef = useRef<HTMLDivElement>(null)
98+
useEffect(() => {
99+
let cancelled = false
100+
const kick = () => {
101+
const m = mapRef.current?.getMap()
102+
if (!m) return
103+
m.resize()
104+
m.triggerRepaint()
105+
}
106+
const el = containerRef.current
107+
let ro: ResizeObserver | null = null
108+
if (el && typeof ResizeObserver !== 'undefined') {
109+
ro = new ResizeObserver(kick)
110+
ro.observe(el)
111+
}
112+
// Belt-and-suspenders: poll every 200 ms until carto source is loaded.
113+
const poll = () => {
114+
if (cancelled) return
115+
kick()
116+
const m = mapRef.current?.getMap()
117+
if (m && m.isSourceLoaded?.('carto')) return
118+
setTimeout(poll, 200)
119+
}
120+
setTimeout(poll, 50)
121+
return () => { cancelled = true; ro?.disconnect() }
122+
}, [])
123+
124+
// Active = latest year × current direction × current time period.
125+
const flows = useMemo(() => {
126+
const yearSet = new Set(records.map(r => r.year))
127+
const latestYear = Math.max(...yearSet)
128+
return records
129+
.filter(r => r.year === latestYear && r.direction === direction && r.time_period === timePeriod)
130+
.filter(r => r.persons > 0)
131+
}, [records, direction, timePeriod])
132+
133+
// For width-scaling: maxPersons = the largest (sector, mode) cell.
134+
const maxPersons = useMemo(() => {
135+
let m = 0
136+
for (const f of flows) m = Math.max(m, f.persons)
137+
return m
138+
}, [flows])
139+
140+
const geojson = useMemo(() => {
141+
const features: GeoJSON.Feature[] = []
142+
const zoom = mapView.zoom
143+
144+
for (const f of flows) {
145+
const key = `${f.sector}|${f.mode}` as const
146+
const paths = SECTOR_MODE_PATHS[key]
147+
if (!paths || paths.length === 0) continue
148+
const color = MODE_COLORS[f.mode]
149+
// Width sized in pixels so all sectors share the same scale at any zoom.
150+
// Split the flow's persons evenly across all paths assigned to this
151+
// (sector, mode); the rough share isn't accurate but reads cleanly.
152+
const totalWidthPx = (f.persons / maxPersons) * 60
153+
const perPathPx = Math.max(2, totalWidthPx / paths.length)
154+
155+
for (const entryId of paths) {
156+
let path: LatLon[] = ENTRY_PATHS[entryId]
157+
if (direction === 'leaving') path = [...path].reverse()
158+
const halfW = pxToHalfDeg(perPathPx, zoom, 1, REF_LAT)
159+
const ring = ribbonArrow(path, halfW, REF_LAT, {
160+
arrowWingFactor: ARROW_WING,
161+
arrowLenFactor: ARROW_LEN,
162+
widthPx: perPathPx,
163+
})
164+
if (!ring.length) continue
165+
features.push({
166+
type: 'Feature',
167+
properties: {
168+
color,
169+
width: perPathPx,
170+
key: `${key}|${entryId}`,
171+
sectorMode: key,
172+
entryId,
173+
persons: f.persons,
174+
},
175+
geometry: {
176+
type: 'Polygon',
177+
coordinates: [ring.map(([lon, lat]) => [lon, lat])],
178+
},
179+
})
180+
}
181+
}
182+
// Sort widest-first so smaller flows draw on top
183+
features.sort((a, b) => (b.properties?.width ?? 0) - (a.properties?.width ?? 0))
184+
return { type: 'FeatureCollection' as const, features }
185+
}, [flows, maxPersons, mapView.zoom, direction])
186+
187+
// One label per sector at the arrow tip of that sector's largest mode.
188+
// Avoids cluttering the map with up to 5 labels per sector.
189+
const sectorLabels = useMemo(() => {
190+
type Item = { sector: Sector; pos: LatLon; persons: number; topMode: NycMode; topModePersons: number }
191+
const bySector = new Map<Sector, Item>()
192+
for (const f of flows) {
193+
const key = `${f.sector}|${f.mode}` as const
194+
const paths = SECTOR_MODE_PATHS[key]
195+
if (!paths || paths.length === 0) continue
196+
const path = ENTRY_PATHS[paths[0]]
197+
const tip = direction === 'leaving' ? path[0] : path[path.length - 1]
198+
const existing = bySector.get(f.sector)
199+
if (!existing) {
200+
bySector.set(f.sector, {
201+
sector: f.sector, pos: tip, persons: f.persons,
202+
topMode: f.mode, topModePersons: f.persons,
203+
})
204+
} else {
205+
existing.persons += f.persons
206+
if (f.persons > existing.topModePersons) {
207+
existing.pos = tip
208+
existing.topMode = f.mode
209+
existing.topModePersons = f.persons
210+
}
211+
}
212+
}
213+
return [...bySector.values()]
214+
}, [flows, direction])
215+
216+
const dirLabel = direction === 'entering' ? 'Entering CBD' : 'Leaving CBD'
217+
const timeLabel = timePeriod === 'peak_1hr' ? (direction === 'entering' ? '8-9am' : '5-6pm') :
218+
timePeriod === 'peak_period' ? (direction === 'entering' ? '7-10am' : '4-7pm') : '24hr'
219+
const totalPersons = flows.reduce((s, f) => s + f.persons, 0)
220+
221+
return (
222+
<div className="geo-sankey">
223+
<h2>NYC-wide CBD flows</h2>
224+
<p className="chart-subtitle">{dirLabel}, {timeLabel}, {Math.round(totalPersons / 1000)}k total · 2024</p>
225+
<div ref={containerRef} style={{ width: '100%', height: 820, position: 'relative', borderRadius: 8, overflow: 'hidden' }}>
226+
<MapGL
227+
ref={mapRef}
228+
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
229+
style={{ width: '100%', height: '100%' }}
230+
longitude={mapView.lng}
231+
latitude={mapView.lat}
232+
zoom={mapView.zoom}
233+
onMove={e => setMapView({ lat: e.viewState.latitude, lng: e.viewState.longitude, zoom: e.viewState.zoom })}
234+
onLoad={() => mapRef.current?.getMap()?.scrollZoom.disable()}
235+
interactiveLayerIds={['flow-fills']}
236+
onMouseMove={e => {
237+
const f = e.features?.[0]
238+
setHoveredKey((f?.properties?.sectorMode as string) ?? null)
239+
}}
240+
onMouseLeave={() => setHoveredKey(null)}
241+
>
242+
<Source id="flows" type="geojson" data={geojson}>
243+
<Layer
244+
id="flow-fills"
245+
type="fill"
246+
paint={flowFillPaint({
247+
'fill-opacity': hoveredKey
248+
? ['case', ['==', ['get', 'sectorMode'], hoveredKey], 0.95, 0.18]
249+
: 0.82,
250+
}) as any}
251+
/>
252+
</Source>
253+
{/* One inline label per sector at the largest-mode arrow tip.
254+
Anchor placement is hand-tuned per sector so labels don't pile
255+
up at the Manhattan core. */}
256+
{sectorLabels.map(s => {
257+
const a = SECTOR_ANCHORS[s.sector]
258+
return (
259+
<Marker
260+
key={s.sector}
261+
longitude={s.pos[1]}
262+
latitude={s.pos[0]}
263+
anchor={a.anchor}
264+
offset={a.offset}
265+
style={{ pointerEvents: 'none' }}
266+
>
267+
<div
268+
style={{
269+
background: 'rgba(0,0,0,0.82)', color: '#eee',
270+
padding: '3px 8px', borderRadius: 4, fontSize: 12,
271+
whiteSpace: 'nowrap', borderLeft: `3px solid ${MODE_COLORS[s.topMode]}`,
272+
}}
273+
>
274+
<strong>{SECTOR_LABELS[s.sector]}</strong>{' '}
275+
<span style={{ opacity: 0.85 }}>{(s.persons / 1000).toFixed(0)}k</span>
276+
</div>
277+
</Marker>
278+
)
279+
})}
280+
</MapGL>
281+
{/* Floating tooltip when a flow is hovered */}
282+
{hoveredKey && (() => {
283+
const f = flows.find(x => `${x.sector}|${x.mode}` === hoveredKey)
284+
if (!f) return null
285+
const sectorPretty = ({
286+
nj: 'NJ', queens: 'Queens', brooklyn: 'Brooklyn',
287+
'60th_street': '60th St', staten_island: 'Staten Island', roosevelt_island: 'Roosevelt Is.',
288+
} as Record<string, string>)[f.sector] ?? f.sector
289+
return (
290+
<div style={{
291+
position: 'absolute', top: 10, left: 10, padding: '6px 10px',
292+
background: 'rgba(0,0,0,0.78)', color: '#eee', borderRadius: 4, fontSize: 13,
293+
pointerEvents: 'none',
294+
}}>
295+
<strong>{sectorPretty} · {f.mode}</strong>: {f.persons.toLocaleString()} persons
296+
</div>
297+
)
298+
})()}
299+
</div>
300+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center', alignItems: 'center', marginTop: 8 }}>
301+
<Toggle options={DIR_OPTIONS} value={direction} onChange={setDirection} />
302+
<Toggle options={TIME_OPTIONS} value={timePeriod} onChange={setTimePeriod} />
303+
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
304+
{NYC_MODE_ORDER.map(m => (
305+
<span key={m} style={{ marginRight: 12 }}>
306+
<span style={{
307+
display: 'inline-block', width: 10, height: 10, borderRadius: 2,
308+
background: MODE_COLORS[m], marginRight: 4, verticalAlign: 'middle',
309+
}} />
310+
{m}
311+
</span>
312+
))}
313+
</span>
314+
</div>
315+
</div>
316+
)
317+
}

0 commit comments

Comments
 (0)