forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdemRpc.ts
More file actions
269 lines (252 loc) · 9.27 KB
/
Copy pathdemRpc.ts
File metadata and controls
269 lines (252 loc) · 9.27 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
// The message protocol between contours.ts and the app's DEM worker - both
// sides in one module, so the two ends cannot drift apart in separate files.
//
// WHY THE APP OWNS THIS WORKER AT ALL
//
// maplibre-contour's stock worker cannot be taught to read the downloaded
// DEM package: its worker is a blob built from the library's own chunks,
// its tile fetch is a plain fetch(url) in that worker, and the one seam the
// library exposes for replacing the fetch - LocalDemManager's `getTile`
// init option - is a function, which cannot cross a postMessage boundary
// into a worker the library constructed. So the app constructs the worker
// instead (demWorker.ts), builds the SAME exported LocalDemManager inside
// it with demTiles.ts's local-first getTile, and speaks this protocol to
// it. Everything heavy stays where it is today - fetch, image decode and
// isoline generation all in the worker, one decoded-tile cache shared by
// the hillshade and the contour generator - which is the property the
// stock worker existed for, kept rather than traded away for offline.
//
// The protocol is three requests and an abort, mirroring maplibre-contour's
// own DemManager interface so WorkerDemManager below can stand in for the
// manager a DemSource constructs. Aborts travel as their own message: the
// main side rejects locally at once (a cancelled pan must not wait on a
// worker round trip), the worker aborts the underlying controller so the
// work actually stops, and a late reply to an aborted id falls into the
// void by design.
/** Contour options travel opaquely: DemSource computes them on the main
* thread and LocalDemManager consumes them in the worker; this protocol
* only carries them, and typing them structurally here would be a second
* copy of upstream's type to keep in sync. */
export type ContourOptions = object
export type DemRequest =
| { id: number; kind: 'fetchTile'; z: number; x: number; y: number }
| { id: number; kind: 'fetchAndParseTile'; z: number; x: number; y: number }
| {
id: number
kind: 'fetchContourTile'
z: number
x: number
y: number
options: ContourOptions
}
| { id: number; kind: 'abort' }
export type DemResponse =
| {
id: number
ok: true
kind: 'fetchTile'
data: Blob
cacheControl?: string
expires?: string
}
| {
id: number
ok: true
kind: 'fetchAndParseTile'
width: number
height: number
data: Float32Array
}
| { id: number; ok: true; kind: 'fetchContourTile'; arrayBuffer: ArrayBuffer }
| { id: number; ok: false; message: string }
/** The slice of maplibre-contour's DemManager both sides agree on, spelled
* structurally for the same reason as demTiles.ts's DemFetchResponse: the
* package entry exports no named types. LocalDemManager satisfies this on
* the worker side; WorkerDemManager implements it on the main side; tsc
* checks the real compatibility at the `DemSource.manager` assignment. */
export interface DemManagerLike {
loaded: Promise<void>
fetchTile(
z: number,
x: number,
y: number,
abortController: AbortController,
): Promise<{ data: Blob; cacheControl?: string; expires?: string }>
fetchAndParseTile(
z: number,
x: number,
y: number,
abortController: AbortController,
): Promise<{ width: number; height: number; data: Float32Array }>
fetchContourTile(
z: number,
x: number,
y: number,
options: ContourOptions,
abortController: AbortController,
): Promise<{ arrayBuffer: ArrayBuffer }>
}
/**
* The worker side: turns requests into manager calls and posts the answers
* back. A factory over the manager and the post function rather than code
* in the worker entry, so jsdom tests can drive it with a stub manager and
* a recording post - the worker entry itself is three lines of glue.
*/
export function createDemRequestHandler(
manager: DemManagerLike,
post: (message: DemResponse) => void,
): (request: DemRequest) => void {
const inflight = new Map<number, AbortController>()
const respond = (id: number, work: Promise<DemResponse>) => {
work
.then((message) => {
// An id no longer inflight was aborted; its requester already
// rejected and a reply would be answering nobody.
if (!inflight.has(id)) return
post(message)
})
.catch((error: unknown) => {
if (!inflight.has(id)) return
post({
id,
ok: false,
message: error instanceof Error ? error.message : String(error),
})
})
.finally(() => inflight.delete(id))
}
return (request: DemRequest) => {
if (request.kind === 'abort') {
inflight.get(request.id)?.abort()
inflight.delete(request.id)
return
}
const abortController = new AbortController()
inflight.set(request.id, abortController)
const { id, z, x, y } = request
if (request.kind === 'fetchTile') {
respond(
id,
manager.fetchTile(z, x, y, abortController).then((tile) => ({
id,
ok: true,
kind: 'fetchTile',
data: tile.data,
cacheControl: tile.cacheControl,
expires: tile.expires,
})),
)
} else if (request.kind === 'fetchAndParseTile') {
// Everything below is structured-CLONED, never transferred.
// LocalDemManager serves parsed and contour tiles through AsyncCaches
// that hand the SAME object to every later caller, so transferring
// would detach a buffer the cache is still holding and poison every
// subsequent read of that tile. The copy is the price of the cache
// staying warm, and the cheaper side of the trade: one clone per
// tile against re-fetching and re-decoding it.
respond(
id,
manager.fetchAndParseTile(z, x, y, abortController).then((tile) => ({
id,
ok: true,
kind: 'fetchAndParseTile',
...tile,
})),
)
} else {
respond(
id,
manager
.fetchContourTile(z, x, y, request.options, abortController)
.then((tile) => ({
id,
ok: true,
kind: 'fetchContourTile',
arrayBuffer: tile.arrayBuffer,
})),
)
}
}
}
/** What WorkerDemManager needs from a Worker - structural, so tests can
* hand over one half of a MessageChannel instead of a real thread. */
export interface WorkerLike {
postMessage(message: DemRequest): void
addEventListener(type: 'message', listener: (event: MessageEvent) => void): void
}
interface Pending {
resolve: (response: DemResponse & { ok: true }) => void
reject: (error: Error) => void
cleanup: () => void
}
/**
* The main-thread side: maplibre-contour's DemManager interface over the
* app's DEM worker. Assigned to `DemSource.manager` (a public, typed field)
* in contours.ts, which is what routes the hillshade's `dem://` reads and
* the contour generator's tiles through the worker - and through the
* local-first getTile living inside it.
*/
export class WorkerDemManager implements DemManagerLike {
loaded = Promise.resolve()
private readonly worker: WorkerLike
private readonly pending = new Map<number, Pending>()
private nextId = 0
constructor(worker: WorkerLike) {
this.worker = worker
worker.addEventListener('message', (event) => {
const response = event.data as DemResponse
const waiter = this.pending.get(response.id)
if (waiter === undefined) return
this.pending.delete(response.id)
waiter.cleanup()
if (response.ok) waiter.resolve(response)
else waiter.reject(new Error(response.message))
})
}
private request<K extends DemRequest['kind']>(
message: DemRequest & { kind: K },
abortController: AbortController,
): Promise<DemResponse & { ok: true; kind: K }> {
return new Promise((resolve, reject) => {
const { id } = message
const onAbort = () => {
// Reject locally first - a cancelled pan must not wait a worker
// round trip - then tell the worker so the work really stops.
this.pending.delete(id)
this.worker.postMessage({ id, kind: 'abort' })
reject(new DOMException('The operation was aborted.', 'AbortError'))
}
this.pending.set(id, {
resolve: resolve as Pending['resolve'],
reject,
cleanup: () => abortController.signal.removeEventListener('abort', onAbort),
})
abortController.signal.addEventListener('abort', onAbort)
this.worker.postMessage(message)
})
}
fetchTile(z: number, x: number, y: number, abortController: AbortController) {
return this.request(
{ id: this.nextId++, kind: 'fetchTile' as const, z, x, y },
abortController,
)
}
fetchAndParseTile(z: number, x: number, y: number, abortController: AbortController) {
return this.request(
{ id: this.nextId++, kind: 'fetchAndParseTile' as const, z, x, y },
abortController,
).then(({ width, height, data }) => ({ width, height, data }))
}
fetchContourTile(
z: number,
x: number,
y: number,
options: ContourOptions,
abortController: AbortController,
) {
return this.request(
{ id: this.nextId++, kind: 'fetchContourTile' as const, z, x, y, options },
abortController,
).then(({ arrayBuffer }) => ({ arrayBuffer }))
}
}