forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMapView.tsx
More file actions
643 lines (604 loc) · 28.5 KB
/
Copy pathMapView.tsx
File metadata and controls
643 lines (604 loc) · 28.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
// The map canvas itself. Chrome (header, ribbon, tab bar, controls) composes
// around this rather than living inside it.
//
// Everything delicate here is lifecycle. A map built twice means two WebGL
// contexts, two GPS watchers and doubled range reads against an on-device
// archive that can be 1.18 GB; a map never torn down leaks all of the same.
// React StrictMode mounts, unmounts and remounts on purpose in development to
// surface exactly that, so the effect below is written to survive it: build
// once per effect run, and fully undo the build on cleanup.
import { useEffect, useRef, useState } from 'react'
import { Map as MapLibreMap } from 'maplibre-gl'
// MapLibre's own stylesheet, and not optional. Everything the map puts on
// itself - compass, locate, the scale bar, the zoom buttons - is positioned by
// this file and by nothing else. Without it `.maplibregl-canvas` is not
// absolute, so the control container follows the canvas in normal flow instead
// of sitting over it and lands past the bottom edge of the map: in a 1280x800
// window the whole stack sat at y=804, four pixels below the fold, with the
// document growing a scrollbar to reach it. The map drew correctly and had
// nothing on it, which is exactly what it looked like.
//
// chrome.css's `.map-view .maplibregl-ctrl button` rule is an OVERRIDE of a
// size set here (WIREFRAMES.md's 42px against MapLibre's 29px), which is the
// tell that this import was forgotten rather than declined.
import 'maplibre-gl/dist/maplibre-gl.css'
import { registerPMTilesProtocol } from './protocol'
import { registerBasemapProtocol } from './basemap'
import { registerMapWorker } from './mapWorker'
import { attachMapAppearance, attachTrailData, buildMapStyle } from './style'
import { attachMapDetail } from './mapDetail'
import { attachContourUnits, registerTerrain } from './contours'
import { attachLiveSourceHealth, type SourceReport } from './liveSourceHealth'
import { attachElevationLabelUnits } from './liveTopo'
import type { TerrainUrls } from './terrain'
import { attachMapChrome, type ScaleUnits } from './mapChrome'
import type { ResolvedTheme } from '../lib/theme'
import { attachPoiData, attachPoiFilter, attachPoiIcons } from './poiLayers'
import {
attachAtcUpdateData,
attachAtcUpdateTaps,
type AtcUpdatePoint,
} from './atcUpdateLayers'
import { attachClosureData, type ClosureBand } from './closureLayers'
import { attachDroughtData, setDroughtVisible, type DroughtBand } from './droughtLayers'
import { attachWarningData, attachWarningIcon, type WarningPoint } from './warningLayers'
import { attachPoiTaps } from './poiTaps'
import type { BoundingBox, MapPoint } from '../lib/legendContents'
import type {
BackgroundSource,
LayerDetailLevel,
MapStyle,
Theme,
} from '../lib/userPreferences'
import { openingZoomFloor, type ArchiveZooms } from '../lib/archiveCoverage'
export interface MapViewProps {
/** `pmtiles://` URL for the downloaded topo archive. */
topoArchiveUrl: string
/**
* Local URL of the exported trail lines.
*
* Seeds the style so the opening frame already has the trail on it, and is
* then re-pointed in place whenever it changes - the same two-step `theme`
* and `pois` get, and for the same reason. The shell mints this URL when the
* lines come back from IndexedDB, a beat after the map is built.
*/
trailsUrl: string
/** Which background to draw - see lib/userPreferences.ts. */
background?: BackgroundSource
/**
* The POIs to draw - the same array the legend counts. Pushed onto the live
* map rather than baked into the style, because they are read from IndexedDB
* well after the map is built and swapping a style out drops the WebGL
* context with it.
*/
pois?: readonly MapPoint[]
/**
* POI categories the hiker has hidden from the legend. Applied as a filter
* on the pin layer, so hiding a category costs a filter, not a rebuild.
*/
hiddenTypes?: ReadonlySet<string>
/**
* The legend's "Verified?" toggle: draw only waypoints somebody has
* confirmed exist. Rides the same filter as {@link hiddenTypes} rather than
* a second one, so the two cannot fight over the layer.
*/
verifiedOnly?: boolean
/**
* Closed stretches of trail, already in map coordinates.
*
* Coordinates rather than mile markers, deliberately. Turning "mile 1,408.2
* to 1,408.6" into a line needs the centerline index, which the shell holds
* and this component has no business asking for - the same division that
* keeps `pois` a list of points rather than a POI database. See
* closureLayers.ts's `closureBands`.
*/
closures?: readonly ClosureBand[]
/**
* This week's drought bands, already as published polygons (#720).
*
* Unlike `closures`, these need no coordinate work in the shell: the
* pipeline ships real geometry rather than mile markers, so there is no
* centerline index in the way and the features go straight onto the source.
*/
drought?: readonly DroughtBand[]
/** Whether the hiker has the drought wash switched on. Separate from the
* data for the reason droughtLayers.ts gives: the bands arrive once and
* the switch moves whenever somebody taps it. */
showDrought?: boolean
/**
* The ATC's own trail updates, in the same coordinates and drawn at the
* same weight - a second band source rather than more features in
* `closures`, because the two carry different rhythms and a tap has to be
* able to say which kind it landed on (map/atcUpdateLayers.ts, #461).
*/
atcUpdates?: readonly ClosureBand[]
/** The same notices that name a single mile rather than a stretch, drawn as
* dots. Most of what the ATC publishes is one of these. */
atcUpdatePoints?: readonly AtcUpdatePoint[]
/** A tap landed on an ATC band, by band id. The shell decides what to show
* - this component deliberately does not know what a sheet is. */
onSelectAtcUpdate?: (bandId: string) => void
/**
* Moderator-escalated warnings, as points. NEVER a notification - see the
* header of warningLayers.ts.
*/
warnings?: readonly WarningPoint[]
/** Initial centre only - later camera moves go through the map imperatively. */
center?: [number, number]
/** Initial zoom only. */
zoom?: number
/**
* Opening view as a bounding box, `[[west, south], [east, north]]`. Takes
* precedence over `center`/`zoom`, and is the better way to say "show all of
* this" - the zoom that fits a box depends on the size of the screen, so
* picking one here would frame it differently on every phone.
*/
bounds?: [[number, number], [number, number]]
/**
* What the downloaded archive's own header says it covers, when it is known.
*
* Used for exactly one thing: keeping the opening camera out of the zooms
* the archive has no tiles for, which on the offline background is the
* difference between a map and blank paper (#216). It never constrains what
* the hiker can do afterwards - zooming out past the download is allowed,
* and the chrome says so rather than the map refusing.
*/
/**
* Room to leave around {@link bounds} when fitting it, per side.
*
* A number is the same inset all round and is what almost every caller wants.
* An object is for the case this exists for: something is drawn OVER the map,
* so the box has to be framed against the part of the canvas that is actually
* visible rather than against the whole of it. First run is that case - the
* onboarding card covers most of the screen, and without a bottom inset the
* trail is fitted to the full canvas and then three quarters of it is hidden,
* leaving a fragment of Maine in the corner above the card (#719 review).
*/
boundsPadding?: number | { top: number; bottom: number; left: number; right: number }
archiveZooms?: ArchiveZooms | null
/**
* A pin was tapped, by POI id - or the bare map was, reported as null so
* the shell can dismiss whatever the last pin opened. Must be stable across
* renders (useCallback), like `onViewportChange` - an inline function would
* re-bind the map's listeners on every render of the parent.
*
* Only the id: this component knows what is drawn on the map, not what the
* app knows about it, and looking a POI up is the shell's job.
*/
onSelectPoi?: (id: string | null) => void
/** Web only; touch platforms rely on pinch (see mapChrome.ts). */
showZoomButtons?: boolean
units?: ScaleUnits
/** Whether the hiker has location switched on - which decides whether the
* locate control is offered at all (#312, and see mapChrome.ts for the
* three things attaching it unconditionally cost). Defaults to false, so a
* caller that has not thought about it does not open a GPS watch. */
locationEnabled?: boolean
/**
* Which theme the canvas is drawn in - see map/style.ts's mapBackdrop.
*
* Resolved by the shell (lib/useTheme.ts) rather than read here, for the
* reason `units` is: this component draws the map, and a hiker's preference
* is the shell's to know. It also has to be the same answer the chrome
* around the canvas is using, and two independent reads of one media query
* is how a dark app ends up around a light map.
*/
theme?: ResolvedTheme
/**
* The stored theme preference behind `theme`'s resolution, so the sheet
* can tell a CHOSEN dark from a sunset one: field's auto-dark is
* night_hike, its chosen dark is its own maximum-contrast night sheet -
* see liveTopo.ts's sheetVariant.
*/
themeChoice?: Theme
/**
* Which of the sheet's palettes to draw, and whether night_hike's red-light
* sub-mode is armed (MAP_STYLE_SPEC.md). Handed down like `theme` and
* applied the same two ways: seeded into the built style for a correct
* first frame, repainted in place on change.
*/
mapStyle?: MapStyle
redLight?: boolean
/**
* How much of the sheet to draw - see map/mapDetail.ts. Pure layer
* visibility on the live sheet; the downloaded raster has no layers to
* thin and ignores it.
*/
detail?: LayerDetailLevel
/**
* What is on screen now, so the legend can describe it. Must be stable
* across renders (useCallback) - an inline function would re-subscribe on
* every render of the parent.
*/
onViewportChange?: (bbox: BoundingBox) => void
/**
* The live map, handed over on build and `null` on teardown, so the shell
* can move the camera imperatively. `center` cannot do that job - it seeds
* the opening view only, and the first GPS fix usually lands after it.
*/
onMapReady?: (map: MapLibreMap | null) => void
/**
* Which of the background's sources reported an error and never drew
* anything - see map/liveSourceHealth.ts.
*
* Reported rather than rendered: this component draws the map, and what the
* hiker is told about it belongs to the chrome. Must be stable across
* renders (a `useState` setter already is), like `onViewportChange` - an
* inline function would re-attach the listeners on every parent render.
*
* The report carries both what never arrived and what has actually drawn,
* plus `withdrawn` for the one report this map sends as it is torn down. A
* caller drawing only this map's chrome needs the first; one that remembers
* a failure across screens needs all three (#352).
*/
onLiveSourceHealth?: (report: SourceReport) => void
}
const DEFAULT_CENTER: [number, number] = [-77.1, 39.3]
const DEFAULT_ZOOM = 12
/** Breathing room around a fitted box, on every side, when the caller asks for
* nothing more specific. */
const FIT_PADDING = 24
// Module-level, so the default is the SAME value on every render. A `= []`
// default parameter would hand over a fresh identity each time and re-run the
// effect that depends on it, which for the POI source means re-serialising
// every pin on the trail on every render of the map screen.
const NO_POIS: readonly MapPoint[] = []
const NOTHING_HIDDEN: ReadonlySet<string> = new Set()
const NO_CLOSURES: readonly ClosureBand[] = []
const NO_DROUGHT: readonly DroughtBand[] = []
const NO_ATC_UPDATES: readonly ClosureBand[] = []
const NO_ATC_POINTS: readonly AtcUpdatePoint[] = []
const NO_WARNINGS: readonly WarningPoint[] = []
export function MapView({
topoArchiveUrl,
trailsUrl,
background = 'hiking_topo_live',
pois = NO_POIS,
hiddenTypes = NOTHING_HIDDEN,
verifiedOnly = false,
closures = NO_CLOSURES,
drought = NO_DROUGHT,
showDrought = false,
atcUpdates = NO_ATC_UPDATES,
atcUpdatePoints = NO_ATC_POINTS,
onSelectAtcUpdate,
warnings = NO_WARNINGS,
onSelectPoi,
center,
zoom,
bounds,
archiveZooms = null,
boundsPadding = FIT_PADDING,
showZoomButtons = false,
units = 'imperial',
locationEnabled = false,
theme = 'light',
themeChoice = 'auto',
mapStyle = 'field',
redLight = false,
detail = 'standard',
onViewportChange,
onMapReady,
onLiveSourceHealth,
}: MapViewProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<MapLibreMap | null>(null)
/**
* Which trail lines the live map's source is already pointing at, or null
* when there is no map.
*
* Seeding the style AND pushing the same URL in afterwards would fetch and
* re-tile the lines twice for every map built - twelve megabytes of
* coordinates, and measurably more worker time than the whole low-zoom
* tiling costs once (see the `tolerance` note in map/style.ts). So the
* construction below records what it seeded, and the attach further down
* writes only when the answer has actually changed.
*/
const drawnTrailsUrl = useRef<string | null>(null)
// `center`/`zoom` are deliberately NOT dependencies. A parent writing
// center={[x, y]} inline hands over a new array identity on every render; if
// that drove this effect the map would be destroyed and rebuilt each time the
// parent re-rendered. They seed the initial camera, and nothing more.
useEffect(() => {
const container = containerRef.current
// Unreachable, and kept for the type checker: the div this ref is attached
// to is rendered unconditionally, and an effect only runs after that div is
// in the DOM. Ignored for coverage rather than covered, since there is no
// way to render this component without its own container.
/* v8 ignore next */
if (container === null) return
// Before anything else: MapLibre 6 looks for its own worker next to the
// bundle, where no bundler ever puts it, and a map with no worker parses no
// tiles at all - see mapWorker.ts. Every layer below depends on this line.
registerMapWorker()
// The style resolves pmtiles:// URLs, so the protocol has to exist first.
registerPMTilesProtocol()
// And basemap:// URLs - the hiking sheet's local-first tile resolution
// (basemap.ts). Registered unconditionally like the pmtiles scheme, and
// unlike the terrain protocols below: it reaches the network only as the
// per-tile fallthrough of a source the chosen style actually declares,
// so there is no behind-the-back request to guard against.
registerBasemapProtocol()
// Same contract for the DEM and contour protocols, with one difference
// worth being deliberate about: this one reaches the network, so it is
// only set up when a background that uses it was actually asked for.
// Someone who chose the downloaded archive to stay off the network should
// not have a DEM protocol registered behind their back.
//
// Best-effort, and narrower than it used to claim. This comment said the
// branch fires when "a Web Worker and a blob URL" are unavailable; neither
// is true. contours.ts feature-detects Worker and falls back to the main
// thread rather than throwing, and the worker it constructs is the app's
// own emitted asset (demWorker.ts, since #187) rather than the library's
// blob. What actually lands here is a Content-Security-Policy whose
// worker-src refuses that construction - no such policy is served today,
// so this is a guard against a future hardening rather than a path
// anyone is on.
//
// Either way the outcome is a map without contours, not no map: a failure
// here costs terrain and nothing else. That is now true of what gets
// built, too - style.ts used to drop the entire live sheet along with it.
let terrain: TerrainUrls | undefined
if (background === 'hiking_topo_live') {
try {
terrain = registerTerrain(units)
} catch (error) {
console.warn('Terrain unavailable; drawing the background without it.', error)
}
}
const created = new MapLibreMap({
container,
style: buildMapStyle({
topoArchiveUrl,
trailsUrl,
background,
terrain,
units,
theme,
themeChoice,
mapStyle,
redLight,
}),
// `bounds` wins where it is given: MapLibre works out the zoom that fits
// the box on this particular screen, which is the whole point of asking
// for a box rather than a zoom number.
...(bounds === undefined
? { center: center ?? DEFAULT_CENTER, zoom: zoom ?? DEFAULT_ZOOM }
: { bounds, fitBoundsOptions: { padding: boundsPadding } }),
// Attribution is rendered by the app's own chrome, positioned per
// WIREFRAMES.md, rather than by MapLibre's default control.
attributionControl: false,
})
// Recorded, not assumed: the style above was seeded with whatever this
// render's lines are, so the attach below has nothing to do until they
// change. Cleared on teardown so the next map is seeded and recorded
// together, and a rebuild can never inherit the previous map's answer.
drawnTrailsUrl.current = trailsUrl
setMap(created)
return () => {
drawnTrailsUrl.current = null
created.remove()
setMap(null)
}
// Intentionally omitting `center`/`zoom` - see the note above. Including
// them would rebuild the whole map whenever a parent re-rendered with an
// inline array, which is the bug this omission exists to avoid.
//
// `units` is omitted for the same reason, even though it seeds the contour
// interval: switching to metric must not cost a WebGL context. The units
// effect below re-points the contour source in place instead.
//
// `theme`, `mapStyle` and `redLight` are omitted on exactly that pattern
// (MAP_STYLE_SPEC.md spells it as a requirement: appearance never rebuilds
// the map). They seed the backdrop, the archive's dimming, the trail ink
// and the sheet's palette so a cold start under a dark appearance is dark
// in its first frame, and the appearance effect below repaints all of it
// in place for every change after that. A hiker tapping "Dark" while
// walking must not lose the map they were reading.
//
// `trailsUrl` is omitted on that same pattern, and it is the one that used
// to cost a cold start a whole extra map. The lines are read out of
// IndexedDB a beat after the map is built, so depending on this URL meant
// every launch tore down the map a second after it appeared - a blink and
// a re-frame, for data a GeoJSON source can take in place. It still seeds
// the style, so the first frame already has the trail on it; the effect
// below re-points the source for every change after that.
//
// Only `background` remains, and it earns the rebuild: the two backgrounds
// are different sources and a different layer stack, not a different value
// in the same one. App.tsx holds its first render until it knows which one
// to ask for, so switching is the hiker's doing rather than a fact about
// the phone arriving late.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topoArchiveUrl, background])
// Keeps the OPENING camera out of the zooms the download cannot draw (#216).
//
// Its own effect rather than part of the construction above, for three
// reasons that all point the same way. It reads the zoom MapLibre actually
// settled on, so nothing here has to reimplement how a box is fitted to a
// screen. It re-runs when the archive's header lands, which is a tick AFTER
// the map is built and would otherwise be missed entirely. And it does that
// without the archive's coverage becoming a dependency of the construction
// effect, where a late-arriving header would tear down a live WebGL context
// and rebuild it.
//
// Only the zoom is touched. The centre stays exactly where fitBounds put it,
// which is what makes this defensible at all: App.tsx's opening view is
// built around not making a confident-looking claim about where the hiker is
// (Harpers Ferry was removed for precisely that), and moving the scale in
// without moving the centre claims nothing new.
//
// Gated on `bounds`, which the shell supplies only for the very first view -
// once there is a remembered camera it passes centre and zoom instead. So
// this cannot fight a hiker who has deliberately zoomed out to look at the
// whole trail; it only decides where they start.
useEffect(() => {
if (map === null || bounds === undefined || background !== 'usgs_topo_offline') return
const floor = openingZoomFloor(archiveZooms, map.getZoom())
if (floor !== null) map.setZoom(floor)
}, [map, bounds, background, archiveZooms])
// Chrome lives in its own effect so that a preference which only affects the
// controls - the scale bar's units, the zoom buttons - re-attaches three
// controls instead of tearing down and rebuilding the entire map underneath
// the hiker.
useEffect(() => {
if (map === null) return
return attachMapChrome(map, { showZoomButtons, units, locationEnabled })
}, [map, showZoomButtons, units, locationEnabled])
// The appearance's half of the same promise, and the widest one: it
// repaints the backdrop, the archive's dimming, the trail's ink and every
// colour on the live sheet - see map/style.ts's attachMapAppearance.
useEffect(() => {
if (map === null) return
return attachMapAppearance(map, { theme, themeChoice, mapStyle, redLight })
}, [map, theme, themeChoice, mapStyle, redLight])
// And the detail level's: which of the sheet's layers are drawn at all.
// Pure visibility (map/mapDetail.ts), so a hiker thinning the sheet keeps
// the camera, the tiles in flight and the WebGL context, like every other
// preference on this screen.
useEffect(() => {
if (map === null) return
return attachMapDetail(map, detail)
}, [map, detail])
// The contours' half of that same promise. The scale bar can just be
// re-created with new units; the contour source has to be re-pointed at a
// different tile URL, which is what this does - see contours.ts.
useEffect(() => {
if (map === null) return
return attachContourUnits(map, units)
}, [map, units])
// And the labels' half: the contour suffix and the peak elevation field
// are baked into the style as layout, so re-pointing the tiles alone
// leaves metric values under imperial punctuation - see liveTopo.ts.
useEffect(() => {
if (map === null) return
return attachElevationLabelUnits(map, units)
}, [map, units])
// The trail lines, pushed onto the live map for the same reason the POIs
// below are: they come out of IndexedDB after the map exists, and they are
// one GeoJSON source, which can simply be handed a new URL. Its own effect
// rather than folded in with the pins, because the two arrive from separate
// reads and a re-tiling of twelve megabytes of coordinates must not ride
// along with a legend tap.
useEffect(() => {
if (map === null || drawnTrailsUrl.current === trailsUrl) return
drawnTrailsUrl.current = trailsUrl
return attachTrailData(map, trailsUrl)
}, [map, trailsUrl])
// Three separate effects rather than one, because they change on different
// clocks: the pin images are built once and never again, while the source and
// the filter both move when the hiker taps a legend row. Folding them together
// would re-register sixty rasterised badges on every one of those taps.
//
// The images are the one that must NOT re-run - that is the whole of this
// split. The other two now share a clock (see below) and are still two
// effects, because a POI download landing should not re-run a `setFilter` and
// the pair reads as what it is: two different questions about the same tap.
useEffect(() => {
if (map === null) return
return attachPoiIcons(map)
}, [map])
// The hidden set is in BOTH of the next two effects, and deliberately. The
// filter decides which pins are drawn; the source decides which POIs get a
// pin to be drawn at all, and a site folds its members away only behind a pin
// the filter is going to keep (#607). So a legend tap rebuilds the features as
// well as re-filtering the layer - features/POI_SITES.md §6 asked for exactly
// that, and 2,800 points is the cost it weighed.
useEffect(() => {
if (map === null) return
return attachPoiData(map, pois, { hiddenTypes, verifiedOnly })
}, [map, pois, hiddenTypes, verifiedOnly])
useEffect(() => {
if (map === null) return
return attachPoiFilter(map, hiddenTypes, verifiedOnly)
}, [map, hiddenTypes, verifiedOnly])
// The safety overlays, on the same three-clocks reasoning as the POIs above:
// the warning pin image is built once, and the two datasets arrive from the
// network on their own schedules and refuse independently (App.tsx). Folding
// them together would mean a closures read that came back re-rasterising a
// 88px pin, and either read failing would hold the other off the map.
useEffect(() => {
if (map === null) return
return attachWarningIcon(map)
}, [map])
useEffect(() => {
if (map === null) return
return attachClosureData(map, closures)
}, [map, closures])
// Two effects rather than one, and deliberately: the bands arrive from the
// network once and the switch moves whenever a hiker taps it. Folding them
// together would re-push the polygons on every tap of the toggle.
//
// Neither is passed to `buildMapStyle` at creation, though the option
// exists there for tests and for reading the stack in one place. Threading
// the switch through the style would put it in the style's rebuild
// dependencies, and a hiker flipping a background tint would get the whole
// map torn down and rebuilt - the exact cost `setDroughtVisible` avoids.
// The layer is built hidden and this effect shows it on the next frame.
useEffect(() => {
if (map === null) return
return attachDroughtData(map, drought)
}, [map, drought])
useEffect(() => {
if (map === null) return
return setDroughtVisible(map, showDrought)
}, [map, showDrought])
useEffect(() => {
if (map === null) return
return attachAtcUpdateData(map, atcUpdates, atcUpdatePoints)
}, [map, atcUpdates, atcUpdatePoints])
useEffect(() => {
if (map === null) return
return attachWarningData(map, warnings)
}, [map, warnings])
// Taps are their own effect for the same reason: this one re-binds when the
// shell hands over a different handler, which has nothing to do with the
// pins themselves and must not re-push the POI source to do it.
useEffect(() => {
if (map === null || onSelectPoi === undefined) return
return attachPoiTaps(map, onSelectPoi)
}, [map, onSelectPoi])
useEffect(() => {
if (map === null || onSelectAtcUpdate === undefined) return
return attachAtcUpdateTaps(map, onSelectAtcUpdate)
}, [map, onSelectAtcUpdate])
useEffect(() => {
if (map === null || onViewportChange === undefined) return
const report = () => {
const bounds = map.getBounds()
onViewportChange({
west: bounds.getWest(),
south: bounds.getSouth(),
east: bounds.getEast(),
north: bounds.getNorth(),
})
}
// Reported once up front as well as on every move, so the legend is
// correct for the opening view rather than only after the first pan.
report()
map.on('moveend', report)
return () => {
map.off('moveend', report)
}
}, [map, onViewportChange])
// Its own effect, like every other attach here, so that a shell which starts
// caring about source health does not cost a WebGL context to wire up.
useEffect(() => {
if (map === null || onLiveSourceHealth === undefined) return
return attachLiveSourceHealth(map, onLiveSourceHealth)
}, [map, onLiveSourceHealth])
useEffect(() => {
if (onMapReady === undefined) return
onMapReady(map)
return () => onMapReady(null)
}, [map, onMapReady])
return (
<div
ref={containerRef}
className="map-view"
role="region"
aria-label="Trail map"
data-testid="map-view"
/>
)
}