@navaramap/three 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/fontWorker-DaqfXmZf.js +2 -0
- package/dist/assets/fontWorker-DaqfXmZf.js.map +1 -0
- package/dist/assets/index-Dydat1h1.js +25 -0
- package/dist/assets/index-Dydat1h1.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-DympKYVX.wasm → navara_wasm_font_worker_bg-XvE_oYPr.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-Bx_l45cW.wasm → navara_wasm_worker_bg-Dg9fnGLP.wasm} +0 -0
- package/dist/index.d.ts +140 -4
- package/dist/index.js +5385 -5022
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +164 -86
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +12 -12
- package/src/declutter/DeclutterManager.test.ts +65 -0
- package/src/declutter/DeclutterManager.ts +60 -1
- package/src/declutter/types.ts +17 -0
- package/src/event/feature.ts +45 -2
- package/src/event/worker.ts +18 -0
- package/src/index.ts +51 -2
- package/src/layers/effect/TransparentPassEffectDesc.ts +1 -1
- package/src/mesh/nvLineGeometry.test.ts +88 -0
- package/src/mesh/nvLineGeometry.ts +148 -0
- package/src/mesh/polygonOutline.ts +126 -42
- package/src/mesh/sdfText/batchedSdfText.test.ts +377 -0
- package/src/mesh/sdfText/batchedSdfText.ts +192 -21
- package/src/mesh/sdfText/labelVisibility.test.ts +106 -0
- package/src/mesh/sdfText/labelVisibility.ts +131 -0
- package/src/mesh/sdfText/pendingSettlement.test.ts +151 -0
- package/src/mesh/sdfText/pendingSettlement.ts +68 -0
- package/src/pick/pickTerrain.ts +22 -18
- package/src/terrain/sampleTerrainMostDetailed.test.ts +236 -0
- package/src/terrain/sampleTerrainMostDetailed.ts +447 -0
- package/dist/assets/fontWorker-JfTCpd38.js +0 -2
- package/dist/assets/fontWorker-JfTCpd38.js.map +0 -1
- package/dist/assets/index-B3U_DvcP.js +0 -25
- package/dist/assets/index-B3U_DvcP.js.map +0 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import type { LatLng } from "@navaramap/core";
|
|
2
|
+
import {
|
|
3
|
+
sampleQuantizedMeshHeights,
|
|
4
|
+
sampleRasterDemHeights,
|
|
5
|
+
terrainPositionsToTiles,
|
|
6
|
+
terrainTileUrl,
|
|
7
|
+
} from "@navaramap/engine-api";
|
|
8
|
+
import { workerPoolStats } from "@navaramap/worker";
|
|
9
|
+
|
|
10
|
+
import { getImageDataFromBlob } from "../tasks/getImageDataFromBlob";
|
|
11
|
+
import type { QuantizedMeshSource, RasterDemSource } from "../type";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A terrain source `sampleTerrainMostDetailed` can sample: the same
|
|
15
|
+
* `quantized-mesh` / `raster-dem` descriptions `addSource` accepts.
|
|
16
|
+
*/
|
|
17
|
+
export type TerrainSampleSource = QuantizedMeshSource | RasterDemSource;
|
|
18
|
+
|
|
19
|
+
export type SampleTerrainOptions = {
|
|
20
|
+
/** Aborts in-flight tile fetches and rejects with the abort reason. */
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
/**
|
|
23
|
+
* Sample at this fixed zoom level instead of probing down from the
|
|
24
|
+
* source's `maxZoom`. A tile missing at this level yields `undefined`
|
|
25
|
+
* heights rather than falling back to a coarser level (Cesium's
|
|
26
|
+
* `sampleTerrain` semantics).
|
|
27
|
+
*/
|
|
28
|
+
level?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SampledTerrainPosition = {
|
|
32
|
+
/** Latitude in radians, echoed from the input. */
|
|
33
|
+
lat: number;
|
|
34
|
+
/** Longitude in radians, echoed from the input. */
|
|
35
|
+
lng: number;
|
|
36
|
+
/**
|
|
37
|
+
* Terrain height in meters, or `undefined` when no tile covering the
|
|
38
|
+
* position could be fetched or decoded.
|
|
39
|
+
*/
|
|
40
|
+
height: number | undefined;
|
|
41
|
+
/** Zoom level the height was sampled at; `undefined` on failure. */
|
|
42
|
+
level: number | undefined;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Same fallback as the engine (`DEFAULT_MAX_ZOOM` in `source_types.rs`). */
|
|
46
|
+
const DEFAULT_MAX_ZOOM = 20;
|
|
47
|
+
/** All-zero decoder, mirroring the engine's `ElevationDecoder::default()`. */
|
|
48
|
+
const DEFAULT_ELEVATION_DECODER = {
|
|
49
|
+
r_scaler: 0,
|
|
50
|
+
g_scaler: 0,
|
|
51
|
+
b_scaler: 0,
|
|
52
|
+
offset: 0,
|
|
53
|
+
max_offset: 0,
|
|
54
|
+
min_offset: 0,
|
|
55
|
+
boundary: 0,
|
|
56
|
+
epsilon: 0,
|
|
57
|
+
};
|
|
58
|
+
const MAX_CONCURRENT_TILE_FETCHES = 6;
|
|
59
|
+
/** Levels probed concurrently per descent step while hunting for coverage. */
|
|
60
|
+
const PROBE_BATCH_LEVELS = 3;
|
|
61
|
+
/** Retries for transient failures (5xx / network errors) per tile URL. */
|
|
62
|
+
const TRANSIENT_RETRIES = 2;
|
|
63
|
+
const RETRY_BASE_DELAY_MS = 200;
|
|
64
|
+
|
|
65
|
+
type FetchOutcome =
|
|
66
|
+
| { kind: "ok"; response: Response }
|
|
67
|
+
/** The server answered 404: the tile does not exist at this level. */
|
|
68
|
+
| { kind: "missing" }
|
|
69
|
+
/** The request kept failing (5xx / network) or was otherwise rejected. */
|
|
70
|
+
| { kind: "failed" };
|
|
71
|
+
|
|
72
|
+
type SourceConfig = {
|
|
73
|
+
kind: "quantized-mesh" | "raster-dem";
|
|
74
|
+
url: string;
|
|
75
|
+
geographic: boolean;
|
|
76
|
+
tms: boolean;
|
|
77
|
+
minZoom: number;
|
|
78
|
+
maxZoom: number;
|
|
79
|
+
token: string | undefined;
|
|
80
|
+
decoder: typeof DEFAULT_ELEVATION_DECODER;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Samples terrain heights at the most detailed zoom level available from
|
|
85
|
+
* `source`, fetching the needed tiles over the network — unlike the
|
|
86
|
+
* synchronous `ThreeView.sampleTerrainHeight`, which only reads tiles already
|
|
87
|
+
* resident for rendering and therefore returns coarse-LOD heights (or
|
|
88
|
+
* nothing) when the camera is far away.
|
|
89
|
+
*
|
|
90
|
+
* Positions are grouped by tile, each unique tile is fetched once, and
|
|
91
|
+
* heights are interpolated by the same engine code that builds the rendered
|
|
92
|
+
* surface. Sampling starts at the source's `maxZoom` and falls back to
|
|
93
|
+
* parent tiles on 404 until data is found (down to `minZoom`), so sources
|
|
94
|
+
* whose real coverage is shallower than `maxZoom` still resolve — at the
|
|
95
|
+
* cost of a few extra requests.
|
|
96
|
+
*
|
|
97
|
+
* Failures follow HTTP semantics: 404 means "no tile at this level" and
|
|
98
|
+
* descends; 401/403 rejects the whole call (misconfigured token); 5xx and
|
|
99
|
+
* network errors are retried, then yield `height: undefined` for the
|
|
100
|
+
* affected positions.
|
|
101
|
+
*
|
|
102
|
+
* Requires the API WASM module to be initialized (`ThreeView.init()` does
|
|
103
|
+
* this).
|
|
104
|
+
*
|
|
105
|
+
* @param source - A `quantized-mesh` or `raster-dem` source description (the
|
|
106
|
+
* same shape `addSource` accepts)
|
|
107
|
+
* @param positions - Geodetic positions (lat/lng in radians)
|
|
108
|
+
* @param options - Optional abort signal and fixed-level sampling
|
|
109
|
+
* @returns One result per input position, in the same order (the input
|
|
110
|
+
* objects are not mutated)
|
|
111
|
+
*/
|
|
112
|
+
export async function sampleTerrainMostDetailed(
|
|
113
|
+
source: TerrainSampleSource,
|
|
114
|
+
positions: LatLng[],
|
|
115
|
+
options?: SampleTerrainOptions,
|
|
116
|
+
): Promise<SampledTerrainPosition[]> {
|
|
117
|
+
if (positions.length === 0) return [];
|
|
118
|
+
const config = resolveSourceConfig(source);
|
|
119
|
+
const signal = options?.signal;
|
|
120
|
+
throwIfAborted(signal);
|
|
121
|
+
|
|
122
|
+
const startLevel = clampLevel(options?.level ?? config.maxZoom, config);
|
|
123
|
+
// Fixed-level sampling never falls back to parents.
|
|
124
|
+
const floorLevel = options?.level != null ? startLevel : config.minZoom;
|
|
125
|
+
|
|
126
|
+
const flat = new Float64Array(positions.length * 2);
|
|
127
|
+
positions.forEach((p, i) => {
|
|
128
|
+
flat[i * 2] = p.lng;
|
|
129
|
+
flat[i * 2 + 1] = p.lat;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Group positions by their containing tile at the starting level; each
|
|
133
|
+
// group is fetched and sampled once no matter how many positions share it.
|
|
134
|
+
const tileXYs = terrainPositionsToTiles(config.geographic, startLevel, flat);
|
|
135
|
+
const groups = new Map<string, { x: number; y: number; indices: number[] }>();
|
|
136
|
+
for (let i = 0; i < positions.length; i++) {
|
|
137
|
+
const x = tileXYs[i * 2];
|
|
138
|
+
const y = tileXYs[i * 2 + 1];
|
|
139
|
+
const key = `${x}/${y}`;
|
|
140
|
+
let group = groups.get(key);
|
|
141
|
+
if (!group) {
|
|
142
|
+
group = { x, y, indices: [] };
|
|
143
|
+
groups.set(key, group);
|
|
144
|
+
}
|
|
145
|
+
group.indices.push(i);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const results: SampledTerrainPosition[] = positions.map((p) => ({
|
|
149
|
+
lat: p.lat,
|
|
150
|
+
lng: p.lng,
|
|
151
|
+
height: undefined,
|
|
152
|
+
level: undefined,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// Distinct groups can descend onto the same parent tile; sharing outcomes
|
|
156
|
+
// by URL keeps each tile fetched at most once per call.
|
|
157
|
+
const outcomeCache = new Map<string, Promise<FetchOutcome>>();
|
|
158
|
+
const limit = createLimiter(MAX_CONCURRENT_TILE_FETCHES);
|
|
159
|
+
|
|
160
|
+
await Promise.all(
|
|
161
|
+
[...groups.values()].map(async (group) => {
|
|
162
|
+
const groupFlat = new Float64Array(group.indices.length * 2);
|
|
163
|
+
group.indices.forEach((originalIndex, i) => {
|
|
164
|
+
groupFlat[i * 2] = flat[originalIndex * 2];
|
|
165
|
+
groupFlat[i * 2 + 1] = flat[originalIndex * 2 + 1];
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const found = await probeMostDetailedTile(
|
|
169
|
+
config,
|
|
170
|
+
group,
|
|
171
|
+
startLevel,
|
|
172
|
+
floorLevel,
|
|
173
|
+
outcomeCache,
|
|
174
|
+
limit,
|
|
175
|
+
signal,
|
|
176
|
+
);
|
|
177
|
+
if (!found) return;
|
|
178
|
+
|
|
179
|
+
const heights = await sampleTile(config, found, groupFlat, signal);
|
|
180
|
+
if (!heights) return;
|
|
181
|
+
group.indices.forEach((originalIndex, i) => {
|
|
182
|
+
const h = heights[i];
|
|
183
|
+
if (h !== undefined && Number.isFinite(h)) {
|
|
184
|
+
results[originalIndex].height = h;
|
|
185
|
+
results[originalIndex].level = found.level;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return results;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveSourceConfig(source: TerrainSampleSource): SourceConfig {
|
|
195
|
+
const url = source.url;
|
|
196
|
+
if (!url) {
|
|
197
|
+
throw new TypeError("sampleTerrainMostDetailed: source.url is required");
|
|
198
|
+
}
|
|
199
|
+
// Defaults mirror the engine's source registration (`source_types.rs`), so
|
|
200
|
+
// sampling reads the same tiles rendering would for the same description.
|
|
201
|
+
if (source.type === "quantized-mesh") {
|
|
202
|
+
return {
|
|
203
|
+
kind: "quantized-mesh",
|
|
204
|
+
url,
|
|
205
|
+
geographic: source.geographic ?? true,
|
|
206
|
+
tms: source.tms ?? true,
|
|
207
|
+
minZoom: source.minZoom ?? 0,
|
|
208
|
+
maxZoom: source.maxZoom ?? DEFAULT_MAX_ZOOM,
|
|
209
|
+
token: source.token ?? undefined,
|
|
210
|
+
decoder: DEFAULT_ELEVATION_DECODER,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const dem = source as RasterDemSource;
|
|
214
|
+
return {
|
|
215
|
+
kind: "raster-dem",
|
|
216
|
+
url,
|
|
217
|
+
geographic: false,
|
|
218
|
+
tms: dem.tms ?? false,
|
|
219
|
+
minZoom: dem.minZoom ?? 0,
|
|
220
|
+
maxZoom: dem.maxZoom ?? DEFAULT_MAX_ZOOM,
|
|
221
|
+
token: undefined,
|
|
222
|
+
decoder: { ...DEFAULT_ELEVATION_DECODER, ...dem.elevationDecoder },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function clampLevel(level: number, config: SourceConfig): number {
|
|
227
|
+
return Math.min(Math.max(Math.floor(level), config.minZoom), config.maxZoom);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Walks levels from `startLevel` down to `floorLevel` looking for the
|
|
232
|
+
* deepest tile the server actually has. The first probe is `startLevel`
|
|
233
|
+
* alone — sources are usually fully covered at `maxZoom`, so the common case
|
|
234
|
+
* costs exactly one request. Only after a 404 does each further step probe
|
|
235
|
+
* {@link PROBE_BATCH_LEVELS} consecutive levels concurrently, so a source
|
|
236
|
+
* whose coverage stops well short of `maxZoom` costs one round-trip per
|
|
237
|
+
* batch of missing levels instead of one per level.
|
|
238
|
+
*/
|
|
239
|
+
async function probeMostDetailedTile(
|
|
240
|
+
config: SourceConfig,
|
|
241
|
+
group: { x: number; y: number },
|
|
242
|
+
startLevel: number,
|
|
243
|
+
floorLevel: number,
|
|
244
|
+
outcomeCache: Map<string, Promise<FetchOutcome>>,
|
|
245
|
+
limit: <T>(task: () => Promise<T>) => Promise<T>,
|
|
246
|
+
signal: AbortSignal | undefined,
|
|
247
|
+
): Promise<{ x: number; y: number; level: number; response: Response } | null> {
|
|
248
|
+
let batchTop = startLevel;
|
|
249
|
+
while (batchTop >= floorLevel) {
|
|
250
|
+
const batchSize = batchTop === startLevel ? 1 : PROBE_BATCH_LEVELS;
|
|
251
|
+
const batchBottom = Math.max(batchTop - batchSize + 1, floorLevel);
|
|
252
|
+
const levels: number[] = [];
|
|
253
|
+
for (let level = batchTop; level >= batchBottom; level--)
|
|
254
|
+
levels.push(level);
|
|
255
|
+
|
|
256
|
+
const outcomes = await Promise.all(
|
|
257
|
+
levels.map((level) => {
|
|
258
|
+
const shift = startLevel - level;
|
|
259
|
+
const x = group.x >> shift;
|
|
260
|
+
const y = group.y >> shift;
|
|
261
|
+
const url = terrainTileUrl(
|
|
262
|
+
config.url,
|
|
263
|
+
config.geographic,
|
|
264
|
+
config.tms,
|
|
265
|
+
x,
|
|
266
|
+
y,
|
|
267
|
+
level,
|
|
268
|
+
);
|
|
269
|
+
let outcome = outcomeCache.get(url);
|
|
270
|
+
if (!outcome) {
|
|
271
|
+
outcome = limit(() => fetchTile(config, url, signal));
|
|
272
|
+
outcomeCache.set(url, outcome);
|
|
273
|
+
}
|
|
274
|
+
return outcome.then((o) => ({ level, x, y, outcome: o }));
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Deepest success in the batch wins; a hard failure stops the descent
|
|
279
|
+
// (the tile exists but is unreadable — a coarser parent would silently
|
|
280
|
+
// hide the error behind a coarse height).
|
|
281
|
+
for (const { level, x, y, outcome } of outcomes) {
|
|
282
|
+
if (outcome.kind === "ok")
|
|
283
|
+
return { x, y, level, response: outcome.response };
|
|
284
|
+
if (outcome.kind === "failed") return null;
|
|
285
|
+
}
|
|
286
|
+
batchTop = batchBottom - 1;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Fetch one tile URL, classifying the result per HTTP semantics. */
|
|
292
|
+
async function fetchTile(
|
|
293
|
+
config: SourceConfig,
|
|
294
|
+
url: string,
|
|
295
|
+
signal: AbortSignal | undefined,
|
|
296
|
+
): Promise<FetchOutcome> {
|
|
297
|
+
const headers: Record<string, string> = {};
|
|
298
|
+
if (config.kind === "quantized-mesh") {
|
|
299
|
+
// Same content negotiation as the rendering fetch path, minus the
|
|
300
|
+
// normals/watermask extensions — sampling only needs positions.
|
|
301
|
+
headers.Accept =
|
|
302
|
+
"application/vnd.quantized-mesh,application/octet-stream;q=0.9,*/*;q=0.01";
|
|
303
|
+
}
|
|
304
|
+
if (config.token) headers.Authorization = `Bearer ${config.token}`;
|
|
305
|
+
|
|
306
|
+
for (let attempt = 0; ; attempt++) {
|
|
307
|
+
throwIfAborted(signal);
|
|
308
|
+
let response: Response;
|
|
309
|
+
try {
|
|
310
|
+
response = await fetch(url, { headers, signal });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
// fetch() rejects on network errors and aborts; only retry the former.
|
|
313
|
+
if (signal?.aborted) throw e;
|
|
314
|
+
if (attempt < TRANSIENT_RETRIES) {
|
|
315
|
+
await delay(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
return { kind: "failed" };
|
|
319
|
+
}
|
|
320
|
+
if (response.ok) return { kind: "ok", response };
|
|
321
|
+
// 404/410: the tile genuinely doesn't exist at this level — the caller
|
|
322
|
+
// descends to the parent. Anything else is an error, not "no data".
|
|
323
|
+
if (response.status === 404 || response.status === 410) {
|
|
324
|
+
return { kind: "missing" };
|
|
325
|
+
}
|
|
326
|
+
if (response.status === 401 || response.status === 403) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`sampleTerrainMostDetailed: ${response.status} ${response.statusText} for ${url} — check the source's token`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (response.status >= 500 && attempt < TRANSIENT_RETRIES) {
|
|
332
|
+
await delay(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
return { kind: "failed" };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Decode the fetched tile and interpolate a height per position, on the same
|
|
341
|
+
* engine code paths rendering uses. Returns `null` when the payload doesn't
|
|
342
|
+
* decode; `NaN` entries mean the tile had no data at that position.
|
|
343
|
+
*/
|
|
344
|
+
async function sampleTile(
|
|
345
|
+
config: SourceConfig,
|
|
346
|
+
tile: { x: number; y: number; level: number; response: Response },
|
|
347
|
+
groupFlat: Float64Array,
|
|
348
|
+
signal: AbortSignal | undefined,
|
|
349
|
+
): Promise<Float64Array | null> {
|
|
350
|
+
if (config.kind === "quantized-mesh") {
|
|
351
|
+
const bytes = new Uint8Array(await tile.response.arrayBuffer());
|
|
352
|
+
throwIfAborted(signal);
|
|
353
|
+
return (
|
|
354
|
+
sampleQuantizedMeshHeights(
|
|
355
|
+
bytes,
|
|
356
|
+
config.geographic,
|
|
357
|
+
tile.x,
|
|
358
|
+
tile.y,
|
|
359
|
+
tile.level,
|
|
360
|
+
groupFlat,
|
|
361
|
+
) ?? null
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const rgba = await decodeImageToRgba(await tile.response.blob());
|
|
366
|
+
throwIfAborted(signal);
|
|
367
|
+
if (!rgba) return null;
|
|
368
|
+
return (
|
|
369
|
+
sampleRasterDemHeights(
|
|
370
|
+
rgba,
|
|
371
|
+
config.decoder,
|
|
372
|
+
tile.x,
|
|
373
|
+
tile.y,
|
|
374
|
+
tile.level,
|
|
375
|
+
groupFlat,
|
|
376
|
+
) ?? null
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Decode an image blob (PNG/WebP DEM tile) into tightly-packed RGBA bytes.
|
|
382
|
+
* When a view has initialized the worker pool, the decode runs there — the
|
|
383
|
+
* same off-main-thread path the rendering fetches use. Standalone callers
|
|
384
|
+
* without a pool decode on the calling thread instead.
|
|
385
|
+
*/
|
|
386
|
+
async function decodeImageToRgba(blob: Blob): Promise<Uint8Array | null> {
|
|
387
|
+
if (workerPoolStats()) {
|
|
388
|
+
try {
|
|
389
|
+
const data = await getImageDataFromBlob(blob);
|
|
390
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let bitmap: ImageBitmap;
|
|
397
|
+
try {
|
|
398
|
+
bitmap = await createImageBitmap(blob);
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
404
|
+
const context = canvas.getContext("2d");
|
|
405
|
+
if (!context) return null;
|
|
406
|
+
context.drawImage(bitmap, 0, 0);
|
|
407
|
+
const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
408
|
+
return new Uint8Array(image.data.buffer, 0, image.data.length);
|
|
409
|
+
} finally {
|
|
410
|
+
bitmap.close();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Minimal promise concurrency limiter for the tile fetches. */
|
|
415
|
+
function createLimiter(
|
|
416
|
+
maxConcurrent: number,
|
|
417
|
+
): <T>(task: () => Promise<T>) => Promise<T> {
|
|
418
|
+
let active = 0;
|
|
419
|
+
const queue: (() => void)[] = [];
|
|
420
|
+
const next = () => {
|
|
421
|
+
active--;
|
|
422
|
+
queue.shift()?.();
|
|
423
|
+
};
|
|
424
|
+
return async <T>(task: () => Promise<T>): Promise<T> => {
|
|
425
|
+
if (active >= maxConcurrent) {
|
|
426
|
+
await new Promise<void>((resolve) => queue.push(resolve));
|
|
427
|
+
}
|
|
428
|
+
active++;
|
|
429
|
+
try {
|
|
430
|
+
return await task();
|
|
431
|
+
} finally {
|
|
432
|
+
next();
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function throwIfAborted(signal: AbortSignal | undefined) {
|
|
438
|
+
if (signal?.aborted) {
|
|
439
|
+
throw signal.reason instanceof Error
|
|
440
|
+
? signal.reason
|
|
441
|
+
: new DOMException("The operation was aborted.", "AbortError");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function delay(ms: number): Promise<void> {
|
|
446
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
447
|
+
}
|