forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontours.ts
More file actions
177 lines (165 loc) · 7.76 KB
/
Copy pathcontours.ts
File metadata and controls
177 lines (165 loc) · 7.76 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
// Turns terrain.ts's numbers into live tiles: registers maplibre-contour's two
// protocols and hands back the URLs a style points its terrain sources at.
//
// The runtime half of the pair - see terrain.ts for what the constants mean
// and why the DEM is where it is. The split exists so that building a style
// costs nothing but arithmetic; only a real map ever loads this file.
//
// Both protocols come off ONE DemSource on purpose. It owns the decoded-tile
// cache, so the hillshade and the contour generator read the same elevation
// tiles out of one place rather than each fetching its own copy - which is the
// difference between one DEM download and two for the same screen.
// Named import rather than a default: maplibre-gl v6 ships no default export,
// and `setupMaplibre` only ever calls `addProtocol` off whatever it is handed.
// Handing it exactly that one function is both honest about what is used and
// what the test double already provides.
import { addProtocol, type Map as MapLibreMap, type VectorTileSource } from 'maplibre-gl'
import mlcontour from 'maplibre-contour'
import {
CONTOUR_ELEVATION_KEY,
CONTOUR_LAYER,
CONTOUR_LEVEL_KEY,
CONTOUR_SOURCE_ID,
CONTOUR_THRESHOLDS,
DEM_MAX_ZOOM,
DEM_TILE_URL,
METRES_TO_FEET,
type ContourUnits,
type TerrainUrls,
} from './terrain'
import { demGetTile } from './demTiles'
import { WorkerDemManager } from './demRpc'
import { whenStyleReady } from './styleReady'
/**
* jsdom has no `Worker`, and neither would a browser old enough to be worth
* guessing about. Feature-detected rather than branched on the environment, so
* the same line is honest in both: where a worker exists, contour generation
* stays off the UI thread (it is marching squares over a quarter-million
* samples per tile, which visibly janks a pan if run inline), and where it does
* not, the work still happens, just on the main thread.
*/
function workerAvailable(): boolean {
return typeof Worker === 'function'
}
// One DemSource per page, for the same reason protocol.ts keeps one PMTiles
// protocol: the instance owns both the cache and the protocol registration, so
// a second would double the memory and leave MapLibre holding a handler whose
// cache the other one had already warmed.
let source: InstanceType<typeof mlcontour.DemSource> | null = null
function demSource(): InstanceType<typeof mlcontour.DemSource> {
if (source !== null) return source
// `worker: false` even where a Worker exists, because the manager the
// DemSource would build for `worker: true` is the one thing in this stack
// that cannot be taught to read the downloaded DEM package (#187): its
// worker fetches with a plain fetch(url), and the getTile replacement the
// library exposes is a function, which cannot cross into a worker the
// library constructed. So the DemSource is built with the main-thread
// manager and the manager is then swapped by capability:
//
// Worker exists the app's own DEM worker (demWorker.ts) - the same
// exported LocalDemManager machinery, constructed
// worker-side WITH demTiles.ts's local-first getTile.
// Fetch, decode and isoline generation all stay off
// the UI thread, exactly as with the stock worker.
// no Worker the DemSource's own LocalDemManager, its getTile
// replaced in place (a public, typed field). Same
// resolution, main thread - jsdom, mostly.
//
// Either way every elevation read - the hillshade's and the contour
// generator's, through the one shared cache - goes archive-first.
const created = new mlcontour.DemSource({
url: DEM_TILE_URL,
encoding: 'terrarium',
maxzoom: DEM_MAX_ZOOM,
worker: false,
})
if (workerAvailable()) {
created.manager = new WorkerDemManager(
new Worker(new URL('./demWorker.ts', import.meta.url), { type: 'module' }),
)
} else {
;(created.manager as InstanceType<typeof mlcontour.LocalDemManager>).getTile =
demGetTile
}
created.setupMaplibre({ addProtocol } as Parameters<typeof created.setupMaplibre>[0])
source = created
return created
}
/**
* Registers the DEM and contour protocols and returns the URLs to point a
* style's terrain sources at.
*
* Idempotent, and safe to call before every map build - MapView does exactly
* that, the same way it calls registerPMTilesProtocol().
*/
export function registerTerrain(units: ContourUnits = 'imperial'): TerrainUrls {
const dem = demSource()
return {
demUrl: dem.sharedDemProtocolUrl,
contourTilesUrl: dem.contourProtocolUrl({
// The DEM is in metres whoever is reading it, so the conversion happens
// here, once - every threshold and every label downstream is already in
// the unit that will be shown.
multiplier: units === 'imperial' ? METRES_TO_FEET : 1,
thresholds: CONTOUR_THRESHOLDS[units],
elevationKey: CONTOUR_ELEVATION_KEY,
levelKey: CONTOUR_LEVEL_KEY,
contourLayer: CONTOUR_LAYER,
// Read one zoom out and use a quadrant of it: fewer, larger reads for the
// same coverage, and smoother lines where the DEM is overzoomed.
overzoom: 1,
}),
}
}
/**
* Switches the contour interval between feet and metres on a LIVE map, without
* rebuilding it.
*
* The interval is encoded in the contour source's own tile URL, so 40ft lines
* and 10m lines really are different tiles rather than a repaint - which makes
* the obvious implementation (put `units` in the map-building effect's
* dependencies) tear down the WebGL context and rebuild the whole map when
* someone toggles metric. MapView deliberately keeps display preferences out
* of that effect so a settings change never pulls the map out from under a
* hiker, and this is what lets the contours honour it too: `setTiles` re-points
* the existing source in place.
*
* A no-op when the URL has not actually changed, so mounting does not
* immediately invalidate the tiles the style just asked for.
*
* Best-effort in the same way and for the same reason as poiLayers.ts's
* attach helpers: it needs a loaded style, which is a later and more fragile
* moment than construction, and the cost of failing is contours at the
* previous interval - never a broken map.
*/
export function attachContourUnits(map: MapLibreMap, units: ContourUnits): () => void {
return whenStyleReady(
map,
// The contour source is both the precondition and the target. It is also
// legitimately absent whenever the style's background is not the live one
// - see below - so this waits for a style that has it, and the detach ends
// the wait if none ever does.
() => map.getSource(CONTOUR_SOURCE_ID) !== undefined,
() => {
const contours = map.getSource(CONTOUR_SOURCE_ID) as VectorTileSource | undefined
// Absent whenever the background in the style is not the live one, which
// is a normal state rather than a failure - nothing to retune.
//
// `setTiles` is feature-checked as well as the source, because getSource
// answers with the union of every source kind and only a vector one can
// be re-pointed. Same guard poiLayers.ts makes before calling setData,
// and for the same reason: the id is ours, but the shape behind it is
// whatever the current style put there.
if (contours === undefined || typeof contours.setTiles !== 'function') return
const wanted = registerTerrain(units).contourTilesUrl
if (contours.tiles?.length === 1 && contours.tiles[0] === wanted) return
contours.setTiles([wanted])
},
'Contour interval',
)
}
/** Test seam only - drops the cached source so a test can observe a fresh
* registration. Production never needs it; a page has one map. */
export function resetTerrainForTests(): void {
source = null
}