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
137 lines (123 loc) · 4.72 KB
/
Copy pathMapView.tsx
File metadata and controls
137 lines (123 loc) · 4.72 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
// 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'
import { registerPMTilesProtocol } from './protocol'
import { buildMapStyle } from './style'
import { attachMapChrome, type ScaleUnits } from './mapChrome'
import type { BoundingBox } from '../lib/legendContents'
export interface MapViewProps {
/** `pmtiles://` URL for the downloaded topo archive. */
topoArchiveUrl: string
/** Local URL of the exported trail lines. */
trailsUrl: string
/** Initial centre only - later camera moves go through the map imperatively. */
center?: [number, number]
/** Initial zoom only. */
zoom?: number
/** Web only; touch platforms rely on pinch (see mapChrome.ts). */
showZoomButtons?: boolean
units?: ScaleUnits
/**
* 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
}
const DEFAULT_CENTER: [number, number] = [-77.1, 39.3]
const DEFAULT_ZOOM = 12
export function MapView({
topoArchiveUrl,
trailsUrl,
center,
zoom,
showZoomButtons = false,
units = 'imperial',
onViewportChange,
onMapReady,
}: MapViewProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<MapLibreMap | 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
if (container === null) return
// The style resolves pmtiles:// URLs, so the protocol has to exist first.
registerPMTilesProtocol()
const created = new MapLibreMap({
container,
style: buildMapStyle({ topoArchiveUrl, trailsUrl }),
center: center ?? DEFAULT_CENTER,
zoom: zoom ?? DEFAULT_ZOOM,
// Attribution is rendered by the app's own chrome, positioned per
// WIREFRAMES.md, rather than by MapLibre's default control.
attributionControl: false,
})
setMap(created)
return () => {
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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topoArchiveUrl, trailsUrl])
// Chrome lives in its own effect so that changing a display preference -
// switching the scale bar to metric, say - 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 })
}, [map, showZoomButtons, units])
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])
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"
/>
)
}