tileflow 0.1.0-alpha.21 → 0.1.0-alpha.23
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/README.md +27 -20
- package/REFERENCE.md +429 -0
- package/dist/build-RQF35KW6.js +21 -0
- package/dist/chunk-43VQMAQO.js +52 -0
- package/dist/chunk-5U4J6E6Y.js +4911 -0
- package/dist/chunk-EXDH7EHK.js +2803 -0
- package/dist/chunk-GPPOECR5.js +16 -0
- package/dist/chunk-JVTGXPGV.js +382 -0
- package/dist/cli-main-K3PKVCIP.js +10835 -0
- package/dist/errors-C3VZMCM5.js +20 -0
- package/dist/index.js +8 -12121
- package/dist/inspect-source-5CD3AV5J.js +227 -0
- package/dist/recipe-3BPMNDPX.js +23 -0
- package/dist/tileset-entry-V5RUQGZG.js +26 -0
- package/package.json +16 -7
- package/schemas/tileset-recipe.v1.json +171 -0
|
@@ -0,0 +1,4911 @@
|
|
|
1
|
+
import {
|
|
2
|
+
layerNamePattern,
|
|
3
|
+
maximumZoom,
|
|
4
|
+
parseSourceCrsOption,
|
|
5
|
+
recipeFromSettings,
|
|
6
|
+
recipeHash,
|
|
7
|
+
resolveCrs,
|
|
8
|
+
statementFromGeojsonCrs,
|
|
9
|
+
statementFromGeopackageSrs,
|
|
10
|
+
wgs84Statement
|
|
11
|
+
} from "./chunk-JVTGXPGV.js";
|
|
12
|
+
import {
|
|
13
|
+
SampleComplete,
|
|
14
|
+
TilesetBuildError,
|
|
15
|
+
featureTooLarge,
|
|
16
|
+
featureTooManyBytes,
|
|
17
|
+
listChoices,
|
|
18
|
+
maximumFeatureBytes,
|
|
19
|
+
maximumFeaturePositions,
|
|
20
|
+
yieldToEventLoop
|
|
21
|
+
} from "./chunk-43VQMAQO.js";
|
|
22
|
+
import {
|
|
23
|
+
getCliVersion
|
|
24
|
+
} from "./chunk-GPPOECR5.js";
|
|
25
|
+
|
|
26
|
+
// src/tileset-build/build.ts
|
|
27
|
+
import { existsSync as existsSync2, mkdtempSync, readdirSync as readdirSync2, rmSync, statfsSync, statSync as statSync2 } from "fs";
|
|
28
|
+
import { rename } from "fs/promises";
|
|
29
|
+
import { basename as basename3, dirname as dirname2, extname as extname3, join as join4, resolve } from "path";
|
|
30
|
+
import { serializeCanonicalJson } from "@tileflow/core";
|
|
31
|
+
import { inspectTileflowPmtiles } from "@tileflow/dev/tilesets";
|
|
32
|
+
|
|
33
|
+
// src/tileset-build/ingest.ts
|
|
34
|
+
import { createHash } from "crypto";
|
|
35
|
+
|
|
36
|
+
// src/tileset-build/spool.ts
|
|
37
|
+
import { closeSync, openSync, readSync, unlinkSync, writeSync } from "fs";
|
|
38
|
+
import { join } from "path";
|
|
39
|
+
var field = {
|
|
40
|
+
words: 0,
|
|
41
|
+
type: 1,
|
|
42
|
+
minX: 2,
|
|
43
|
+
minY: 3,
|
|
44
|
+
maxX: 4,
|
|
45
|
+
maxY: 5,
|
|
46
|
+
parts: 6,
|
|
47
|
+
points: 7,
|
|
48
|
+
propertyBytes: 8,
|
|
49
|
+
idLow: 9,
|
|
50
|
+
idHigh: 10,
|
|
51
|
+
/** 1 when the parent tile drew this feature, so that this tile must too. */
|
|
52
|
+
kept: 11
|
|
53
|
+
};
|
|
54
|
+
var headerWords = 12;
|
|
55
|
+
var worldSize = 2 ** 31;
|
|
56
|
+
var geometryType = { line: 2, point: 1, polygon: 3 };
|
|
57
|
+
var memoryLimitWords = 4 * 1024 * 1024 / 4;
|
|
58
|
+
var fileBufferWords = 1024 * 1024 / 4;
|
|
59
|
+
var readChunkWords = 16 * 1024 * 1024 / 4;
|
|
60
|
+
var SpoolDirectory = class {
|
|
61
|
+
constructor(path) {
|
|
62
|
+
this.path = path;
|
|
63
|
+
}
|
|
64
|
+
path;
|
|
65
|
+
#next = 0;
|
|
66
|
+
nextFile() {
|
|
67
|
+
return join(this.path, `spool-${this.#next += 1}`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var FeatureSpool = class _FeatureSpool {
|
|
71
|
+
constructor(directory) {
|
|
72
|
+
this.directory = directory;
|
|
73
|
+
}
|
|
74
|
+
directory;
|
|
75
|
+
words = new Int32Array(1024);
|
|
76
|
+
length = 0;
|
|
77
|
+
count = 0;
|
|
78
|
+
#descriptor = null;
|
|
79
|
+
#path = null;
|
|
80
|
+
/** Return the offset of `size` writable words in `this.words`. */
|
|
81
|
+
reserve(size) {
|
|
82
|
+
if (this.length + size > this.words.length) this.#makeRoom(size);
|
|
83
|
+
const offset = this.length;
|
|
84
|
+
this.length += size;
|
|
85
|
+
this.count += 1;
|
|
86
|
+
return offset;
|
|
87
|
+
}
|
|
88
|
+
append(source, offset, size) {
|
|
89
|
+
const target = this.reserve(size);
|
|
90
|
+
this.words.set(source.subarray(offset, offset + size), target);
|
|
91
|
+
}
|
|
92
|
+
/** Visit each record; `words` is only valid during the callback. */
|
|
93
|
+
forEach(visit) {
|
|
94
|
+
if (this.#path === null) {
|
|
95
|
+
visitRecords(this.words, this.length, visit);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.#flush();
|
|
99
|
+
if (this.#descriptor !== null) {
|
|
100
|
+
closeSync(this.#descriptor);
|
|
101
|
+
this.#descriptor = null;
|
|
102
|
+
}
|
|
103
|
+
const descriptor = openSync(this.#path, "r");
|
|
104
|
+
try {
|
|
105
|
+
let chunk = new Int32Array(readChunkWords);
|
|
106
|
+
let carried = 0;
|
|
107
|
+
for (; ; ) {
|
|
108
|
+
const bytes = readSync(
|
|
109
|
+
descriptor,
|
|
110
|
+
new Uint8Array(chunk.buffer),
|
|
111
|
+
carried * 4,
|
|
112
|
+
(chunk.length - carried) * 4,
|
|
113
|
+
null
|
|
114
|
+
);
|
|
115
|
+
const available = carried + bytes / 4;
|
|
116
|
+
const consumed = visitRecords(chunk, available, visit);
|
|
117
|
+
carried = available - consumed;
|
|
118
|
+
if (bytes === 0) break;
|
|
119
|
+
chunk.copyWithin(0, consumed, available);
|
|
120
|
+
if (carried > 0 && chunk[field.words] > chunk.length) {
|
|
121
|
+
const larger = new Int32Array(chunk[field.words] * 2);
|
|
122
|
+
larger.set(chunk.subarray(0, carried));
|
|
123
|
+
chunk = larger;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
closeSync(descriptor);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Rewrite the records from the highest key to the lowest, keeping input order between equals, and
|
|
132
|
+
* dispose of this spool. Holds about 24 bytes per record in memory; the records stay on disk.
|
|
133
|
+
*/
|
|
134
|
+
reordered(keys) {
|
|
135
|
+
const sizes = new Uint32Array(this.count);
|
|
136
|
+
let visited = 0;
|
|
137
|
+
this.forEach((words, offset) => {
|
|
138
|
+
sizes[visited] = words[offset + field.words];
|
|
139
|
+
visited += 1;
|
|
140
|
+
});
|
|
141
|
+
const order = new Uint32Array(this.count);
|
|
142
|
+
for (let index = 0; index < order.length; index += 1) order[index] = index;
|
|
143
|
+
order.sort((left, right) => keys[right] - keys[left] || 0 || left - right);
|
|
144
|
+
const positions = new Float64Array(this.count);
|
|
145
|
+
let position = 0;
|
|
146
|
+
for (const index of order) {
|
|
147
|
+
positions[index] = position;
|
|
148
|
+
position += sizes[index];
|
|
149
|
+
}
|
|
150
|
+
const target = new _FeatureSpool(this.directory);
|
|
151
|
+
target.count = this.count;
|
|
152
|
+
if (this.#path === null) {
|
|
153
|
+
target.words = new Int32Array(position);
|
|
154
|
+
target.length = position;
|
|
155
|
+
visited = 0;
|
|
156
|
+
this.forEach((words, offset) => {
|
|
157
|
+
target.words.set(words.subarray(offset, offset + sizes[visited]), positions[visited]);
|
|
158
|
+
visited += 1;
|
|
159
|
+
});
|
|
160
|
+
this.dispose();
|
|
161
|
+
return target;
|
|
162
|
+
}
|
|
163
|
+
target.#path = this.directory.nextFile();
|
|
164
|
+
const descriptor = openSync(target.#path, "w");
|
|
165
|
+
try {
|
|
166
|
+
visited = 0;
|
|
167
|
+
this.forEach((words, offset) => {
|
|
168
|
+
const bytes = new Uint8Array(
|
|
169
|
+
words.buffer,
|
|
170
|
+
words.byteOffset + offset * 4,
|
|
171
|
+
sizes[visited] * 4
|
|
172
|
+
);
|
|
173
|
+
let written = 0;
|
|
174
|
+
while (written < bytes.length) {
|
|
175
|
+
written += writeSync(
|
|
176
|
+
descriptor,
|
|
177
|
+
bytes,
|
|
178
|
+
written,
|
|
179
|
+
bytes.length - written,
|
|
180
|
+
positions[visited] * 4 + written
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
visited += 1;
|
|
184
|
+
});
|
|
185
|
+
} finally {
|
|
186
|
+
closeSync(descriptor);
|
|
187
|
+
}
|
|
188
|
+
this.dispose();
|
|
189
|
+
return target;
|
|
190
|
+
}
|
|
191
|
+
dispose() {
|
|
192
|
+
if (this.#descriptor !== null) closeSync(this.#descriptor);
|
|
193
|
+
if (this.#path !== null) unlinkSync(this.#path);
|
|
194
|
+
this.#descriptor = null;
|
|
195
|
+
this.#path = null;
|
|
196
|
+
this.words = new Int32Array(0);
|
|
197
|
+
this.length = 0;
|
|
198
|
+
}
|
|
199
|
+
#makeRoom(size) {
|
|
200
|
+
if (this.#path === null && this.length + size > memoryLimitWords) {
|
|
201
|
+
this.#path = this.directory.nextFile();
|
|
202
|
+
this.#descriptor = openSync(this.#path, "w");
|
|
203
|
+
}
|
|
204
|
+
if (this.#path !== null) {
|
|
205
|
+
this.#flush();
|
|
206
|
+
if (size <= fileBufferWords && this.words.length >= fileBufferWords) return;
|
|
207
|
+
}
|
|
208
|
+
const needed = this.length + size;
|
|
209
|
+
const capacity = Math.max(needed, Math.min(this.words.length * 2, memoryLimitWords));
|
|
210
|
+
const grown = new Int32Array(this.#path === null ? capacity : Math.max(size, fileBufferWords));
|
|
211
|
+
grown.set(this.words.subarray(0, this.length));
|
|
212
|
+
this.words = grown;
|
|
213
|
+
}
|
|
214
|
+
#flush() {
|
|
215
|
+
if (this.length === 0 || this.#descriptor === null) return;
|
|
216
|
+
const bytes = new Uint8Array(this.words.buffer, 0, this.length * 4);
|
|
217
|
+
let written = 0;
|
|
218
|
+
while (written < bytes.length) {
|
|
219
|
+
written += writeSync(this.#descriptor, bytes, written, bytes.length - written);
|
|
220
|
+
}
|
|
221
|
+
this.length = 0;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
function visitRecords(words, available, visit) {
|
|
225
|
+
let offset = 0;
|
|
226
|
+
while (offset < available) {
|
|
227
|
+
const size = words[offset + field.words];
|
|
228
|
+
if (offset + size > available) break;
|
|
229
|
+
visit(words, offset);
|
|
230
|
+
offset += size;
|
|
231
|
+
}
|
|
232
|
+
return offset;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/tileset-build/ingest.ts
|
|
236
|
+
var fieldNamePattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/u;
|
|
237
|
+
var maximumFields = 512;
|
|
238
|
+
var maximumMercatorLatitude = 85.0511287798066;
|
|
239
|
+
var coordinateToleranceDegrees = 1e-3;
|
|
240
|
+
var sampleCapacity = 1 << 18;
|
|
241
|
+
var unranked = -Infinity;
|
|
242
|
+
var typeBits = { boolean: 1, number: 2, string: 4 };
|
|
243
|
+
var maximumFieldValues = 64;
|
|
244
|
+
var maximumFieldValueCharacters = 64;
|
|
245
|
+
var largestHashedId = BigInt(Number.MAX_SAFE_INTEGER);
|
|
246
|
+
var FeatureIngest = class {
|
|
247
|
+
constructor(spool, selection, transform, rank = null, featureId = null) {
|
|
248
|
+
this.spool = spool;
|
|
249
|
+
this.selection = selection;
|
|
250
|
+
this.rank = rank;
|
|
251
|
+
this.featureId = featureId;
|
|
252
|
+
this.#transform = transform;
|
|
253
|
+
if (rank) this.#rankKeys = new Float64Array(1024);
|
|
254
|
+
}
|
|
255
|
+
spool;
|
|
256
|
+
selection;
|
|
257
|
+
rank;
|
|
258
|
+
featureId;
|
|
259
|
+
#keys = /* @__PURE__ */ new Map();
|
|
260
|
+
#keyTypes = [];
|
|
261
|
+
#statistics = [];
|
|
262
|
+
#transform;
|
|
263
|
+
#properties = new Uint8Array(4096);
|
|
264
|
+
#propertiesView = new DataView(this.#properties.buffer);
|
|
265
|
+
#propertyLength = 0;
|
|
266
|
+
#points = [];
|
|
267
|
+
#parts = [];
|
|
268
|
+
#warnings = /* @__PURE__ */ new Set();
|
|
269
|
+
#features = 0;
|
|
270
|
+
#emptyGeometry = 0;
|
|
271
|
+
#outsideMercator = 0;
|
|
272
|
+
#clampedLatitude = 0;
|
|
273
|
+
#wideSegments = 0;
|
|
274
|
+
#nonNumericIds = 0;
|
|
275
|
+
#usableIds = 0;
|
|
276
|
+
#degenerateParts = 0;
|
|
277
|
+
#nudgedCoordinates = 0;
|
|
278
|
+
#minLon = Infinity;
|
|
279
|
+
#minLat = Infinity;
|
|
280
|
+
#maxLon = -Infinity;
|
|
281
|
+
#maxLat = -Infinity;
|
|
282
|
+
#types = /* @__PURE__ */ new Set();
|
|
283
|
+
#sampleStride = 1;
|
|
284
|
+
#sampleSeen = 0;
|
|
285
|
+
#rankKeys = new Float64Array(0);
|
|
286
|
+
#currentRank = unranked;
|
|
287
|
+
#ranked = 0;
|
|
288
|
+
#unrankedFeatures = 0;
|
|
289
|
+
/** Bounded, evenly thinned vertex sample used to choose a maximum zoom. */
|
|
290
|
+
sample = new Int32Array(sampleCapacity * 2);
|
|
291
|
+
sampleCount = 0;
|
|
292
|
+
/** Set by readers so that errors name the offending feature. */
|
|
293
|
+
location = "input";
|
|
294
|
+
/** When set, the read ends with `SampleComplete` after this many features. */
|
|
295
|
+
stopAfter;
|
|
296
|
+
get featureCount() {
|
|
297
|
+
return this.#features;
|
|
298
|
+
}
|
|
299
|
+
get sampleStride() {
|
|
300
|
+
return this.#sampleStride;
|
|
301
|
+
}
|
|
302
|
+
warn(message) {
|
|
303
|
+
this.#warnings.add(message);
|
|
304
|
+
}
|
|
305
|
+
add(geometry, properties, id) {
|
|
306
|
+
if (geometry === null || geometry === void 0) {
|
|
307
|
+
this.#emptyGeometry += 1;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (this.#features === this.stopAfter) throw new SampleComplete();
|
|
311
|
+
this.#encodeProperties(properties);
|
|
312
|
+
if (this.rank) this.#readRank(properties[this.rank.field]);
|
|
313
|
+
const numericId = this.#numericId(this.featureId ? this.#selectedId(properties) : id);
|
|
314
|
+
this.#addGeometry(geometry, numericId);
|
|
315
|
+
}
|
|
316
|
+
skipEmpty() {
|
|
317
|
+
this.#emptyGeometry += 1;
|
|
318
|
+
}
|
|
319
|
+
/** The features in descending priority, or the input order when no rank was requested. */
|
|
320
|
+
rankedSpool(spool) {
|
|
321
|
+
if (!this.rank) return spool;
|
|
322
|
+
return spool.reordered(this.#rankKeys.subarray(0, spool.count));
|
|
323
|
+
}
|
|
324
|
+
#readRank(value) {
|
|
325
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
326
|
+
this.#currentRank = this.rank?.ascending ? -value : value;
|
|
327
|
+
this.#ranked += 1;
|
|
328
|
+
} else {
|
|
329
|
+
this.#currentRank = unranked;
|
|
330
|
+
this.#unrankedFeatures += 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Summarize the pass and finalize names that downstream tile consumers accept. */
|
|
334
|
+
finish() {
|
|
335
|
+
if (this.#keys.size > maximumFields) {
|
|
336
|
+
throw new TilesetBuildError(
|
|
337
|
+
"TF_TILESET_BUILD_TOO_MANY_FIELDS",
|
|
338
|
+
"input.fields",
|
|
339
|
+
`The input has ${this.#keys.size} attribute fields; the limit is ${maximumFields}. Pick what maps need with --include.`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (this.rank && this.#features > 0 && this.#ranked === 0) {
|
|
343
|
+
throw new TilesetBuildError(
|
|
344
|
+
"TF_TILESET_BUILD_RANK_INVALID",
|
|
345
|
+
"rankBy",
|
|
346
|
+
`No feature has a numeric value in "${this.rank.field}". --rank-by needs a numeric attribute.`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (this.#unrankedFeatures > 0) {
|
|
350
|
+
this.#warnings.add(
|
|
351
|
+
`${this.#unrankedFeatures} features have no numeric "${this.rank?.field}" and are the first to be thinned.`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (this.featureId && this.#features > 0 && this.#usableIds === 0) {
|
|
355
|
+
throw new TilesetBuildError(
|
|
356
|
+
"TF_TILESET_BUILD_FEATURE_ID_INVALID",
|
|
357
|
+
"featureId",
|
|
358
|
+
`No feature has a non-negative integer in "${this.featureId.field}". Hash text values with --hash-feature-id.`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (this.#features === 0) {
|
|
362
|
+
throw new TilesetBuildError(
|
|
363
|
+
"TF_TILESET_BUILD_EMPTY",
|
|
364
|
+
"input",
|
|
365
|
+
`The input has no usable features (${this.#emptyGeometry} without geometry, ${this.#outsideMercator} outside the Web Mercator range).`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const taken = /* @__PURE__ */ new Set();
|
|
369
|
+
const fields = [...this.#keys].map(([sourceName, key]) => {
|
|
370
|
+
const name = safeFieldName(sourceName, key);
|
|
371
|
+
if (taken.has(name)) {
|
|
372
|
+
throw new TilesetBuildError(
|
|
373
|
+
"TF_TILESET_BUILD_FIELD_CONFLICT",
|
|
374
|
+
"input.fields",
|
|
375
|
+
`Field "${sourceName}" becomes "${name}", which another field already uses. Remove one with --exclude.`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
taken.add(name);
|
|
379
|
+
if (name !== sourceName) {
|
|
380
|
+
this.#warnings.add(`Field "${sourceName}" was renamed to "${name}".`);
|
|
381
|
+
}
|
|
382
|
+
return { name, sourceName, type: fieldType(this.#keyTypes[key]), ...this.#fieldSummary(key) };
|
|
383
|
+
});
|
|
384
|
+
if (this.#clampedLatitude > 0) {
|
|
385
|
+
this.#warnings.add(
|
|
386
|
+
`${this.#clampedLatitude} features extend past latitude \xB185.05 and were cut at the Web Mercator limit.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (this.#outsideMercator > 0) {
|
|
390
|
+
this.#warnings.add(
|
|
391
|
+
`${this.#outsideMercator} points past latitude \xB185.05 were skipped; Web Mercator tiles cannot hold them.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (this.#nudgedCoordinates > 0) {
|
|
395
|
+
this.#warnings.add(
|
|
396
|
+
`${this.#nudgedCoordinates} coordinates up to ${coordinateToleranceDegrees}\xB0 past longitude \xB1180 or latitude \xB190 were moved to that limit.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (this.#wideSegments > 0) {
|
|
400
|
+
this.#warnings.add(
|
|
401
|
+
`${this.#wideSegments} features have segments spanning more than 180\xB0 of longitude and will draw across the map. Split them at the antimeridian first.`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (this.#nonNumericIds > 0) {
|
|
405
|
+
this.#warnings.add(
|
|
406
|
+
`${this.#nonNumericIds} feature ids are not non-negative integers and were dropped; vector tiles only store integer ids.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (this.#degenerateParts > 0) {
|
|
410
|
+
this.#warnings.add(
|
|
411
|
+
`${this.#degenerateParts} rings or lines with too few positions were skipped.`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (this.#emptyGeometry > 0) {
|
|
415
|
+
this.#warnings.add(`${this.#emptyGeometry} features without geometry were skipped.`);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
bounds: [this.#minLon, this.#minLat, this.#maxLon, this.#maxLat],
|
|
419
|
+
features: this.#features,
|
|
420
|
+
fields,
|
|
421
|
+
geometryTypes: [...this.#types].sort(),
|
|
422
|
+
skipped: { emptyGeometry: this.#emptyGeometry, outsideMercator: this.#outsideMercator },
|
|
423
|
+
warnings: [...this.#warnings]
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
#fieldSummary(key) {
|
|
427
|
+
const { complete, count, max, min, values } = this.#statistics[key];
|
|
428
|
+
return {
|
|
429
|
+
count,
|
|
430
|
+
...Number.isFinite(max) ? { max, min } : {},
|
|
431
|
+
values: [...values].sort(compareFieldValues),
|
|
432
|
+
valuesComplete: complete
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
#recordValue(key, value) {
|
|
436
|
+
const statistics = this.#statistics[key];
|
|
437
|
+
statistics.count += 1;
|
|
438
|
+
if (typeof value === "number") {
|
|
439
|
+
if (value < statistics.min) statistics.min = value;
|
|
440
|
+
if (value > statistics.max) statistics.max = value;
|
|
441
|
+
}
|
|
442
|
+
if (typeof value === "string" && value.length > maximumFieldValueCharacters || typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
443
|
+
statistics.complete = false;
|
|
444
|
+
} else if (statistics.values.size < maximumFieldValues) {
|
|
445
|
+
statistics.values.add(value);
|
|
446
|
+
} else if (statistics.complete && !statistics.values.has(value)) {
|
|
447
|
+
statistics.complete = false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
keyNames(summary) {
|
|
451
|
+
return summary.fields.map((item) => item.name);
|
|
452
|
+
}
|
|
453
|
+
#selectedId(properties) {
|
|
454
|
+
const value = properties[this.featureId.field];
|
|
455
|
+
if (!this.featureId?.hash || value === void 0 || value === null) return value;
|
|
456
|
+
const digest = createHash("sha256").update(String(value)).digest();
|
|
457
|
+
return Number((digest.readBigUInt64BE(0) >> 11n) % largestHashedId);
|
|
458
|
+
}
|
|
459
|
+
#numericId(id) {
|
|
460
|
+
if (id === void 0 || id === null) return 0;
|
|
461
|
+
if (typeof id === "number" && Number.isSafeInteger(id) && id >= 0 && id < 2 ** 53 - 1) {
|
|
462
|
+
this.#usableIds += 1;
|
|
463
|
+
return id + 1;
|
|
464
|
+
}
|
|
465
|
+
this.#nonNumericIds += 1;
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
#addGeometry(geometry, id) {
|
|
469
|
+
const { coordinates, type } = geometry;
|
|
470
|
+
switch (type) {
|
|
471
|
+
case "Point":
|
|
472
|
+
this.#resetGeometry();
|
|
473
|
+
if (isEmptyList(coordinates)) return this.skipEmpty();
|
|
474
|
+
this.#pushPosition(coordinates, true);
|
|
475
|
+
if (this.#points.length === 0) return;
|
|
476
|
+
this.#parts.push(1);
|
|
477
|
+
return this.#commit(geometryType.point, "Point", id);
|
|
478
|
+
case "MultiPoint": {
|
|
479
|
+
this.#resetGeometry();
|
|
480
|
+
const positions = this.#list(coordinates);
|
|
481
|
+
if (positions.length === 0) return this.skipEmpty();
|
|
482
|
+
for (const position of positions) this.#pushPosition(position, true);
|
|
483
|
+
if (this.#points.length === 0) return;
|
|
484
|
+
this.#parts.push(this.#points.length / 2);
|
|
485
|
+
return this.#commit(geometryType.point, "Point", id);
|
|
486
|
+
}
|
|
487
|
+
case "LineString":
|
|
488
|
+
case "MultiLineString": {
|
|
489
|
+
this.#resetGeometry();
|
|
490
|
+
const lines = type === "LineString" ? [coordinates] : this.#list(coordinates);
|
|
491
|
+
for (const line of lines) this.#pushLine(line);
|
|
492
|
+
if (this.#parts.length === 0) return this.skipEmpty();
|
|
493
|
+
return this.#commit(geometryType.line, "LineString", id);
|
|
494
|
+
}
|
|
495
|
+
case "Polygon":
|
|
496
|
+
case "MultiPolygon": {
|
|
497
|
+
this.#resetGeometry();
|
|
498
|
+
const polygons = type === "Polygon" ? [coordinates] : this.#list(coordinates);
|
|
499
|
+
for (const polygon of polygons) {
|
|
500
|
+
const rings = this.#list(polygon);
|
|
501
|
+
for (let index = 0; index < rings.length; index += 1) {
|
|
502
|
+
this.#pushRing(rings[index], index === 0);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (this.#parts.length === 0) return this.skipEmpty();
|
|
506
|
+
return this.#commit(geometryType.polygon, "Polygon", id);
|
|
507
|
+
}
|
|
508
|
+
case "GeometryCollection": {
|
|
509
|
+
const geometries = this.#list(geometry.geometries);
|
|
510
|
+
if (geometries.length === 0) return this.skipEmpty();
|
|
511
|
+
for (const child of geometries) this.#addGeometry(child, id);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
default:
|
|
515
|
+
throw this.#invalid(`unsupported geometry type ${JSON.stringify(type)}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
#resetGeometry() {
|
|
519
|
+
this.#points.length = 0;
|
|
520
|
+
this.#parts.length = 0;
|
|
521
|
+
this.#clampedThisFeature = false;
|
|
522
|
+
}
|
|
523
|
+
#list(value) {
|
|
524
|
+
if (!Array.isArray(value)) throw this.#invalid("coordinates are not an array");
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
#pushLine(line) {
|
|
528
|
+
const positions = this.#list(line);
|
|
529
|
+
if (positions.length === 0) return;
|
|
530
|
+
if (positions.length < 2) {
|
|
531
|
+
this.#degenerateParts += 1;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const start = this.#points.length;
|
|
535
|
+
for (const position of positions) this.#pushPosition(position);
|
|
536
|
+
this.#checkWideSegments(start);
|
|
537
|
+
this.#parts.push(positions.length);
|
|
538
|
+
}
|
|
539
|
+
#pushRing(ring, exterior) {
|
|
540
|
+
const positions = this.#list(ring);
|
|
541
|
+
if (positions.length === 0) return;
|
|
542
|
+
const start = this.#points.length;
|
|
543
|
+
const clampedBefore = this.#clampedThisFeature;
|
|
544
|
+
for (const position of positions) this.#pushPosition(position);
|
|
545
|
+
this.#checkWideSegments(start);
|
|
546
|
+
const points = this.#points;
|
|
547
|
+
let count = (points.length - start) / 2;
|
|
548
|
+
if (points[start] === points.at(-2) && points[start + 1] === points.at(-1)) {
|
|
549
|
+
count -= 1;
|
|
550
|
+
points.length -= 2;
|
|
551
|
+
}
|
|
552
|
+
if (count < 3) {
|
|
553
|
+
points.length = start;
|
|
554
|
+
this.#clampedThisFeature = clampedBefore;
|
|
555
|
+
this.#degenerateParts += 1;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
orientRing(points, start, count, exterior);
|
|
559
|
+
this.#parts.push(count);
|
|
560
|
+
}
|
|
561
|
+
#checkWideSegments(start) {
|
|
562
|
+
const points = this.#points;
|
|
563
|
+
for (let index = start + 2; index < points.length; index += 2) {
|
|
564
|
+
if (Math.abs(points[index] - points[index - 2]) > worldSize / 2) {
|
|
565
|
+
this.#wideSegments += 1;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
#pushPosition(position, dropPastMercator = false) {
|
|
571
|
+
if (!Array.isArray(position) || position.length < 2) {
|
|
572
|
+
throw this.#invalid("a position must be [longitude, latitude]");
|
|
573
|
+
}
|
|
574
|
+
this.#pushXY(position[0], position[1], dropPastMercator);
|
|
575
|
+
}
|
|
576
|
+
#pushXY(sourceX, sourceY, dropPastMercator) {
|
|
577
|
+
if (this.#points.length >= maximumFeaturePositions * 2) throw featureTooLarge(this.location);
|
|
578
|
+
if (typeof sourceX !== "number" || typeof sourceY !== "number" || !Number.isFinite(sourceX) || !Number.isFinite(sourceY)) {
|
|
579
|
+
throw this.#invalid(`coordinate [${String(sourceX)}, ${String(sourceY)}] is not numeric`);
|
|
580
|
+
}
|
|
581
|
+
let longitude = sourceX;
|
|
582
|
+
let latitude = sourceY;
|
|
583
|
+
if (this.#transform) [longitude, latitude] = this.#transform(sourceX, sourceY);
|
|
584
|
+
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || Math.abs(longitude) > 180 + coordinateToleranceDegrees || Math.abs(latitude) > 90 + coordinateToleranceDegrees) {
|
|
585
|
+
throw new TilesetBuildError(
|
|
586
|
+
"TF_TILESET_BUILD_COORDINATES_OUT_OF_RANGE",
|
|
587
|
+
"input",
|
|
588
|
+
this.#transform ? `${this.location}: [${sourceX}, ${sourceY}] did not convert to a valid longitude and latitude. The stated CRS probably does not match the data.` : `${this.location}: [${sourceX}, ${sourceY}] is outside WGS84 longitude and latitude. The data looks projected or has swapped axes. State its CRS with --source-crs <crs> --reproject; the build will not guess.`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
if (Math.abs(longitude) > 180 || Math.abs(latitude) > 90) {
|
|
592
|
+
longitude = Math.max(-180, Math.min(180, longitude));
|
|
593
|
+
latitude = Math.max(-90, Math.min(90, latitude));
|
|
594
|
+
this.#nudgedCoordinates += 1;
|
|
595
|
+
}
|
|
596
|
+
if (dropPastMercator && Math.abs(latitude) > maximumMercatorLatitude) {
|
|
597
|
+
this.#outsideMercator += 1;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (longitude < this.#minLon) this.#minLon = longitude;
|
|
601
|
+
if (longitude > this.#maxLon) this.#maxLon = longitude;
|
|
602
|
+
const clampedLatitude = Math.max(
|
|
603
|
+
-maximumMercatorLatitude,
|
|
604
|
+
Math.min(maximumMercatorLatitude, latitude)
|
|
605
|
+
);
|
|
606
|
+
if (clampedLatitude !== latitude) this.#markClamped();
|
|
607
|
+
if (clampedLatitude < this.#minLat) this.#minLat = clampedLatitude;
|
|
608
|
+
if (clampedLatitude > this.#maxLat) this.#maxLat = clampedLatitude;
|
|
609
|
+
const sine = Math.sin(clampedLatitude * Math.PI / 180);
|
|
610
|
+
const worldX = (longitude + 180) / 360 * worldSize;
|
|
611
|
+
const worldY = (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * worldSize;
|
|
612
|
+
this.#points.push(
|
|
613
|
+
Math.min(worldSize - 1, Math.max(0, Math.round(worldX))),
|
|
614
|
+
Math.min(worldSize - 1, Math.max(0, Math.round(worldY)))
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
#clampedThisFeature = false;
|
|
618
|
+
#markClamped() {
|
|
619
|
+
this.#clampedThisFeature = true;
|
|
620
|
+
}
|
|
621
|
+
#commit(type, typeName, id) {
|
|
622
|
+
if (this.#clampedThisFeature) this.#clampedLatitude += 1;
|
|
623
|
+
this.#clampedThisFeature = false;
|
|
624
|
+
if (this.rank) {
|
|
625
|
+
if (this.#features === this.#rankKeys.length) {
|
|
626
|
+
const grown = new Float64Array(this.#rankKeys.length * 2);
|
|
627
|
+
grown.set(this.#rankKeys);
|
|
628
|
+
this.#rankKeys = grown;
|
|
629
|
+
}
|
|
630
|
+
this.#rankKeys[this.#features] = this.#currentRank;
|
|
631
|
+
}
|
|
632
|
+
const points = this.#points;
|
|
633
|
+
const partCount = this.#parts.length;
|
|
634
|
+
const pointCount = points.length / 2;
|
|
635
|
+
const propertyWords = Math.ceil(this.#propertyLength / 4);
|
|
636
|
+
const size = headerWords + partCount + points.length + propertyWords;
|
|
637
|
+
const offset = this.spool.reserve(size);
|
|
638
|
+
const words = this.spool.words;
|
|
639
|
+
let minX = Infinity;
|
|
640
|
+
let minY = Infinity;
|
|
641
|
+
let maxX = -Infinity;
|
|
642
|
+
let maxY = -Infinity;
|
|
643
|
+
let target = offset + headerWords + partCount;
|
|
644
|
+
for (let index = 0; index < points.length; index += 2) {
|
|
645
|
+
const x = points[index];
|
|
646
|
+
const y = points[index + 1];
|
|
647
|
+
if (x < minX) minX = x;
|
|
648
|
+
if (x > maxX) maxX = x;
|
|
649
|
+
if (y < minY) minY = y;
|
|
650
|
+
if (y > maxY) maxY = y;
|
|
651
|
+
words[target] = x;
|
|
652
|
+
words[target + 1] = y;
|
|
653
|
+
target += 2;
|
|
654
|
+
this.#sampleVertex(x, y);
|
|
655
|
+
}
|
|
656
|
+
words[offset + field.words] = size;
|
|
657
|
+
words[offset + field.type] = type;
|
|
658
|
+
words[offset + field.minX] = minX;
|
|
659
|
+
words[offset + field.minY] = minY;
|
|
660
|
+
words[offset + field.maxX] = maxX;
|
|
661
|
+
words[offset + field.maxY] = maxY;
|
|
662
|
+
words[offset + field.parts] = partCount;
|
|
663
|
+
words[offset + field.points] = pointCount;
|
|
664
|
+
words[offset + field.propertyBytes] = this.#propertyLength;
|
|
665
|
+
words[offset + field.idLow] = id % 2 ** 32;
|
|
666
|
+
words[offset + field.idHigh] = Math.floor(id / 2 ** 32);
|
|
667
|
+
words[offset + field.kept] = 0;
|
|
668
|
+
for (let index = 0; index < partCount; index += 1) {
|
|
669
|
+
words[offset + headerWords + index] = this.#parts[index];
|
|
670
|
+
}
|
|
671
|
+
if (propertyWords > 0) {
|
|
672
|
+
words[offset + size - 1] = 0;
|
|
673
|
+
new Uint8Array(words.buffer, words.byteOffset + target * 4, this.#propertyLength).set(
|
|
674
|
+
this.#properties.subarray(0, this.#propertyLength)
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
this.#features += 1;
|
|
678
|
+
this.#types.add(typeName);
|
|
679
|
+
}
|
|
680
|
+
#sampleVertex(x, y) {
|
|
681
|
+
this.#sampleSeen += 1;
|
|
682
|
+
if (this.#sampleSeen % this.#sampleStride !== 0) return;
|
|
683
|
+
if (this.sampleCount === sampleCapacity) {
|
|
684
|
+
for (let index = 0; index < sampleCapacity / 2; index += 1) {
|
|
685
|
+
this.sample[index * 2] = this.sample[index * 4];
|
|
686
|
+
this.sample[index * 2 + 1] = this.sample[index * 4 + 1];
|
|
687
|
+
}
|
|
688
|
+
this.sampleCount = sampleCapacity / 2;
|
|
689
|
+
this.#sampleStride *= 2;
|
|
690
|
+
if (this.#sampleSeen % this.#sampleStride !== 0) return;
|
|
691
|
+
}
|
|
692
|
+
this.sample[this.sampleCount * 2] = x;
|
|
693
|
+
this.sample[this.sampleCount * 2 + 1] = y;
|
|
694
|
+
this.sampleCount += 1;
|
|
695
|
+
}
|
|
696
|
+
#encodeProperties(properties) {
|
|
697
|
+
this.#propertyLength = 0;
|
|
698
|
+
const { exclude, include } = this.selection;
|
|
699
|
+
for (const name in properties) {
|
|
700
|
+
if (!Object.hasOwn(properties, name)) continue;
|
|
701
|
+
const value = properties[name];
|
|
702
|
+
if (value === null || value === void 0) continue;
|
|
703
|
+
if (include ? !include.has(name) : exclude?.has(name)) continue;
|
|
704
|
+
this.#encodeProperty(name, value);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
#encodeProperty(name, value) {
|
|
708
|
+
let key = this.#keys.get(name);
|
|
709
|
+
if (key === void 0) {
|
|
710
|
+
key = this.#keys.size;
|
|
711
|
+
if (key >= 65535) {
|
|
712
|
+
throw new TilesetBuildError(
|
|
713
|
+
"TF_TILESET_BUILD_TOO_MANY_FIELDS",
|
|
714
|
+
"input.fields",
|
|
715
|
+
`The input has more than ${key} attribute fields. Pick what maps need with --include.`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
this.#keys.set(name, key);
|
|
719
|
+
this.#keyTypes.push(0);
|
|
720
|
+
this.#statistics.push({
|
|
721
|
+
complete: true,
|
|
722
|
+
count: 0,
|
|
723
|
+
max: -Infinity,
|
|
724
|
+
min: Infinity,
|
|
725
|
+
values: /* @__PURE__ */ new Set()
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
this.#recordValue(key, value);
|
|
729
|
+
const start = this.#propertyLength + 6;
|
|
730
|
+
let end;
|
|
731
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
732
|
+
this.#keyTypes[key] |= typeBits.number;
|
|
733
|
+
end = this.#encodeNumber(start, value);
|
|
734
|
+
} else if (typeof value === "boolean") {
|
|
735
|
+
this.#keyTypes[key] |= typeBits.boolean;
|
|
736
|
+
this.#ensureProperties(start + 2);
|
|
737
|
+
this.#properties[start] = 56;
|
|
738
|
+
this.#properties[start + 1] = value ? 1 : 0;
|
|
739
|
+
end = start + 2;
|
|
740
|
+
} else {
|
|
741
|
+
this.#keyTypes[key] |= typeBits.string;
|
|
742
|
+
end = this.#encodeString(start, typeof value === "string" ? value : JSON.stringify(value));
|
|
743
|
+
}
|
|
744
|
+
this.#propertiesView.setUint16(this.#propertyLength, key, true);
|
|
745
|
+
this.#propertiesView.setUint32(this.#propertyLength + 2, end - start, true);
|
|
746
|
+
this.#propertyLength = end;
|
|
747
|
+
}
|
|
748
|
+
#encodeNumber(start, value) {
|
|
749
|
+
this.#ensureProperties(start + 12);
|
|
750
|
+
if (Number.isSafeInteger(value)) {
|
|
751
|
+
let remaining = value >= 0 ? value * 2 : -value * 2 - 1;
|
|
752
|
+
let cursor = start;
|
|
753
|
+
this.#properties[cursor] = 48;
|
|
754
|
+
cursor += 1;
|
|
755
|
+
while (remaining >= 128) {
|
|
756
|
+
this.#properties[cursor] = remaining % 128 | 128;
|
|
757
|
+
cursor += 1;
|
|
758
|
+
remaining = Math.floor(remaining / 128);
|
|
759
|
+
}
|
|
760
|
+
this.#properties[cursor] = remaining;
|
|
761
|
+
return cursor + 1;
|
|
762
|
+
}
|
|
763
|
+
this.#properties[start] = 25;
|
|
764
|
+
this.#propertiesView.setFloat64(start + 1, value, true);
|
|
765
|
+
return start + 9;
|
|
766
|
+
}
|
|
767
|
+
#encodeString(start, value) {
|
|
768
|
+
const byteLength = Buffer.byteLength(value);
|
|
769
|
+
this.#ensureProperties(start + 6 + byteLength);
|
|
770
|
+
let cursor = start;
|
|
771
|
+
this.#properties[cursor] = 10;
|
|
772
|
+
cursor += 1;
|
|
773
|
+
let remaining = byteLength;
|
|
774
|
+
while (remaining >= 128) {
|
|
775
|
+
this.#properties[cursor] = remaining & 127 | 128;
|
|
776
|
+
cursor += 1;
|
|
777
|
+
remaining >>>= 7;
|
|
778
|
+
}
|
|
779
|
+
this.#properties[cursor] = remaining;
|
|
780
|
+
cursor += 1;
|
|
781
|
+
Buffer.from(this.#properties.buffer).write(value, cursor, "utf8");
|
|
782
|
+
return cursor + byteLength;
|
|
783
|
+
}
|
|
784
|
+
#ensureProperties(size) {
|
|
785
|
+
if (size <= this.#properties.length) return;
|
|
786
|
+
const grown = new Uint8Array(Math.max(size, this.#properties.length * 2));
|
|
787
|
+
grown.set(this.#properties);
|
|
788
|
+
this.#properties = grown;
|
|
789
|
+
this.#propertiesView = new DataView(grown.buffer);
|
|
790
|
+
}
|
|
791
|
+
#invalid(reason) {
|
|
792
|
+
return new TilesetBuildError(
|
|
793
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
794
|
+
"input",
|
|
795
|
+
`${this.location}: ${reason}.`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
function isEmptyList(value) {
|
|
800
|
+
return Array.isArray(value) && value.length === 0;
|
|
801
|
+
}
|
|
802
|
+
function orientRing(points, start, count, exterior) {
|
|
803
|
+
let area = 0;
|
|
804
|
+
let previousX = points[start + (count - 1) * 2];
|
|
805
|
+
let previousY = points[start + (count - 1) * 2 + 1];
|
|
806
|
+
for (let index = 0; index < count; index += 1) {
|
|
807
|
+
const x = points[start + index * 2];
|
|
808
|
+
const y = points[start + index * 2 + 1];
|
|
809
|
+
area += previousX * y - x * previousY;
|
|
810
|
+
previousX = x;
|
|
811
|
+
previousY = y;
|
|
812
|
+
}
|
|
813
|
+
if (area === 0 || area > 0 === exterior) return;
|
|
814
|
+
for (let low = 0, high = count - 1; low < high; low += 1, high -= 1) {
|
|
815
|
+
const x = points[start + low * 2];
|
|
816
|
+
const y = points[start + low * 2 + 1];
|
|
817
|
+
points[start + low * 2] = points[start + high * 2];
|
|
818
|
+
points[start + low * 2 + 1] = points[start + high * 2 + 1];
|
|
819
|
+
points[start + high * 2] = x;
|
|
820
|
+
points[start + high * 2 + 1] = y;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function safeFieldName(name, key) {
|
|
824
|
+
if (fieldNamePattern.test(name)) return name;
|
|
825
|
+
const cleaned = name.normalize("NFKD").replace(/\p{M}/gu, "").replace(/[^A-Za-z0-9._:-]+/gu, "_").replace(/^[^A-Za-z0-9]+/u, "").slice(0, 192);
|
|
826
|
+
return cleaned || `field_${key + 1}`;
|
|
827
|
+
}
|
|
828
|
+
function fieldType(bits) {
|
|
829
|
+
if (bits === typeBits.boolean) return "Boolean";
|
|
830
|
+
if (bits === typeBits.number) return "Number";
|
|
831
|
+
if (bits === typeBits.string) return "String";
|
|
832
|
+
return "Mixed";
|
|
833
|
+
}
|
|
834
|
+
function compareFieldValues(left, right) {
|
|
835
|
+
if (typeof left === "number" && typeof right === "number") return left - right;
|
|
836
|
+
const [a, b] = [String(left), String(right)];
|
|
837
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/tileset-build/pmtiles-writer.ts
|
|
841
|
+
import { createHash as createHash2 } from "crypto";
|
|
842
|
+
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, writeSync as writeSync2 } from "fs";
|
|
843
|
+
import { zxyToTileId } from "pmtiles";
|
|
844
|
+
|
|
845
|
+
// src/tileset-build/gzip.ts
|
|
846
|
+
import { deflateSync } from "fflate";
|
|
847
|
+
import { gzip as gzipCallback, gzipSync } from "zlib";
|
|
848
|
+
var operatingSystemByte = 9;
|
|
849
|
+
var unknownOperatingSystem = 255;
|
|
850
|
+
function withStableHeader(compressed) {
|
|
851
|
+
compressed[operatingSystemByte] = unknownOperatingSystem;
|
|
852
|
+
return compressed;
|
|
853
|
+
}
|
|
854
|
+
function stableGzipSync(bytes) {
|
|
855
|
+
return withStableHeader(gzipSync(bytes));
|
|
856
|
+
}
|
|
857
|
+
function stableGzip(bytes) {
|
|
858
|
+
return new Promise((resolve2, reject) => {
|
|
859
|
+
gzipCallback(bytes, (error, result) => {
|
|
860
|
+
if (error) reject(error);
|
|
861
|
+
else resolve2(withStableHeader(result));
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
function compressedSizeForBudget(bytes) {
|
|
866
|
+
return deflateSync(bytes, { level: 6 }).length;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/tileset-build/pmtiles-writer.ts
|
|
870
|
+
var headerBytes = 127;
|
|
871
|
+
var rootLimitBytes = 16384 - headerBytes;
|
|
872
|
+
var maximumEntries = 16e6;
|
|
873
|
+
var maximumClusteredContents = 2e5;
|
|
874
|
+
var deduplicatedTileBytes = 4096;
|
|
875
|
+
var maximumKnownContents = 2e6;
|
|
876
|
+
var concurrentCompression = 8;
|
|
877
|
+
var TileArchiveWriter = class {
|
|
878
|
+
constructor(dataPath) {
|
|
879
|
+
this.dataPath = dataPath;
|
|
880
|
+
this.#descriptor = openSync2(dataPath, "w");
|
|
881
|
+
}
|
|
882
|
+
dataPath;
|
|
883
|
+
#descriptor;
|
|
884
|
+
#dataOffset = 0;
|
|
885
|
+
#contentOffsets = [];
|
|
886
|
+
#contentLengths = [];
|
|
887
|
+
#tileIds = new Float64Array(1024);
|
|
888
|
+
#runLengths = new Float64Array(1024);
|
|
889
|
+
#contents = new Int32Array(1024);
|
|
890
|
+
#entryCount = 0;
|
|
891
|
+
#addressed = 0;
|
|
892
|
+
#known = /* @__PURE__ */ new Map();
|
|
893
|
+
#pending = /* @__PURE__ */ new Set();
|
|
894
|
+
#failure = null;
|
|
895
|
+
get tileCount() {
|
|
896
|
+
return this.#entryCount;
|
|
897
|
+
}
|
|
898
|
+
/** Register tile bytes and return a content reference other tiles may share. */
|
|
899
|
+
async addTile(z, x, y, tile) {
|
|
900
|
+
let content = -1;
|
|
901
|
+
let identity = null;
|
|
902
|
+
if (tile.length <= deduplicatedTileBytes) {
|
|
903
|
+
identity = createHash2("sha1").update(tile).digest("base64");
|
|
904
|
+
content = this.#known.get(identity) ?? -1;
|
|
905
|
+
}
|
|
906
|
+
if (content < 0) {
|
|
907
|
+
content = this.#contentOffsets.length;
|
|
908
|
+
this.#contentOffsets.push(-1);
|
|
909
|
+
this.#contentLengths.push(0);
|
|
910
|
+
if (identity !== null && this.#known.size < maximumKnownContents) {
|
|
911
|
+
this.#known.set(identity, content);
|
|
912
|
+
}
|
|
913
|
+
await this.#compress(content, tile);
|
|
914
|
+
}
|
|
915
|
+
this.#addEntry(zxyToTileId(z, x, y), 1, content);
|
|
916
|
+
return content;
|
|
917
|
+
}
|
|
918
|
+
/** Point a whole z/x/y subtree level at one content; Hilbert ordering keeps it contiguous. */
|
|
919
|
+
addSubtreeLevel(z, x, y, depth, content) {
|
|
920
|
+
const levelStart = (4 ** (z + depth) - 1) / 3;
|
|
921
|
+
const span = 4 ** depth;
|
|
922
|
+
const sample = zxyToTileId(z + depth, x * 2 ** depth, y * 2 ** depth) - levelStart;
|
|
923
|
+
this.#addEntry(levelStart + Math.floor(sample / span) * span, span, content);
|
|
924
|
+
}
|
|
925
|
+
/** Wait for compression, then write the finished archive. Tile contents are copied in order. */
|
|
926
|
+
async finish(archivePath, fields, metadata) {
|
|
927
|
+
await Promise.all(this.#pending);
|
|
928
|
+
if (this.#failure) throw this.#failure;
|
|
929
|
+
this.close();
|
|
930
|
+
const entries = this.#sortedEntries();
|
|
931
|
+
const input = openSync2(this.dataPath, "r");
|
|
932
|
+
const output = openSync2(archivePath, "w");
|
|
933
|
+
try {
|
|
934
|
+
return this.#layOut(entries, input, output, fields, metadata);
|
|
935
|
+
} finally {
|
|
936
|
+
closeSync2(input);
|
|
937
|
+
closeSync2(output);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
close() {
|
|
941
|
+
if (this.#descriptor !== null) closeSync2(this.#descriptor);
|
|
942
|
+
this.#descriptor = null;
|
|
943
|
+
}
|
|
944
|
+
#addEntry(tileId, runLength, content) {
|
|
945
|
+
if (this.#entryCount === maximumEntries) {
|
|
946
|
+
throw new TilesetBuildError(
|
|
947
|
+
"TF_TILESET_BUILD_TOO_MANY_TILES",
|
|
948
|
+
"zoom",
|
|
949
|
+
`The archive would exceed ${maximumEntries} tile entries. Lower --maxzoom.`
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
if (this.#entryCount === this.#tileIds.length) {
|
|
953
|
+
const size = this.#entryCount * 2;
|
|
954
|
+
const tileIds = new Float64Array(size);
|
|
955
|
+
const runLengths = new Float64Array(size);
|
|
956
|
+
const contents = new Int32Array(size);
|
|
957
|
+
tileIds.set(this.#tileIds);
|
|
958
|
+
runLengths.set(this.#runLengths);
|
|
959
|
+
contents.set(this.#contents);
|
|
960
|
+
this.#tileIds = tileIds;
|
|
961
|
+
this.#runLengths = runLengths;
|
|
962
|
+
this.#contents = contents;
|
|
963
|
+
}
|
|
964
|
+
this.#tileIds[this.#entryCount] = tileId;
|
|
965
|
+
this.#runLengths[this.#entryCount] = runLength;
|
|
966
|
+
this.#contents[this.#entryCount] = content;
|
|
967
|
+
this.#entryCount += 1;
|
|
968
|
+
this.#addressed += runLength;
|
|
969
|
+
}
|
|
970
|
+
/** Tile compression runs on the thread pool; results land in completion order, which is fine. */
|
|
971
|
+
async #compress(content, tile) {
|
|
972
|
+
while (this.#pending.size >= concurrentCompression) await Promise.race(this.#pending);
|
|
973
|
+
if (this.#failure) throw this.#failure;
|
|
974
|
+
const task = stableGzip(tile).then((compressed) => {
|
|
975
|
+
if (this.#descriptor === null) return;
|
|
976
|
+
let written = 0;
|
|
977
|
+
while (written < compressed.length) {
|
|
978
|
+
written += writeSync2(this.#descriptor, compressed, written);
|
|
979
|
+
}
|
|
980
|
+
this.#contentOffsets[content] = this.#dataOffset;
|
|
981
|
+
this.#contentLengths[content] = compressed.length;
|
|
982
|
+
this.#dataOffset += compressed.length;
|
|
983
|
+
}).catch((error) => {
|
|
984
|
+
this.#failure = error;
|
|
985
|
+
}).finally(() => {
|
|
986
|
+
this.#pending.delete(task);
|
|
987
|
+
});
|
|
988
|
+
this.#pending.add(task);
|
|
989
|
+
}
|
|
990
|
+
#sortedEntries() {
|
|
991
|
+
const order = new Uint32Array(this.#entryCount);
|
|
992
|
+
for (let index = 0; index < order.length; index += 1) order[index] = index;
|
|
993
|
+
const tileIds = this.#tileIds;
|
|
994
|
+
return order.sort((left, right) => tileIds[left] - tileIds[right]);
|
|
995
|
+
}
|
|
996
|
+
#layOut(order, input, output, fields, metadata) {
|
|
997
|
+
const finalOffsets = new Float64Array(this.#contentOffsets.length).fill(-1);
|
|
998
|
+
const contentOrder = [];
|
|
999
|
+
let tileDataLength = 0;
|
|
1000
|
+
const entries = [];
|
|
1001
|
+
for (const index of order) {
|
|
1002
|
+
const content = this.#contents[index];
|
|
1003
|
+
const tileId = this.#tileIds[index];
|
|
1004
|
+
const runLength = this.#runLengths[index];
|
|
1005
|
+
const previous = entries.at(-1);
|
|
1006
|
+
if (previous && previous.content === content && previous.tileId + previous.runLength === tileId) {
|
|
1007
|
+
previous.runLength += runLength;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (finalOffsets[content] < 0) {
|
|
1011
|
+
finalOffsets[content] = tileDataLength;
|
|
1012
|
+
contentOrder.push(content);
|
|
1013
|
+
tileDataLength += this.#contentLengths[content];
|
|
1014
|
+
}
|
|
1015
|
+
entries.push({
|
|
1016
|
+
content,
|
|
1017
|
+
length: this.#contentLengths[content],
|
|
1018
|
+
offset: finalOffsets[content],
|
|
1019
|
+
runLength,
|
|
1020
|
+
tileId
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
const { leaves, root } = buildDirectories(entries);
|
|
1024
|
+
const metadataBytes = stableGzipSync(Buffer.from(metadata, "utf8"));
|
|
1025
|
+
const rootOffset = headerBytes;
|
|
1026
|
+
const metadataOffset = rootOffset + root.length;
|
|
1027
|
+
const leafOffset = metadataOffset + metadataBytes.length;
|
|
1028
|
+
const dataOffset = leafOffset + leaves.length;
|
|
1029
|
+
const clustered = contentOrder.length <= maximumClusteredContents;
|
|
1030
|
+
const header = Buffer.alloc(headerBytes);
|
|
1031
|
+
header.write("PMTiles", 0, "latin1");
|
|
1032
|
+
header[7] = 3;
|
|
1033
|
+
const writeOffsets = [
|
|
1034
|
+
[8, rootOffset],
|
|
1035
|
+
[16, root.length],
|
|
1036
|
+
[24, metadataOffset],
|
|
1037
|
+
[32, metadataBytes.length],
|
|
1038
|
+
[40, leafOffset],
|
|
1039
|
+
[48, leaves.length],
|
|
1040
|
+
[56, dataOffset],
|
|
1041
|
+
[64, tileDataLength],
|
|
1042
|
+
[72, this.#addressed],
|
|
1043
|
+
[80, entries.length],
|
|
1044
|
+
[88, contentOrder.length]
|
|
1045
|
+
];
|
|
1046
|
+
for (const [position, value] of writeOffsets) header.writeBigUInt64LE(BigInt(value), position);
|
|
1047
|
+
header[96] = clustered ? 1 : 0;
|
|
1048
|
+
header[97] = 2;
|
|
1049
|
+
header[98] = 2;
|
|
1050
|
+
header[99] = 1;
|
|
1051
|
+
header[100] = fields.minzoom;
|
|
1052
|
+
header[101] = fields.maxzoom;
|
|
1053
|
+
header.writeInt32LE(Math.round(fields.bounds[0] * 1e7), 102);
|
|
1054
|
+
header.writeInt32LE(Math.round(fields.bounds[1] * 1e7), 106);
|
|
1055
|
+
header.writeInt32LE(Math.round(fields.bounds[2] * 1e7), 110);
|
|
1056
|
+
header.writeInt32LE(Math.round(fields.bounds[3] * 1e7), 114);
|
|
1057
|
+
header[118] = fields.center[2];
|
|
1058
|
+
header.writeInt32LE(Math.round(fields.center[0] * 1e7), 119);
|
|
1059
|
+
header.writeInt32LE(Math.round(fields.center[1] * 1e7), 123);
|
|
1060
|
+
for (const section of [header, root, metadataBytes, leaves]) writeAll(output, section);
|
|
1061
|
+
let buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
1062
|
+
for (const content of contentOrder) {
|
|
1063
|
+
const length = this.#contentLengths[content];
|
|
1064
|
+
if (length > buffer.length) buffer = Buffer.allocUnsafe(length);
|
|
1065
|
+
readSync2(input, buffer, 0, length, this.#contentOffsets[content]);
|
|
1066
|
+
writeAll(output, buffer.subarray(0, length));
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
addressedTiles: this.#addressed,
|
|
1070
|
+
byteCount: dataOffset + tileDataLength,
|
|
1071
|
+
tileContents: contentOrder.length,
|
|
1072
|
+
tileEntries: entries.length
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
function writeAll(descriptor, bytes) {
|
|
1077
|
+
let written = 0;
|
|
1078
|
+
while (written < bytes.length) {
|
|
1079
|
+
written += writeSync2(descriptor, bytes, written, bytes.length - written);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function buildDirectories(entries) {
|
|
1083
|
+
const root = serializeDirectory(entries);
|
|
1084
|
+
if (root.length <= rootLimitBytes) return { leaves: Buffer.alloc(0), root };
|
|
1085
|
+
for (let leafSize = 4096; ; leafSize = Math.ceil(leafSize * 1.2)) {
|
|
1086
|
+
const rootEntries = [];
|
|
1087
|
+
const leaves = [];
|
|
1088
|
+
let offset = 0;
|
|
1089
|
+
for (let start = 0; start < entries.length; start += leafSize) {
|
|
1090
|
+
const leaf = serializeDirectory(entries.slice(start, start + leafSize));
|
|
1091
|
+
rootEntries.push({
|
|
1092
|
+
content: -1,
|
|
1093
|
+
length: leaf.length,
|
|
1094
|
+
offset,
|
|
1095
|
+
runLength: 0,
|
|
1096
|
+
tileId: entries[start].tileId
|
|
1097
|
+
});
|
|
1098
|
+
leaves.push(leaf);
|
|
1099
|
+
offset += leaf.length;
|
|
1100
|
+
}
|
|
1101
|
+
const serialized = serializeDirectory(rootEntries);
|
|
1102
|
+
if (serialized.length <= rootLimitBytes) {
|
|
1103
|
+
return { leaves: Buffer.concat(leaves), root: serialized };
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function serializeDirectory(entries) {
|
|
1108
|
+
const bytes = Buffer.allocUnsafe(10 + entries.length * 40);
|
|
1109
|
+
let cursor = 0;
|
|
1110
|
+
const varint = (value) => {
|
|
1111
|
+
while (value >= 128) {
|
|
1112
|
+
bytes[cursor] = value % 128 | 128;
|
|
1113
|
+
cursor += 1;
|
|
1114
|
+
value = Math.floor(value / 128);
|
|
1115
|
+
}
|
|
1116
|
+
bytes[cursor] = value;
|
|
1117
|
+
cursor += 1;
|
|
1118
|
+
};
|
|
1119
|
+
varint(entries.length);
|
|
1120
|
+
let lastId = 0;
|
|
1121
|
+
for (const entry of entries) {
|
|
1122
|
+
varint(entry.tileId - lastId);
|
|
1123
|
+
lastId = entry.tileId;
|
|
1124
|
+
}
|
|
1125
|
+
for (const entry of entries) varint(entry.runLength);
|
|
1126
|
+
for (const entry of entries) varint(entry.length);
|
|
1127
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
1128
|
+
const entry = entries[index];
|
|
1129
|
+
const previous = entries[index - 1];
|
|
1130
|
+
const contiguous = previous && entry.offset === previous.offset + previous.length;
|
|
1131
|
+
varint(contiguous ? 0 : entry.offset + 1);
|
|
1132
|
+
}
|
|
1133
|
+
return stableGzipSync(bytes.subarray(0, cursor));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/tileset-build/read-csv.ts
|
|
1137
|
+
import { closeSync as closeSync3, openSync as openSync3, readSync as readSync3 } from "fs";
|
|
1138
|
+
var chunkBytes = 4 * 1024 * 1024;
|
|
1139
|
+
var numberPattern = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u;
|
|
1140
|
+
var longitudeNames = /* @__PURE__ */ new Set(["lon", "lng", "long", "longitude", "x_lon", "lon_x"]);
|
|
1141
|
+
var latitudeNames = /* @__PURE__ */ new Set(["lat", "latitude", "y_lat", "lat_y"]);
|
|
1142
|
+
async function readCsv(path, ingest, options) {
|
|
1143
|
+
const delimiter = detectDelimiter(path);
|
|
1144
|
+
let header = null;
|
|
1145
|
+
let types = [];
|
|
1146
|
+
await forEachRow(path, delimiter, (row, line) => {
|
|
1147
|
+
if (!header) {
|
|
1148
|
+
header = row;
|
|
1149
|
+
types = row.map(() => null);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (row.length === 1 && row[0] === "") return;
|
|
1153
|
+
if (row.length !== header.length) {
|
|
1154
|
+
throw new TilesetBuildError(
|
|
1155
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1156
|
+
"input",
|
|
1157
|
+
`row ${line}: expected ${header.length} columns but found ${row.length}. Check quoting and the delimiter.`
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
for (let index = 0; index < row.length; index += 1) {
|
|
1161
|
+
types[index] = mergeType(types[index], row[index]);
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
if (!header) {
|
|
1165
|
+
throw new TilesetBuildError("TF_TILESET_BUILD_EMPTY", "input", "The CSV file is empty.");
|
|
1166
|
+
}
|
|
1167
|
+
const columns = header;
|
|
1168
|
+
const longitude = coordinateColumn(columns, options.lonColumn, longitudeNames, "longitude");
|
|
1169
|
+
const latitude = coordinateColumn(columns, options.latColumn, latitudeNames, "latitude");
|
|
1170
|
+
let isHeader = true;
|
|
1171
|
+
await forEachRow(path, delimiter, (row, line) => {
|
|
1172
|
+
if (isHeader) {
|
|
1173
|
+
isHeader = false;
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (row.length === 1 && row[0] === "") return;
|
|
1177
|
+
ingest.location = `row ${line}`;
|
|
1178
|
+
const rawX = row[longitude].trim();
|
|
1179
|
+
const rawY = row[latitude].trim();
|
|
1180
|
+
if (rawX === "" && rawY === "") {
|
|
1181
|
+
ingest.skipEmpty();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const x = Number(rawX);
|
|
1185
|
+
const y = Number(rawY);
|
|
1186
|
+
if (rawX === "" || rawY === "" || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
1187
|
+
throw new TilesetBuildError(
|
|
1188
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1189
|
+
"input",
|
|
1190
|
+
`row ${line}: coordinates "${rawX}", "${rawY}" are not numbers. Decimal commas are not supported.`
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const properties = {};
|
|
1194
|
+
for (let index = 0; index < row.length; index += 1) {
|
|
1195
|
+
if (index === longitude || index === latitude) continue;
|
|
1196
|
+
const value = typedValue(row[index], types[index]);
|
|
1197
|
+
if (value !== null) properties[columns[index]] = value;
|
|
1198
|
+
}
|
|
1199
|
+
ingest.add({ coordinates: [x, y], type: "Point" }, properties);
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
async function csvColumnsNameCoordinates(path, options) {
|
|
1203
|
+
if (options.lonColumn || options.latColumn) return true;
|
|
1204
|
+
let names = [];
|
|
1205
|
+
await forEachRow(path, detectDelimiter(path), (row) => {
|
|
1206
|
+
names = row;
|
|
1207
|
+
return "stop";
|
|
1208
|
+
});
|
|
1209
|
+
const normalized = names.map(normalizeName);
|
|
1210
|
+
return normalized.some((name) => longitudeNames.has(name)) && normalized.some((name) => latitudeNames.has(name));
|
|
1211
|
+
}
|
|
1212
|
+
async function csvColumnNames(path) {
|
|
1213
|
+
let names = [];
|
|
1214
|
+
await forEachRow(path, detectDelimiter(path), (row) => {
|
|
1215
|
+
names = row;
|
|
1216
|
+
return "stop";
|
|
1217
|
+
});
|
|
1218
|
+
return names;
|
|
1219
|
+
}
|
|
1220
|
+
function normalizeName(name) {
|
|
1221
|
+
return name.trim().toLowerCase();
|
|
1222
|
+
}
|
|
1223
|
+
function coordinateColumn(header, requested, names, label) {
|
|
1224
|
+
if (requested !== void 0) {
|
|
1225
|
+
const index = header.indexOf(requested);
|
|
1226
|
+
if (index < 0) {
|
|
1227
|
+
throw new TilesetBuildError(
|
|
1228
|
+
"TF_TILESET_BUILD_CSV_COLUMNS",
|
|
1229
|
+
"input.columns",
|
|
1230
|
+
`Column "${requested}" does not exist. Available columns: ${header.join(", ")}.`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
return index;
|
|
1234
|
+
}
|
|
1235
|
+
const matches2 = header.flatMap((name, index) => names.has(normalizeName(name)) ? [index] : []);
|
|
1236
|
+
if (matches2.length === 1) return matches2[0];
|
|
1237
|
+
throw new TilesetBuildError(
|
|
1238
|
+
"TF_TILESET_BUILD_CSV_COLUMNS",
|
|
1239
|
+
"input.columns",
|
|
1240
|
+
matches2.length > 1 ? `Several columns could hold the ${label}: ${matches2.map((index) => header[index]).join(", ")}. Pick one with --${label === "longitude" ? "lon" : "lat"}-column.` : `No ${label} column found among: ${header.join(", ")}. Name it with --${label === "longitude" ? "lon" : "lat"}-column. Columns such as x and y are never assumed to be WGS84.`
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
function mergeType(current2, value) {
|
|
1244
|
+
if (value === "") return current2;
|
|
1245
|
+
const type = valueType(value);
|
|
1246
|
+
if (current2 === null || current2 === type) return type;
|
|
1247
|
+
return "string";
|
|
1248
|
+
}
|
|
1249
|
+
function valueType(value) {
|
|
1250
|
+
if (value === "true" || value === "false") return "boolean";
|
|
1251
|
+
if (numberPattern.test(value) && Number.isFinite(Number(value))) {
|
|
1252
|
+
return value.includes(".") || value.includes("e") || value.includes("E") || Number.isSafeInteger(Number(value)) ? "number" : "string";
|
|
1253
|
+
}
|
|
1254
|
+
return "string";
|
|
1255
|
+
}
|
|
1256
|
+
function typedValue(value, type) {
|
|
1257
|
+
if (value === "") return null;
|
|
1258
|
+
if (type === "number") return Number(value);
|
|
1259
|
+
if (type === "boolean") return value === "true";
|
|
1260
|
+
return value;
|
|
1261
|
+
}
|
|
1262
|
+
function detectDelimiter(path) {
|
|
1263
|
+
const descriptor = openSync3(path, "r");
|
|
1264
|
+
const buffer = Buffer.allocUnsafe(65536);
|
|
1265
|
+
const bytes = readSync3(descriptor, buffer, 0, buffer.length, 0);
|
|
1266
|
+
closeSync3(descriptor);
|
|
1267
|
+
const firstLine = buffer.subarray(0, bytes).toString("utf8").split(/\r?\n/u, 1)[0] ?? "";
|
|
1268
|
+
let best = ",";
|
|
1269
|
+
let bestCount = 0;
|
|
1270
|
+
for (const candidate of [",", ";", " ", "|"]) {
|
|
1271
|
+
const count = firstLine.split(candidate).length - 1;
|
|
1272
|
+
if (count > bestCount) {
|
|
1273
|
+
best = candidate;
|
|
1274
|
+
bestCount = count;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return best;
|
|
1278
|
+
}
|
|
1279
|
+
async function forEachRow(path, delimiter, visit) {
|
|
1280
|
+
const descriptor = openSync3(path, "r");
|
|
1281
|
+
const buffer = Buffer.allocUnsafe(chunkBytes);
|
|
1282
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1283
|
+
let row = [];
|
|
1284
|
+
let cell = "";
|
|
1285
|
+
let quoted = false;
|
|
1286
|
+
let quotePending = false;
|
|
1287
|
+
let line = 1;
|
|
1288
|
+
let rowLine = 1;
|
|
1289
|
+
let first = true;
|
|
1290
|
+
const endCell = () => {
|
|
1291
|
+
row.push(cell);
|
|
1292
|
+
cell = "";
|
|
1293
|
+
};
|
|
1294
|
+
let stopped = false;
|
|
1295
|
+
const endRow = () => {
|
|
1296
|
+
endCell();
|
|
1297
|
+
if (visit(row, rowLine) === "stop") stopped = true;
|
|
1298
|
+
row = [];
|
|
1299
|
+
};
|
|
1300
|
+
const decode = (bytes) => {
|
|
1301
|
+
try {
|
|
1302
|
+
return bytes ? decoder.decode(bytes, { stream: true }) : decoder.decode();
|
|
1303
|
+
} catch {
|
|
1304
|
+
throw new TilesetBuildError(
|
|
1305
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1306
|
+
"input",
|
|
1307
|
+
`Near row ${line}: the CSV file is not valid UTF-8. Re-encode it as UTF-8 first.`
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const consume = (text) => {
|
|
1312
|
+
let start = 0;
|
|
1313
|
+
let index = 0;
|
|
1314
|
+
const length = text.length;
|
|
1315
|
+
while (index < length && !stopped) {
|
|
1316
|
+
const character = text[index];
|
|
1317
|
+
if (quoted) {
|
|
1318
|
+
if (character === '"') {
|
|
1319
|
+
cell += text.slice(start, index);
|
|
1320
|
+
quoted = false;
|
|
1321
|
+
quotePending = true;
|
|
1322
|
+
start = index + 1;
|
|
1323
|
+
} else if (character === "\n") {
|
|
1324
|
+
line += 1;
|
|
1325
|
+
}
|
|
1326
|
+
index += 1;
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
if (quotePending) {
|
|
1330
|
+
quotePending = false;
|
|
1331
|
+
if (character === '"') {
|
|
1332
|
+
cell += '"';
|
|
1333
|
+
quoted = true;
|
|
1334
|
+
start = index + 1;
|
|
1335
|
+
index += 1;
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (character === '"' && start === index && cell === "") {
|
|
1340
|
+
quoted = true;
|
|
1341
|
+
start = index + 1;
|
|
1342
|
+
} else if (character === delimiter) {
|
|
1343
|
+
cell += text.slice(start, index);
|
|
1344
|
+
endCell();
|
|
1345
|
+
start = index + 1;
|
|
1346
|
+
} else if (character === "\n") {
|
|
1347
|
+
cell += text.slice(start, index);
|
|
1348
|
+
if (cell.endsWith("\r")) cell = cell.slice(0, -1);
|
|
1349
|
+
endRow();
|
|
1350
|
+
line += 1;
|
|
1351
|
+
rowLine = line;
|
|
1352
|
+
start = index + 1;
|
|
1353
|
+
}
|
|
1354
|
+
index += 1;
|
|
1355
|
+
}
|
|
1356
|
+
cell += text.slice(start, length);
|
|
1357
|
+
};
|
|
1358
|
+
try {
|
|
1359
|
+
for (; ; ) {
|
|
1360
|
+
const bytes = readSync3(descriptor, buffer, 0, chunkBytes, null);
|
|
1361
|
+
if (bytes === 0) break;
|
|
1362
|
+
let text = decode(buffer.subarray(0, bytes));
|
|
1363
|
+
if (first) {
|
|
1364
|
+
first = false;
|
|
1365
|
+
if (text.charCodeAt(0) === 65279) text = text.slice(1);
|
|
1366
|
+
}
|
|
1367
|
+
consume(text);
|
|
1368
|
+
if (stopped) return;
|
|
1369
|
+
await yieldToEventLoop();
|
|
1370
|
+
}
|
|
1371
|
+
consume(decode());
|
|
1372
|
+
if (quoted) {
|
|
1373
|
+
throw new TilesetBuildError(
|
|
1374
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1375
|
+
"input",
|
|
1376
|
+
`row ${rowLine}: a quoted value never closes.`
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
if (cell !== "" || row.length > 0) {
|
|
1380
|
+
if (cell.endsWith("\r")) cell = cell.slice(0, -1);
|
|
1381
|
+
endRow();
|
|
1382
|
+
}
|
|
1383
|
+
} finally {
|
|
1384
|
+
closeSync3(descriptor);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// src/tileset-build/read-geojson.ts
|
|
1389
|
+
import { closeSync as closeSync4, fstatSync, openSync as openSync4, readFileSync, readSync as readSync4 } from "fs";
|
|
1390
|
+
var chunkBytes2 = 4 * 1024 * 1024;
|
|
1391
|
+
var maximumWholeDocumentBytes = 512 * 1024 * 1024;
|
|
1392
|
+
var quote = 34;
|
|
1393
|
+
var backslash = 92;
|
|
1394
|
+
var openBrace = 123;
|
|
1395
|
+
var closeBrace = 125;
|
|
1396
|
+
var openBracket = 91;
|
|
1397
|
+
var closeBracket = 93;
|
|
1398
|
+
var comma = 44;
|
|
1399
|
+
var colon = 58;
|
|
1400
|
+
async function readGeojson(path, ingest, onCrs) {
|
|
1401
|
+
let index = 0;
|
|
1402
|
+
const scanner = new TopLevelScanner((bytes) => {
|
|
1403
|
+
index += 1;
|
|
1404
|
+
ingest.location = `feature ${index}`;
|
|
1405
|
+
addFeature(parseJson(bytes, ingest.location), ingest);
|
|
1406
|
+
}, onCrs);
|
|
1407
|
+
await forEachChunk(path, (chunk) => scanner.write(chunk));
|
|
1408
|
+
scanner.end();
|
|
1409
|
+
if (!scanner.sawFeatures) readSingleObject(path, ingest, onCrs);
|
|
1410
|
+
}
|
|
1411
|
+
async function readGeojsonSequence(path, ingest) {
|
|
1412
|
+
let line = 0;
|
|
1413
|
+
let carried = [];
|
|
1414
|
+
let carriedBytes = 0;
|
|
1415
|
+
const emit = (bytes) => {
|
|
1416
|
+
line += 1;
|
|
1417
|
+
let start = 0;
|
|
1418
|
+
let end = bytes.length;
|
|
1419
|
+
while (start < end && isSpaceOrSeparator(bytes[start])) start += 1;
|
|
1420
|
+
while (end > start && isSpaceOrSeparator(bytes[end - 1])) end -= 1;
|
|
1421
|
+
if (start === end) return;
|
|
1422
|
+
ingest.location = `line ${line}`;
|
|
1423
|
+
addFeature(parseJson(bytes.subarray(start, end), ingest.location), ingest);
|
|
1424
|
+
};
|
|
1425
|
+
await forEachChunk(path, (chunk) => {
|
|
1426
|
+
let start = 0;
|
|
1427
|
+
for (; ; ) {
|
|
1428
|
+
const newline = chunk.indexOf(10, start);
|
|
1429
|
+
if (newline < 0) break;
|
|
1430
|
+
const piece = chunk.subarray(start, newline);
|
|
1431
|
+
if (carried.length > 0) {
|
|
1432
|
+
emit(Buffer.concat([...carried, piece]));
|
|
1433
|
+
carried = [];
|
|
1434
|
+
carriedBytes = 0;
|
|
1435
|
+
} else {
|
|
1436
|
+
emit(piece);
|
|
1437
|
+
}
|
|
1438
|
+
start = newline + 1;
|
|
1439
|
+
}
|
|
1440
|
+
if (start < chunk.length) {
|
|
1441
|
+
carried.push(Buffer.from(chunk.subarray(start)));
|
|
1442
|
+
carriedBytes += chunk.length - start;
|
|
1443
|
+
if (carriedBytes > maximumFeatureBytes) throw featureTooManyBytes(`line ${line + 1}`);
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
if (carried.length > 0) emit(Buffer.concat(carried));
|
|
1447
|
+
}
|
|
1448
|
+
function readSingleObject(path, ingest, onCrs) {
|
|
1449
|
+
const descriptor = openSync4(path, "r");
|
|
1450
|
+
const size = fstatSync(descriptor).size;
|
|
1451
|
+
closeSync4(descriptor);
|
|
1452
|
+
if (size > maximumWholeDocumentBytes) {
|
|
1453
|
+
throw new TilesetBuildError(
|
|
1454
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1455
|
+
"input",
|
|
1456
|
+
"Single-object GeoJSON documents are limited to 512 MiB. Use a FeatureCollection or newline-delimited features."
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
ingest.location = "document";
|
|
1460
|
+
const document = parseJson(readFileSync(path), ingest.location);
|
|
1461
|
+
if (document.type === "FeatureCollection") return;
|
|
1462
|
+
const crs = document.crs;
|
|
1463
|
+
if (crs !== void 0) onCrs(crs);
|
|
1464
|
+
addFeature(document, ingest);
|
|
1465
|
+
}
|
|
1466
|
+
function addFeature(value, ingest) {
|
|
1467
|
+
if (value === null || typeof value !== "object") {
|
|
1468
|
+
throw new TilesetBuildError(
|
|
1469
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1470
|
+
"input",
|
|
1471
|
+
`${ingest.location}: expected a GeoJSON object.`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
if (value.type === "Feature") {
|
|
1475
|
+
ingest.add(value.geometry, value.properties ?? {}, value.id);
|
|
1476
|
+
} else {
|
|
1477
|
+
ingest.add(value, {});
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
function parseJson(bytes, location) {
|
|
1481
|
+
try {
|
|
1482
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
throw new TilesetBuildError(
|
|
1485
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1486
|
+
"input",
|
|
1487
|
+
`${location}: invalid JSON (${error instanceof Error ? error.message : "parse failure"}). A file with one feature per line needs --format geojsonseq.`
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
async function forEachChunk(path, visit) {
|
|
1492
|
+
const descriptor = openSync4(path, "r");
|
|
1493
|
+
const buffer = Buffer.allocUnsafe(chunkBytes2);
|
|
1494
|
+
try {
|
|
1495
|
+
for (; ; ) {
|
|
1496
|
+
const bytes = readSync4(descriptor, buffer, 0, chunkBytes2, null);
|
|
1497
|
+
if (bytes === 0) return;
|
|
1498
|
+
visit(buffer.subarray(0, bytes));
|
|
1499
|
+
await yieldToEventLoop();
|
|
1500
|
+
}
|
|
1501
|
+
} finally {
|
|
1502
|
+
closeSync4(descriptor);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function isSpaceOrSeparator(byte) {
|
|
1506
|
+
return byte === 32 || byte === 9 || byte === 13 || byte === 10 || byte === 30;
|
|
1507
|
+
}
|
|
1508
|
+
var TopLevelScanner = class {
|
|
1509
|
+
constructor(onFeature, onCrs) {
|
|
1510
|
+
this.onFeature = onFeature;
|
|
1511
|
+
this.onCrs = onCrs;
|
|
1512
|
+
}
|
|
1513
|
+
onFeature;
|
|
1514
|
+
onCrs;
|
|
1515
|
+
sawFeatures = false;
|
|
1516
|
+
#depth = 0;
|
|
1517
|
+
#inString = false;
|
|
1518
|
+
#escaped = false;
|
|
1519
|
+
#inFeatures = false;
|
|
1520
|
+
#capture = [];
|
|
1521
|
+
#capturedBytes = 0;
|
|
1522
|
+
#featuresEmitted = 0;
|
|
1523
|
+
#captureStart = -1;
|
|
1524
|
+
#key = [];
|
|
1525
|
+
#keyStart = -1;
|
|
1526
|
+
#afterColon = false;
|
|
1527
|
+
#currentKey = "";
|
|
1528
|
+
#crsStart = false;
|
|
1529
|
+
write(chunk) {
|
|
1530
|
+
let index = 0;
|
|
1531
|
+
const length = chunk.length;
|
|
1532
|
+
while (index < length) {
|
|
1533
|
+
const byte = chunk[index];
|
|
1534
|
+
if (this.#inString) {
|
|
1535
|
+
if (this.#escaped) {
|
|
1536
|
+
this.#escaped = false;
|
|
1537
|
+
index += 1;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
if (byte === backslash) {
|
|
1541
|
+
this.#escaped = true;
|
|
1542
|
+
index += 1;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
if (byte === quote) {
|
|
1546
|
+
this.#inString = false;
|
|
1547
|
+
if (this.#keyStart >= 0) {
|
|
1548
|
+
this.#key.push(Buffer.from(chunk.subarray(this.#keyStart, index)));
|
|
1549
|
+
this.#keyStart = -1;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
index += 1;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
switch (byte) {
|
|
1556
|
+
case quote:
|
|
1557
|
+
this.#inString = true;
|
|
1558
|
+
if (this.#depth === 1 && !this.#afterColon) {
|
|
1559
|
+
this.#key = [];
|
|
1560
|
+
this.#keyStart = index + 1;
|
|
1561
|
+
}
|
|
1562
|
+
break;
|
|
1563
|
+
case openBrace:
|
|
1564
|
+
case openBracket:
|
|
1565
|
+
if (this.#depth === 1 && this.#afterColon) {
|
|
1566
|
+
if (this.#currentKey === "features" && byte === openBracket) {
|
|
1567
|
+
this.#inFeatures = true;
|
|
1568
|
+
this.sawFeatures = true;
|
|
1569
|
+
this.#afterColon = false;
|
|
1570
|
+
this.#depth += 1;
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
if (this.#currentKey === "crs" && this.#captureStart < 0) {
|
|
1574
|
+
this.#crsStart = true;
|
|
1575
|
+
this.#captureStart = index;
|
|
1576
|
+
}
|
|
1577
|
+
} else if (this.#inFeatures && this.#depth === 2 && this.#captureStart < 0) {
|
|
1578
|
+
this.#captureStart = index;
|
|
1579
|
+
}
|
|
1580
|
+
this.#depth += 1;
|
|
1581
|
+
break;
|
|
1582
|
+
case closeBrace:
|
|
1583
|
+
case closeBracket:
|
|
1584
|
+
this.#depth -= 1;
|
|
1585
|
+
if (this.#inFeatures) {
|
|
1586
|
+
if (this.#depth === 1) {
|
|
1587
|
+
this.#inFeatures = false;
|
|
1588
|
+
} else if (this.#depth === 2) {
|
|
1589
|
+
this.#emitFeature(chunk, index + 1);
|
|
1590
|
+
}
|
|
1591
|
+
} else if (this.#crsStart && this.#depth === 1) {
|
|
1592
|
+
this.#emitCrs(chunk, index + 1);
|
|
1593
|
+
}
|
|
1594
|
+
break;
|
|
1595
|
+
case comma:
|
|
1596
|
+
if (this.#depth === 1) this.#afterColon = false;
|
|
1597
|
+
break;
|
|
1598
|
+
case colon:
|
|
1599
|
+
if (this.#depth === 1 && !this.#afterColon) {
|
|
1600
|
+
this.#afterColon = true;
|
|
1601
|
+
this.#currentKey = Buffer.concat(this.#key).toString("utf8");
|
|
1602
|
+
}
|
|
1603
|
+
break;
|
|
1604
|
+
default:
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
index += 1;
|
|
1608
|
+
}
|
|
1609
|
+
if (this.#keyStart >= 0) {
|
|
1610
|
+
this.#key.push(Buffer.from(chunk.subarray(this.#keyStart)));
|
|
1611
|
+
this.#keyStart = 0;
|
|
1612
|
+
}
|
|
1613
|
+
if (this.#captureStart >= 0) {
|
|
1614
|
+
this.#capture.push(Buffer.from(chunk.subarray(this.#captureStart)));
|
|
1615
|
+
this.#capturedBytes += chunk.length - this.#captureStart;
|
|
1616
|
+
this.#captureStart = 0;
|
|
1617
|
+
if (this.#capturedBytes > maximumFeatureBytes) {
|
|
1618
|
+
throw featureTooManyBytes(`feature ${this.#featuresEmitted + 1}`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
end() {
|
|
1623
|
+
if (this.#depth !== 0 || this.#inString) {
|
|
1624
|
+
throw new TilesetBuildError(
|
|
1625
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1626
|
+
"input",
|
|
1627
|
+
"The GeoJSON document ends before its top-level object closes; the file may be truncated."
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
#take(chunk, end) {
|
|
1632
|
+
const tail = chunk.subarray(this.#captureStart, end);
|
|
1633
|
+
const bytes = this.#capture.length > 0 ? Buffer.concat([...this.#capture, tail]) : tail;
|
|
1634
|
+
this.#capture = [];
|
|
1635
|
+
this.#capturedBytes = 0;
|
|
1636
|
+
this.#captureStart = -1;
|
|
1637
|
+
return bytes;
|
|
1638
|
+
}
|
|
1639
|
+
#emitFeature(chunk, end) {
|
|
1640
|
+
this.#featuresEmitted += 1;
|
|
1641
|
+
this.onFeature(this.#take(chunk, end));
|
|
1642
|
+
}
|
|
1643
|
+
#emitCrs(chunk, end) {
|
|
1644
|
+
this.#crsStart = false;
|
|
1645
|
+
this.onCrs(parseJson(this.#take(chunk, end), "crs member"));
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
// src/tileset-build/read-geopackage.ts
|
|
1650
|
+
import { statSync } from "fs";
|
|
1651
|
+
import { pathToFileURL } from "url";
|
|
1652
|
+
var requiredTables = ["gpkg_contents", "gpkg_geometry_columns", "gpkg_spatial_ref_sys"];
|
|
1653
|
+
var maximumLayers = 65536;
|
|
1654
|
+
var maximumGeometryDepth = 16;
|
|
1655
|
+
var yieldEveryRows = 8192;
|
|
1656
|
+
var yieldEveryBytes = 64 * 1024 * 1024;
|
|
1657
|
+
var GeoPackageLayer = class _GeoPackageLayer {
|
|
1658
|
+
constructor(database, layer, crsStatement) {
|
|
1659
|
+
this.database = database;
|
|
1660
|
+
this.layer = layer;
|
|
1661
|
+
this.crsStatement = crsStatement;
|
|
1662
|
+
}
|
|
1663
|
+
database;
|
|
1664
|
+
layer;
|
|
1665
|
+
crsStatement;
|
|
1666
|
+
static open(path, requested) {
|
|
1667
|
+
const database = openDatabase(loadSqlite(), path);
|
|
1668
|
+
try {
|
|
1669
|
+
return guarded(() => {
|
|
1670
|
+
database.exec("PRAGMA trusted_schema=OFF; PRAGMA query_only=ON;");
|
|
1671
|
+
const tables = plainTables(database);
|
|
1672
|
+
requireGeoPackage(tables);
|
|
1673
|
+
const layer = chooseLayer(listLayers(database, tables), requested);
|
|
1674
|
+
return new _GeoPackageLayer(
|
|
1675
|
+
database,
|
|
1676
|
+
layer,
|
|
1677
|
+
statementFromGeopackageSrs(layer.name, readSrs(database, layer.srsId))
|
|
1678
|
+
);
|
|
1679
|
+
});
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
database.close();
|
|
1682
|
+
throw error;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
close() {
|
|
1686
|
+
this.database.close();
|
|
1687
|
+
}
|
|
1688
|
+
/** `trustRowSrs` is false when --source-crs overrides what the rows say about themselves. */
|
|
1689
|
+
async read(ingest, trustRowSrs) {
|
|
1690
|
+
const { database, layer } = this;
|
|
1691
|
+
await guardedAsync(async () => {
|
|
1692
|
+
const columns = database.prepare("SELECT name, type, pk, hidden FROM pragma_table_xinfo(?)").all(layer.name);
|
|
1693
|
+
const stored = columns.filter((column) => column.hidden === 0);
|
|
1694
|
+
if (!stored.some((column) => column.name === layer.geometryColumn)) {
|
|
1695
|
+
throw invalid(`layer "${layer.name}" has no column "${layer.geometryColumn}".`);
|
|
1696
|
+
}
|
|
1697
|
+
if (stored.length !== columns.length) {
|
|
1698
|
+
ingest.warn("Generated columns in the GeoPackage were ignored.");
|
|
1699
|
+
}
|
|
1700
|
+
const primary = stored.filter((column) => column.pk > 0);
|
|
1701
|
+
const idColumn = primary.length === 1 ? primary[0].name : null;
|
|
1702
|
+
const attributes = [];
|
|
1703
|
+
for (const column of stored) {
|
|
1704
|
+
if (column.name === layer.geometryColumn || column.name === idColumn) continue;
|
|
1705
|
+
if (/^blob/iu.test(column.type)) {
|
|
1706
|
+
ingest.warn(blobWarning);
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
attributes.push({ boolean: /^bool/iu.test(column.type), name: column.name });
|
|
1710
|
+
}
|
|
1711
|
+
const selected = [
|
|
1712
|
+
layer.geometryColumn,
|
|
1713
|
+
...idColumn === null ? [] : [idColumn],
|
|
1714
|
+
...attributes.map((column) => column.name)
|
|
1715
|
+
];
|
|
1716
|
+
const statement = database.prepare(
|
|
1717
|
+
`SELECT ${selected.map(quoteIdentifier).join(", ")} FROM ${quoteIdentifier(layer.name)} NOT INDEXED`
|
|
1718
|
+
);
|
|
1719
|
+
statement.setReadBigInts(true);
|
|
1720
|
+
await readRows(statement, {
|
|
1721
|
+
attributes,
|
|
1722
|
+
expectedSrs: trustRowSrs ? layer.srsId : null,
|
|
1723
|
+
geometryColumn: layer.geometryColumn,
|
|
1724
|
+
idColumn,
|
|
1725
|
+
ingest,
|
|
1726
|
+
layerName: layer.name
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
var blobWarning = "Binary (BLOB) attributes in the GeoPackage have no vector tile equivalent and were skipped.";
|
|
1732
|
+
async function readRows(statement, plan) {
|
|
1733
|
+
const { attributes, expectedSrs, geometryColumn, idColumn, ingest } = plan;
|
|
1734
|
+
let index = 0;
|
|
1735
|
+
let pendingBytes = 0;
|
|
1736
|
+
for (const row of statement.iterate()) {
|
|
1737
|
+
index += 1;
|
|
1738
|
+
ingest.location = `row ${index}`;
|
|
1739
|
+
const value = row[geometryColumn];
|
|
1740
|
+
if (value !== null && !(value instanceof Uint8Array)) {
|
|
1741
|
+
throw invalid(`${ingest.location}: the geometry is not a GeoPackage binary value.`);
|
|
1742
|
+
}
|
|
1743
|
+
const blob = value;
|
|
1744
|
+
pendingBytes += blob?.length ?? 0;
|
|
1745
|
+
if (index % yieldEveryRows === 0 || pendingBytes >= yieldEveryBytes) {
|
|
1746
|
+
pendingBytes = 0;
|
|
1747
|
+
await yieldToEventLoop();
|
|
1748
|
+
}
|
|
1749
|
+
const decoded = blob === null ? null : decodeGeometry(blob, ingest.location);
|
|
1750
|
+
if (decoded?.geometry && expectedSrs !== null && decoded.srsId !== expectedSrs) {
|
|
1751
|
+
throw new TilesetBuildError(
|
|
1752
|
+
"TF_TILESET_BUILD_CRS_MIXED",
|
|
1753
|
+
"crs",
|
|
1754
|
+
`${ingest.location}: the geometry uses srs_id ${decoded.srsId}, but layer "${plan.layerName}" declares ${expectedSrs}. Pass --source-crs if every row really shares one CRS.`
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
if (decoded?.extraDimensions) ingest.warn("Z and M values in the GeoPackage were ignored.");
|
|
1758
|
+
const properties = /* @__PURE__ */ Object.create(null);
|
|
1759
|
+
for (const column of attributes) {
|
|
1760
|
+
const value2 = row[column.name];
|
|
1761
|
+
if (value2 instanceof Uint8Array) {
|
|
1762
|
+
ingest.warn(blobWarning);
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
properties[column.name] = attributeValue(value2, column.boolean);
|
|
1766
|
+
}
|
|
1767
|
+
ingest.add(
|
|
1768
|
+
decoded?.geometry ?? null,
|
|
1769
|
+
properties,
|
|
1770
|
+
idColumn === null ? void 0 : idValue(row[idColumn])
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
function attributeValue(value, boolean) {
|
|
1775
|
+
if (typeof value === "bigint") {
|
|
1776
|
+
const number = Number(value);
|
|
1777
|
+
if (!Number.isSafeInteger(number)) return value.toString();
|
|
1778
|
+
return boolean && (number === 0 || number === 1) ? number === 1 : number;
|
|
1779
|
+
}
|
|
1780
|
+
return typeof value === "number" && !Number.isFinite(value) ? null : value;
|
|
1781
|
+
}
|
|
1782
|
+
function idValue(value) {
|
|
1783
|
+
if (typeof value !== "bigint") return value;
|
|
1784
|
+
const number = Number(value);
|
|
1785
|
+
return Number.isSafeInteger(number) ? number : value.toString();
|
|
1786
|
+
}
|
|
1787
|
+
function loadSqlite() {
|
|
1788
|
+
const emitWarning = process.emitWarning;
|
|
1789
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
1790
|
+
if (String(warning).includes("SQLite is an experimental feature")) return;
|
|
1791
|
+
emitWarning.call(process, warning, ...rest);
|
|
1792
|
+
});
|
|
1793
|
+
try {
|
|
1794
|
+
const sqlite = process.getBuiltinModule?.("node:sqlite");
|
|
1795
|
+
if (typeof sqlite?.StatementSync?.prototype?.iterate === "function") return sqlite;
|
|
1796
|
+
} finally {
|
|
1797
|
+
process.emitWarning = emitWarning;
|
|
1798
|
+
}
|
|
1799
|
+
throw new TilesetBuildError(
|
|
1800
|
+
"TF_TILESET_BUILD_RUNTIME_UNSUPPORTED",
|
|
1801
|
+
"input",
|
|
1802
|
+
`Reading a GeoPackage needs Node.js 22.13 or later; this is ${process.version}. Upgrade Node.js, or convert the data to GeoJSON first.`
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
function openDatabase(sqlite, path) {
|
|
1806
|
+
const unfinished = (suffix) => (statSync(`${path}${suffix}`, { throwIfNoEntry: false })?.size ?? 0) > 0;
|
|
1807
|
+
if (!unfinished("-wal") && !unfinished("-journal")) {
|
|
1808
|
+
try {
|
|
1809
|
+
return new sqlite.DatabaseSync(`${pathToFileURL(path).href}?immutable=1`, { readOnly: true });
|
|
1810
|
+
} catch {
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
return guarded(() => new sqlite.DatabaseSync(path, { readOnly: true }));
|
|
1814
|
+
}
|
|
1815
|
+
function plainTables(database) {
|
|
1816
|
+
const rows = database.prepare("SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'table'").all();
|
|
1817
|
+
return new Set(rows.map((row) => row.name.toLowerCase()));
|
|
1818
|
+
}
|
|
1819
|
+
function requireGeoPackage(tables) {
|
|
1820
|
+
const missing = requiredTables.filter((name) => !tables.has(name));
|
|
1821
|
+
if (missing.length > 0) {
|
|
1822
|
+
throw invalid(`the file is not a GeoPackage: it has no ${missing.join(", ")} table.`);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
function listLayers(database, tables) {
|
|
1826
|
+
const read = (query) => {
|
|
1827
|
+
const rows = database.prepare(`${query} LIMIT ${maximumLayers + 1}`).all();
|
|
1828
|
+
if (rows.length > maximumLayers) {
|
|
1829
|
+
throw new TilesetBuildError(
|
|
1830
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
1831
|
+
"input",
|
|
1832
|
+
`The GeoPackage lists more than ${maximumLayers} tables.`
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
return rows;
|
|
1836
|
+
};
|
|
1837
|
+
const columns = new Map(
|
|
1838
|
+
read(
|
|
1839
|
+
"SELECT table_name AS name, column_name AS geometryColumn, srs_id AS srsId FROM gpkg_geometry_columns"
|
|
1840
|
+
).map((row) => [row.name.toLowerCase(), row])
|
|
1841
|
+
);
|
|
1842
|
+
const layers = [];
|
|
1843
|
+
for (const { name } of read(
|
|
1844
|
+
"SELECT table_name AS name FROM gpkg_contents WHERE data_type = 'features'"
|
|
1845
|
+
)) {
|
|
1846
|
+
const key = name.toLowerCase();
|
|
1847
|
+
const column = columns.get(key);
|
|
1848
|
+
if (column && tables.has(key)) layers.push({ ...column, name });
|
|
1849
|
+
}
|
|
1850
|
+
return layers.sort((left, right) => left.name < right.name ? -1 : 1);
|
|
1851
|
+
}
|
|
1852
|
+
function chooseLayer(layers, requested) {
|
|
1853
|
+
const names = layers.map((layer) => layer.name);
|
|
1854
|
+
if (layers.length === 0) {
|
|
1855
|
+
throw new TilesetBuildError(
|
|
1856
|
+
"TF_TILESET_BUILD_INPUT_LAYER_INVALID",
|
|
1857
|
+
"inputLayer",
|
|
1858
|
+
"The GeoPackage has no feature tables. Tile matrices, attribute tables and SQL views are not read."
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (requested !== void 0) {
|
|
1862
|
+
const match = layers.find((layer) => layer.name === requested);
|
|
1863
|
+
if (match) return match;
|
|
1864
|
+
throw new TilesetBuildError(
|
|
1865
|
+
"TF_TILESET_BUILD_INPUT_LAYER_INVALID",
|
|
1866
|
+
"inputLayer",
|
|
1867
|
+
`The GeoPackage has no feature table named "${requested}". It has: ${listChoices(names)}.`
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
if (layers.length > 1) {
|
|
1871
|
+
throw new TilesetBuildError(
|
|
1872
|
+
"TF_TILESET_BUILD_INPUT_LAYER_INVALID",
|
|
1873
|
+
"inputLayer",
|
|
1874
|
+
`The GeoPackage has ${names.length} feature tables. Choose one with --input-layer: ${listChoices(names)}.`,
|
|
1875
|
+
names
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
return layers[0];
|
|
1879
|
+
}
|
|
1880
|
+
function readSrs(database, srsId) {
|
|
1881
|
+
const hasWkt2 = database.prepare(
|
|
1882
|
+
"SELECT 1 FROM pragma_table_xinfo('gpkg_spatial_ref_sys') WHERE name = 'definition_12_063'"
|
|
1883
|
+
).get();
|
|
1884
|
+
const row = database.prepare(
|
|
1885
|
+
`SELECT srs_id AS srsId, organization, organization_coordsys_id AS organizationCode,
|
|
1886
|
+
definition${hasWkt2 ? ", definition_12_063 AS definition12063" : ""}
|
|
1887
|
+
FROM gpkg_spatial_ref_sys WHERE srs_id = ?`
|
|
1888
|
+
).get(srsId);
|
|
1889
|
+
return row ? { ...row, definition12063: row.definition12063 ?? null } : void 0;
|
|
1890
|
+
}
|
|
1891
|
+
function quoteIdentifier(name) {
|
|
1892
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
1893
|
+
}
|
|
1894
|
+
function invalid(detail) {
|
|
1895
|
+
return new TilesetBuildError(
|
|
1896
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
1897
|
+
"input",
|
|
1898
|
+
`The GeoPackage could not be read: ${detail}`
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
function unreadable(error) {
|
|
1902
|
+
if (error instanceof TilesetBuildError) return error;
|
|
1903
|
+
return invalid(`${error instanceof Error ? error.message : "unknown error"}.`);
|
|
1904
|
+
}
|
|
1905
|
+
function guarded(run) {
|
|
1906
|
+
try {
|
|
1907
|
+
return run();
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
throw unreadable(error);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
async function guardedAsync(run) {
|
|
1913
|
+
try {
|
|
1914
|
+
await run();
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
throw unreadable(error);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
function decodeGeometry(blob, location) {
|
|
1920
|
+
const bytes = Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
1921
|
+
if (bytes.length < 8 || bytes[0] !== 71 || bytes[1] !== 80) {
|
|
1922
|
+
throw invalid(`${location}: the geometry has no GeoPackage binary header.`);
|
|
1923
|
+
}
|
|
1924
|
+
const flags = bytes[3];
|
|
1925
|
+
if ((flags & 32) !== 0) {
|
|
1926
|
+
throw new TilesetBuildError(
|
|
1927
|
+
"TF_TILESET_BUILD_FORMAT_UNSUPPORTED",
|
|
1928
|
+
"input",
|
|
1929
|
+
`${location}: the geometry uses the extended GeoPackage binary encoding, which is not supported.`
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
const envelopeKind = flags >> 1 & 7;
|
|
1933
|
+
if (envelopeKind > 4) throw invalid(`${location}: the geometry header is malformed.`);
|
|
1934
|
+
const envelopeBytes = [0, 32, 48, 48, 64][envelopeKind];
|
|
1935
|
+
const srsId = (flags & 1) === 1 ? bytes.readInt32LE(4) : bytes.readInt32BE(4);
|
|
1936
|
+
if ((flags & 16) !== 0) return { extraDimensions: false, geometry: null, srsId };
|
|
1937
|
+
const reader = new WkbReader(bytes, 8 + envelopeBytes, location);
|
|
1938
|
+
const geometry = reader.geometry(0);
|
|
1939
|
+
return { extraDimensions: reader.extraDimensions, geometry, srsId };
|
|
1940
|
+
}
|
|
1941
|
+
var WkbReader = class {
|
|
1942
|
+
constructor(bytes, offset, location) {
|
|
1943
|
+
this.bytes = bytes;
|
|
1944
|
+
this.offset = offset;
|
|
1945
|
+
this.location = location;
|
|
1946
|
+
}
|
|
1947
|
+
bytes;
|
|
1948
|
+
offset;
|
|
1949
|
+
location;
|
|
1950
|
+
extraDimensions = false;
|
|
1951
|
+
#positions = 0;
|
|
1952
|
+
geometry(depth, expected) {
|
|
1953
|
+
if (depth > maximumGeometryDepth) {
|
|
1954
|
+
throw new TilesetBuildError(
|
|
1955
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
1956
|
+
"input",
|
|
1957
|
+
`${this.location}: geometries nest more than ${maximumGeometryDepth} levels deep.`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
this.#need(5);
|
|
1961
|
+
const littleEndian = this.bytes[this.offset] === 1;
|
|
1962
|
+
const code = littleEndian ? this.bytes.readUInt32LE(this.offset + 1) : this.bytes.readUInt32BE(this.offset + 1);
|
|
1963
|
+
this.offset += 5;
|
|
1964
|
+
const base = code % 1e3;
|
|
1965
|
+
const dimensionCode = Math.floor(code / 1e3);
|
|
1966
|
+
if (dimensionCode > 3) throw this.#malformed(`unknown geometry code ${code}`);
|
|
1967
|
+
if (expected !== void 0 && base !== expected) {
|
|
1968
|
+
throw this.#malformed(`a multi-geometry holds geometry code ${code}`);
|
|
1969
|
+
}
|
|
1970
|
+
const size = dimensionCode === 0 ? 2 : dimensionCode === 3 ? 4 : 3;
|
|
1971
|
+
if (size > 2) this.extraDimensions = true;
|
|
1972
|
+
const read = { littleEndian, size };
|
|
1973
|
+
switch (base) {
|
|
1974
|
+
case 1: {
|
|
1975
|
+
const position = this.#position(read);
|
|
1976
|
+
return Number.isNaN(position[0]) ? null : { coordinates: position, type: "Point" };
|
|
1977
|
+
}
|
|
1978
|
+
case 2:
|
|
1979
|
+
return { coordinates: this.#line(read), type: "LineString" };
|
|
1980
|
+
case 3:
|
|
1981
|
+
return { coordinates: this.#rings(read), type: "Polygon" };
|
|
1982
|
+
case 4:
|
|
1983
|
+
return {
|
|
1984
|
+
coordinates: this.#children(read, depth, 1).map((child) => child.coordinates),
|
|
1985
|
+
type: "MultiPoint"
|
|
1986
|
+
};
|
|
1987
|
+
case 5:
|
|
1988
|
+
return {
|
|
1989
|
+
coordinates: this.#children(read, depth, 2).map((child) => child.coordinates),
|
|
1990
|
+
type: "MultiLineString"
|
|
1991
|
+
};
|
|
1992
|
+
case 6:
|
|
1993
|
+
return {
|
|
1994
|
+
coordinates: this.#children(read, depth, 3).map((child) => child.coordinates),
|
|
1995
|
+
type: "MultiPolygon"
|
|
1996
|
+
};
|
|
1997
|
+
case 7:
|
|
1998
|
+
return { geometries: this.#children(read, depth), type: "GeometryCollection" };
|
|
1999
|
+
case 8:
|
|
2000
|
+
case 9:
|
|
2001
|
+
case 10:
|
|
2002
|
+
case 11:
|
|
2003
|
+
case 12:
|
|
2004
|
+
case 13:
|
|
2005
|
+
case 14:
|
|
2006
|
+
case 15:
|
|
2007
|
+
case 16:
|
|
2008
|
+
case 17:
|
|
2009
|
+
throw new TilesetBuildError(
|
|
2010
|
+
"TF_TILESET_BUILD_FORMAT_UNSUPPORTED",
|
|
2011
|
+
"input",
|
|
2012
|
+
`${this.location}: curved and surface geometries are not supported. Convert them to lines and polygons first.`
|
|
2013
|
+
);
|
|
2014
|
+
default:
|
|
2015
|
+
throw this.#malformed(`unknown geometry code ${code}`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
#children(read, depth, expected) {
|
|
2019
|
+
const count = this.#count(read, 5);
|
|
2020
|
+
const children = [];
|
|
2021
|
+
for (let index = 0; index < count; index += 1) {
|
|
2022
|
+
this.#spendPosition();
|
|
2023
|
+
const child = this.geometry(depth + 1, expected);
|
|
2024
|
+
if (child) children.push(child);
|
|
2025
|
+
}
|
|
2026
|
+
return children;
|
|
2027
|
+
}
|
|
2028
|
+
#rings(read) {
|
|
2029
|
+
const count = this.#count(read, 4);
|
|
2030
|
+
const rings = [];
|
|
2031
|
+
for (let index = 0; index < count; index += 1) {
|
|
2032
|
+
this.#spendPosition();
|
|
2033
|
+
rings.push(this.#line(read));
|
|
2034
|
+
}
|
|
2035
|
+
return rings;
|
|
2036
|
+
}
|
|
2037
|
+
#line(read) {
|
|
2038
|
+
const count = this.#count(read, read.size * 8);
|
|
2039
|
+
const positions = [];
|
|
2040
|
+
for (let index = 0; index < count; index += 1) positions.push(this.#position(read));
|
|
2041
|
+
return positions;
|
|
2042
|
+
}
|
|
2043
|
+
#position(read) {
|
|
2044
|
+
this.#spendPosition();
|
|
2045
|
+
this.#need(read.size * 8);
|
|
2046
|
+
const { bytes, offset } = this;
|
|
2047
|
+
this.offset += read.size * 8;
|
|
2048
|
+
return read.littleEndian ? [bytes.readDoubleLE(offset), bytes.readDoubleLE(offset + 8)] : [bytes.readDoubleBE(offset), bytes.readDoubleBE(offset + 8)];
|
|
2049
|
+
}
|
|
2050
|
+
/** Positions and parts count alike: an empty part costs memory too. */
|
|
2051
|
+
#spendPosition() {
|
|
2052
|
+
this.#positions += 1;
|
|
2053
|
+
if (this.#positions > maximumFeaturePositions) throw featureTooLarge(this.location);
|
|
2054
|
+
}
|
|
2055
|
+
#count(read, itemBytes) {
|
|
2056
|
+
this.#need(4);
|
|
2057
|
+
const count = read.littleEndian ? this.bytes.readUInt32LE(this.offset) : this.bytes.readUInt32BE(this.offset);
|
|
2058
|
+
this.offset += 4;
|
|
2059
|
+
this.#need(count * itemBytes);
|
|
2060
|
+
return count;
|
|
2061
|
+
}
|
|
2062
|
+
#need(size) {
|
|
2063
|
+
if (this.offset + size > this.bytes.length) throw this.#malformed("the geometry ends early");
|
|
2064
|
+
}
|
|
2065
|
+
#malformed(reason) {
|
|
2066
|
+
return invalid(`${this.location}: ${reason}.`);
|
|
2067
|
+
}
|
|
2068
|
+
};
|
|
2069
|
+
|
|
2070
|
+
// src/tileset-build/read-xml.ts
|
|
2071
|
+
import { createReadStream } from "fs";
|
|
2072
|
+
var chunkBytes3 = 4 * 1024 * 1024;
|
|
2073
|
+
var maximumDepth = 128;
|
|
2074
|
+
var maximumAttributes = 256;
|
|
2075
|
+
var maximumMarkupCharacters = 1024 * 1024;
|
|
2076
|
+
var longestReference = 64;
|
|
2077
|
+
function fileChunks(path) {
|
|
2078
|
+
return createReadStream(path, { highWaterMark: chunkBytes3 });
|
|
2079
|
+
}
|
|
2080
|
+
async function readXml(source, handler) {
|
|
2081
|
+
let decoder = null;
|
|
2082
|
+
const scanner = new XmlScanner(handler);
|
|
2083
|
+
try {
|
|
2084
|
+
for await (const chunk of source) {
|
|
2085
|
+
decoder ??= decoderFor(chunk);
|
|
2086
|
+
scanner.write(decoder.decode(chunk, { stream: true }));
|
|
2087
|
+
await yieldToEventLoop();
|
|
2088
|
+
}
|
|
2089
|
+
if (decoder) scanner.write(decoder.decode());
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
if (error.code === "ERR_ENCODING_INVALID_ENCODED_DATA") {
|
|
2092
|
+
throw invalid2("the file's bytes do not match its declared text encoding.");
|
|
2093
|
+
}
|
|
2094
|
+
throw error;
|
|
2095
|
+
}
|
|
2096
|
+
scanner.end();
|
|
2097
|
+
}
|
|
2098
|
+
function documentVocabulary(root, label, extensions = /* @__PURE__ */ new Map()) {
|
|
2099
|
+
let prefix = null;
|
|
2100
|
+
const bound = new Map([...extensions.values()].map((value) => [value, value]));
|
|
2101
|
+
return (name, attributes) => {
|
|
2102
|
+
const colon2 = name.indexOf(":");
|
|
2103
|
+
const namePrefix = colon2 < 0 ? "" : name.slice(0, colon2);
|
|
2104
|
+
const local = name.slice(colon2 + 1);
|
|
2105
|
+
if (prefix === null) {
|
|
2106
|
+
if (local !== root) throw invalid2(`the file is not ${label}: its root is <${name}>.`);
|
|
2107
|
+
prefix = namePrefix;
|
|
2108
|
+
for (const [attribute, value] of attributes) {
|
|
2109
|
+
const canonical2 = extensions.get(value);
|
|
2110
|
+
if (canonical2 && attribute.startsWith("xmlns:")) bound.set(attribute.slice(6), canonical2);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
if (namePrefix === prefix) return local;
|
|
2114
|
+
const canonical = bound.get(namePrefix);
|
|
2115
|
+
return canonical ? `${canonical}:${local}` : "";
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
var TextCapture = class {
|
|
2119
|
+
constructor(limit2, location) {
|
|
2120
|
+
this.limit = limit2;
|
|
2121
|
+
this.location = location;
|
|
2122
|
+
}
|
|
2123
|
+
limit;
|
|
2124
|
+
location;
|
|
2125
|
+
#parts = [];
|
|
2126
|
+
#size = 0;
|
|
2127
|
+
write(fragment) {
|
|
2128
|
+
this.#size += fragment.length;
|
|
2129
|
+
if (this.#size > this.limit) {
|
|
2130
|
+
throw new TilesetBuildError(
|
|
2131
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
2132
|
+
"input",
|
|
2133
|
+
`${this.location()}: a text value is longer than ${this.limit} characters.`
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
this.#parts.push(fragment);
|
|
2137
|
+
}
|
|
2138
|
+
value() {
|
|
2139
|
+
return this.#parts.join("").trim();
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
function invalid2(detail) {
|
|
2143
|
+
return new TilesetBuildError(
|
|
2144
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
2145
|
+
"input",
|
|
2146
|
+
`The XML could not be read: ${detail}`
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
function decoderFor(first) {
|
|
2150
|
+
const label = bomEncoding(first) ?? declaredEncoding(first) ?? "utf-8";
|
|
2151
|
+
try {
|
|
2152
|
+
return new TextDecoder(label, { fatal: true });
|
|
2153
|
+
} catch {
|
|
2154
|
+
throw invalid2(`it declares an unknown text encoding, "${label}".`);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
function bomEncoding(bytes) {
|
|
2158
|
+
if (bytes[0] === 255 && bytes[1] === 254) return "utf-16le";
|
|
2159
|
+
if (bytes[0] === 254 && bytes[1] === 255) return "utf-16be";
|
|
2160
|
+
return null;
|
|
2161
|
+
}
|
|
2162
|
+
function declaredEncoding(bytes) {
|
|
2163
|
+
const prolog = bytes.subarray(0, 256).toString("latin1");
|
|
2164
|
+
return /^\s*<\?xml[^>]*?encoding\s*=\s*["']([A-Za-z0-9._-]+)["']/u.exec(prolog)?.[1] ?? null;
|
|
2165
|
+
}
|
|
2166
|
+
function isSpace(code) {
|
|
2167
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
2168
|
+
}
|
|
2169
|
+
function decodeReferences(value) {
|
|
2170
|
+
if (!value.includes("&")) return value;
|
|
2171
|
+
return value.replace(
|
|
2172
|
+
/&(#x[0-9a-fA-F]{1,60}|#[0-9]{1,60}|[A-Za-z][A-Za-z0-9]{0,59});|&/gu,
|
|
2173
|
+
(match, reference) => {
|
|
2174
|
+
switch (reference) {
|
|
2175
|
+
case void 0:
|
|
2176
|
+
throw invalid2('a bare "&" must be written as "&".');
|
|
2177
|
+
case "amp":
|
|
2178
|
+
return "&";
|
|
2179
|
+
case "lt":
|
|
2180
|
+
return "<";
|
|
2181
|
+
case "gt":
|
|
2182
|
+
return ">";
|
|
2183
|
+
case "quot":
|
|
2184
|
+
return '"';
|
|
2185
|
+
case "apos":
|
|
2186
|
+
return "'";
|
|
2187
|
+
default:
|
|
2188
|
+
break;
|
|
2189
|
+
}
|
|
2190
|
+
if (!reference.startsWith("#")) {
|
|
2191
|
+
throw invalid2(`the entity ${match} is not defined; only the five XML entities are read.`);
|
|
2192
|
+
}
|
|
2193
|
+
const hex = reference.startsWith("#x");
|
|
2194
|
+
const code = Number.parseInt(reference.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
2195
|
+
if (code === 0 || code > 1114111 || code >= 55296 && code <= 57343) {
|
|
2196
|
+
throw invalid2(`${match} is not a valid character.`);
|
|
2197
|
+
}
|
|
2198
|
+
return String.fromCodePoint(code);
|
|
2199
|
+
}
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
var XmlScanner = class {
|
|
2203
|
+
constructor(handler) {
|
|
2204
|
+
this.handler = handler;
|
|
2205
|
+
}
|
|
2206
|
+
handler;
|
|
2207
|
+
#buffer = "";
|
|
2208
|
+
#position = 0;
|
|
2209
|
+
#consumed = 0;
|
|
2210
|
+
#mode = "content";
|
|
2211
|
+
#stack = [];
|
|
2212
|
+
#sawRoot = false;
|
|
2213
|
+
write(text) {
|
|
2214
|
+
this.#consumed += this.#position;
|
|
2215
|
+
this.#buffer = this.#buffer.slice(this.#position) + text;
|
|
2216
|
+
this.#position = 0;
|
|
2217
|
+
this.#scan();
|
|
2218
|
+
}
|
|
2219
|
+
end() {
|
|
2220
|
+
if (this.#mode !== "content" || this.#position < this.#buffer.length) {
|
|
2221
|
+
throw this.#malformed("the document ends inside a tag, comment or CDATA section");
|
|
2222
|
+
}
|
|
2223
|
+
if (!this.#sawRoot) throw invalid2("the file has no XML elements.");
|
|
2224
|
+
if (this.#stack.length > 0) {
|
|
2225
|
+
throw this.#malformed(`<${this.#stack.at(-1)}> is never closed; the file may be truncated`);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
#scan() {
|
|
2229
|
+
for (; ; ) {
|
|
2230
|
+
if (this.#mode === "comment") {
|
|
2231
|
+
const end = this.#buffer.indexOf("-->", this.#position);
|
|
2232
|
+
if (end < 0) {
|
|
2233
|
+
this.#position = Math.max(this.#position, this.#buffer.length - 2);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
this.#position = end + 3;
|
|
2237
|
+
this.#mode = "content";
|
|
2238
|
+
} else if (this.#mode === "cdata") {
|
|
2239
|
+
const end = this.#buffer.indexOf("]]>", this.#position);
|
|
2240
|
+
if (end < 0) {
|
|
2241
|
+
const safe = Math.max(this.#position, this.#buffer.length - 2);
|
|
2242
|
+
this.#emitRaw(safe);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
this.#emitRaw(end);
|
|
2246
|
+
this.#position = end + 3;
|
|
2247
|
+
this.#mode = "content";
|
|
2248
|
+
} else {
|
|
2249
|
+
const open = this.#buffer.indexOf("<", this.#position);
|
|
2250
|
+
if (open < 0) {
|
|
2251
|
+
this.#emitText(this.#safeTextEnd());
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
this.#emitText(open);
|
|
2255
|
+
if (!this.#markup()) return;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
#emitRaw(end) {
|
|
2260
|
+
if (end > this.#position) this.handler.text(this.#buffer.slice(this.#position, end));
|
|
2261
|
+
this.#position = end;
|
|
2262
|
+
}
|
|
2263
|
+
#emitText(end) {
|
|
2264
|
+
if (end <= this.#position) return;
|
|
2265
|
+
const text = this.#buffer.slice(this.#position, end);
|
|
2266
|
+
this.#position = end;
|
|
2267
|
+
if (this.#stack.length > 0) {
|
|
2268
|
+
this.handler.text(decodeReferences(text));
|
|
2269
|
+
} else if (text.trim() !== "") {
|
|
2270
|
+
throw this.#malformed("text appears outside the root element");
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
/** Holds back a trailing `&...` so that a reference split across chunks stays whole. */
|
|
2274
|
+
#safeTextEnd() {
|
|
2275
|
+
const length = this.#buffer.length;
|
|
2276
|
+
const ampersand = this.#buffer.lastIndexOf("&");
|
|
2277
|
+
if (ampersand < Math.max(this.#position, length - longestReference)) return length;
|
|
2278
|
+
return this.#buffer.includes(";", ampersand) ? length : ampersand;
|
|
2279
|
+
}
|
|
2280
|
+
/** Consumes one markup token at `#position`, or returns false if it needs more input. */
|
|
2281
|
+
#markup() {
|
|
2282
|
+
const buffer = this.#buffer;
|
|
2283
|
+
const start = this.#position;
|
|
2284
|
+
const next2 = buffer[start + 1];
|
|
2285
|
+
if (next2 === void 0) return false;
|
|
2286
|
+
if (next2 === "?") {
|
|
2287
|
+
const end = buffer.indexOf("?>", start + 2);
|
|
2288
|
+
if (end < 0) return this.#wait();
|
|
2289
|
+
this.#checkTagLength(end - start);
|
|
2290
|
+
this.#position = end + 2;
|
|
2291
|
+
return true;
|
|
2292
|
+
}
|
|
2293
|
+
if (next2 === "!") return this.#declaration();
|
|
2294
|
+
if (next2 === "/") {
|
|
2295
|
+
const end = buffer.indexOf(">", start + 2);
|
|
2296
|
+
if (end < 0) return this.#wait();
|
|
2297
|
+
this.#checkTagLength(end - start);
|
|
2298
|
+
const name = buffer.slice(start + 2, end).trimEnd();
|
|
2299
|
+
const expected = this.#stack.pop();
|
|
2300
|
+
if (name !== expected) {
|
|
2301
|
+
throw this.#malformed(`</${name}> does not close <${expected ?? "anything"}>`);
|
|
2302
|
+
}
|
|
2303
|
+
this.#position = end + 1;
|
|
2304
|
+
this.handler.close(name);
|
|
2305
|
+
return true;
|
|
2306
|
+
}
|
|
2307
|
+
return this.#startTag();
|
|
2308
|
+
}
|
|
2309
|
+
#declaration() {
|
|
2310
|
+
const buffer = this.#buffer;
|
|
2311
|
+
const start = this.#position;
|
|
2312
|
+
if (buffer.startsWith("<!--", start)) {
|
|
2313
|
+
this.#mode = "comment";
|
|
2314
|
+
this.#position = start + 4;
|
|
2315
|
+
return true;
|
|
2316
|
+
}
|
|
2317
|
+
if (buffer.startsWith("<![CDATA[", start)) {
|
|
2318
|
+
if (this.#stack.length === 0) throw this.#malformed("CDATA appears outside the root element");
|
|
2319
|
+
this.#mode = "cdata";
|
|
2320
|
+
this.#position = start + 9;
|
|
2321
|
+
return true;
|
|
2322
|
+
}
|
|
2323
|
+
const head = buffer.slice(start, start + 9);
|
|
2324
|
+
if ("<!--".startsWith(head) || "<![CDATA[".startsWith(head)) return false;
|
|
2325
|
+
throw new TilesetBuildError(
|
|
2326
|
+
"TF_TILESET_BUILD_INPUT_UNSAFE",
|
|
2327
|
+
"input",
|
|
2328
|
+
"The XML has a DOCTYPE or entity declaration. KML and GPX never need one, and it is how entity-expansion attacks work, so the file is refused."
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
#startTag() {
|
|
2332
|
+
const buffer = this.#buffer;
|
|
2333
|
+
const length = buffer.length;
|
|
2334
|
+
const start = this.#position;
|
|
2335
|
+
let index = start + 1;
|
|
2336
|
+
while (index < length && !this.#endsName(buffer.charCodeAt(index))) index += 1;
|
|
2337
|
+
if (index >= length) return this.#wait();
|
|
2338
|
+
const name = buffer.slice(start + 1, index);
|
|
2339
|
+
if (name === "") throw this.#malformed('a "<" does not start a tag');
|
|
2340
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
2341
|
+
let selfClosing = false;
|
|
2342
|
+
for (; ; ) {
|
|
2343
|
+
while (index < length && isSpace(buffer.charCodeAt(index))) index += 1;
|
|
2344
|
+
if (index >= length) return this.#wait();
|
|
2345
|
+
const character = buffer[index];
|
|
2346
|
+
if (character === ">") {
|
|
2347
|
+
index += 1;
|
|
2348
|
+
break;
|
|
2349
|
+
}
|
|
2350
|
+
if (character === "/") {
|
|
2351
|
+
if (index + 1 >= length) return this.#wait();
|
|
2352
|
+
if (buffer[index + 1] !== ">") throw this.#malformed(`<${name}> has a stray "/"`);
|
|
2353
|
+
selfClosing = true;
|
|
2354
|
+
index += 2;
|
|
2355
|
+
break;
|
|
2356
|
+
}
|
|
2357
|
+
const nameStart = index;
|
|
2358
|
+
while (index < length && buffer[index] !== "=" && !this.#endsName(buffer.charCodeAt(index))) {
|
|
2359
|
+
index += 1;
|
|
2360
|
+
}
|
|
2361
|
+
if (index >= length) return this.#wait();
|
|
2362
|
+
const attribute = buffer.slice(nameStart, index);
|
|
2363
|
+
while (index < length && isSpace(buffer.charCodeAt(index))) index += 1;
|
|
2364
|
+
if (index >= length) return this.#wait();
|
|
2365
|
+
if (buffer[index] !== "=" || attribute === "") {
|
|
2366
|
+
throw this.#malformed(`<${name}> has a malformed attribute`);
|
|
2367
|
+
}
|
|
2368
|
+
index += 1;
|
|
2369
|
+
while (index < length && isSpace(buffer.charCodeAt(index))) index += 1;
|
|
2370
|
+
if (index >= length) return this.#wait();
|
|
2371
|
+
const quote2 = buffer[index];
|
|
2372
|
+
if (quote2 !== '"' && quote2 !== "'") {
|
|
2373
|
+
throw this.#malformed(`attribute "${attribute}" of <${name}> is not quoted`);
|
|
2374
|
+
}
|
|
2375
|
+
const valueEnd = buffer.indexOf(quote2, index + 1);
|
|
2376
|
+
if (valueEnd < 0) return this.#wait();
|
|
2377
|
+
const raw = buffer.slice(index + 1, valueEnd);
|
|
2378
|
+
if (raw.includes("<")) throw this.#malformed(`attribute "${attribute}" contains "<"`);
|
|
2379
|
+
attributes.set(attribute, decodeReferences(raw));
|
|
2380
|
+
if (attributes.size > maximumAttributes) {
|
|
2381
|
+
throw limit(`<${name}> has more than ${maximumAttributes} attributes.`);
|
|
2382
|
+
}
|
|
2383
|
+
index = valueEnd + 1;
|
|
2384
|
+
}
|
|
2385
|
+
this.#checkTagLength(index - start);
|
|
2386
|
+
if (this.#stack.length === 0 && this.#sawRoot) {
|
|
2387
|
+
throw this.#malformed("the document has more than one root element");
|
|
2388
|
+
}
|
|
2389
|
+
if (this.#stack.length >= maximumDepth) {
|
|
2390
|
+
throw limit(`elements nest more than ${maximumDepth} levels deep.`);
|
|
2391
|
+
}
|
|
2392
|
+
this.#sawRoot = true;
|
|
2393
|
+
this.#position = index;
|
|
2394
|
+
this.handler.open(name, attributes);
|
|
2395
|
+
if (selfClosing) this.handler.close(name);
|
|
2396
|
+
else this.#stack.push(name);
|
|
2397
|
+
return true;
|
|
2398
|
+
}
|
|
2399
|
+
#endsName(code) {
|
|
2400
|
+
return isSpace(code) || code === 47 || code === 62;
|
|
2401
|
+
}
|
|
2402
|
+
/** An incomplete token waits for the next chunk, unless it has already grown past reason. */
|
|
2403
|
+
#wait() {
|
|
2404
|
+
this.#checkTagLength(this.#buffer.length - this.#position);
|
|
2405
|
+
return false;
|
|
2406
|
+
}
|
|
2407
|
+
/** Checked on complete tags too, so that the answer does not depend on where chunks fall. */
|
|
2408
|
+
#checkTagLength(length) {
|
|
2409
|
+
if (length > maximumMarkupCharacters) {
|
|
2410
|
+
throw limit(`one tag is longer than ${maximumMarkupCharacters} characters.`);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
#malformed(reason) {
|
|
2414
|
+
return invalid2(`near character ${this.#consumed + this.#position}: ${reason}.`);
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
function limit(detail) {
|
|
2418
|
+
return new TilesetBuildError(
|
|
2419
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
2420
|
+
"input",
|
|
2421
|
+
`The XML exceeds a safety limit: ${detail}`
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// src/tileset-build/read-gpx.ts
|
|
2426
|
+
var maximumFieldCharacters = 1024 * 1024;
|
|
2427
|
+
var textFields = /* @__PURE__ */ new Set(["cmt", "desc", "name", "sym", "src", "time", "type"]);
|
|
2428
|
+
var numericFields = /* @__PURE__ */ new Set(["ele", "number"]);
|
|
2429
|
+
var locations = { rte: "route", trk: "track", wpt: "waypoint" };
|
|
2430
|
+
async function readGpx(source, ingest) {
|
|
2431
|
+
const reader = new GpxReader(ingest);
|
|
2432
|
+
await readXml(source, reader);
|
|
2433
|
+
reader.finish();
|
|
2434
|
+
}
|
|
2435
|
+
var GpxReader = class {
|
|
2436
|
+
constructor(ingest) {
|
|
2437
|
+
this.ingest = ingest;
|
|
2438
|
+
}
|
|
2439
|
+
ingest;
|
|
2440
|
+
#resolve = documentVocabulary("gpx", "a GPX document");
|
|
2441
|
+
#path = [];
|
|
2442
|
+
#counts = { rte: 0, trk: 0, wpt: 0 };
|
|
2443
|
+
#feature = null;
|
|
2444
|
+
#capture = null;
|
|
2445
|
+
#pointDetailIgnored = false;
|
|
2446
|
+
open = (raw, attributes) => {
|
|
2447
|
+
const name = this.#resolve(raw, attributes);
|
|
2448
|
+
const parent = this.#path.at(-1) ?? "";
|
|
2449
|
+
this.#path.push(name);
|
|
2450
|
+
if (this.#capture) return;
|
|
2451
|
+
if (parent === "gpx" && (name === "wpt" || name === "rte" || name === "trk")) {
|
|
2452
|
+
this.#begin(name, attributes);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
const feature = this.#feature;
|
|
2456
|
+
if (!feature) return;
|
|
2457
|
+
if (parent === feature.kind && (textFields.has(name) || numericFields.has(name))) {
|
|
2458
|
+
this.#capture = {
|
|
2459
|
+
depth: this.#path.length - 1,
|
|
2460
|
+
field: name,
|
|
2461
|
+
text: new TextCapture(maximumFieldCharacters, () => this.ingest.location)
|
|
2462
|
+
};
|
|
2463
|
+
} else if (name === "rtept" && parent === "rte") {
|
|
2464
|
+
if (feature.lines.length === 0) feature.lines.push([]);
|
|
2465
|
+
this.#addPoint(feature, feature.lines[0], attributes);
|
|
2466
|
+
} else if (name === "trkseg" && parent === "trk") {
|
|
2467
|
+
this.#spendPosition(feature);
|
|
2468
|
+
feature.lines.push([]);
|
|
2469
|
+
} else if (name === "trkpt" && parent === "trkseg") {
|
|
2470
|
+
this.#addPoint(feature, feature.lines[feature.lines.length - 1], attributes);
|
|
2471
|
+
} else if ((name === "ele" || name === "time") && (parent === "rtept" || parent === "trkpt")) {
|
|
2472
|
+
this.#pointDetailIgnored = true;
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
close = () => {
|
|
2476
|
+
const name = this.#path.pop() ?? "";
|
|
2477
|
+
const capture = this.#capture;
|
|
2478
|
+
if (capture && this.#path.length === capture.depth) {
|
|
2479
|
+
this.#capture = null;
|
|
2480
|
+
this.#store(capture.field, capture.text.value());
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
if (capture) return;
|
|
2484
|
+
const feature = this.#feature;
|
|
2485
|
+
if (feature && this.#path.length === 1 && name === feature.kind) this.#finishFeature(feature);
|
|
2486
|
+
};
|
|
2487
|
+
text = (fragment) => {
|
|
2488
|
+
this.#capture?.text.write(fragment);
|
|
2489
|
+
};
|
|
2490
|
+
finish() {
|
|
2491
|
+
if (this.#pointDetailIgnored) {
|
|
2492
|
+
this.ingest.warn("Elevation and time on GPX route and track points were ignored.");
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
#begin(kind, attributes) {
|
|
2496
|
+
this.#counts[kind] += 1;
|
|
2497
|
+
this.ingest.location = `${locations[kind]} ${this.#counts[kind]}`;
|
|
2498
|
+
this.#feature = {
|
|
2499
|
+
kind,
|
|
2500
|
+
lines: [],
|
|
2501
|
+
point: kind === "wpt" ? this.#position(attributes) : null,
|
|
2502
|
+
positions: 0,
|
|
2503
|
+
properties: /* @__PURE__ */ Object.create(null)
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
#store(field2, value) {
|
|
2507
|
+
if (value === "" || !this.#feature) return;
|
|
2508
|
+
if (numericFields.has(field2)) {
|
|
2509
|
+
const number = Number(value);
|
|
2510
|
+
if (Number.isFinite(number)) this.#feature.properties[field2] = number;
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
this.#feature.properties[field2] = value;
|
|
2514
|
+
}
|
|
2515
|
+
#addPoint(feature, line, attributes) {
|
|
2516
|
+
if (!line) throw this.#invalid("a track point sits outside a track segment");
|
|
2517
|
+
this.#spendPosition(feature);
|
|
2518
|
+
line.push(this.#position(attributes));
|
|
2519
|
+
}
|
|
2520
|
+
/** Positions and segments count alike: an empty segment costs memory too. */
|
|
2521
|
+
#spendPosition(feature) {
|
|
2522
|
+
feature.positions += 1;
|
|
2523
|
+
if (feature.positions > maximumFeaturePositions) throw featureTooLarge(this.ingest.location);
|
|
2524
|
+
}
|
|
2525
|
+
#position(attributes) {
|
|
2526
|
+
const lon = attributes.get("lon")?.trim();
|
|
2527
|
+
const lat = attributes.get("lat")?.trim();
|
|
2528
|
+
if (!lon || !lat) throw this.#invalid("a point needs both lat and lon attributes");
|
|
2529
|
+
return [Number(lon), Number(lat)];
|
|
2530
|
+
}
|
|
2531
|
+
#finishFeature(feature) {
|
|
2532
|
+
this.#feature = null;
|
|
2533
|
+
if (feature.kind === "wpt") {
|
|
2534
|
+
this.ingest.add({ coordinates: feature.point, type: "Point" }, feature.properties);
|
|
2535
|
+
} else if (feature.kind === "rte") {
|
|
2536
|
+
this.ingest.add(
|
|
2537
|
+
{ coordinates: feature.lines[0] ?? [], type: "LineString" },
|
|
2538
|
+
feature.properties
|
|
2539
|
+
);
|
|
2540
|
+
} else {
|
|
2541
|
+
this.ingest.add({ coordinates: feature.lines, type: "MultiLineString" }, feature.properties);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
#invalid(reason) {
|
|
2545
|
+
return new TilesetBuildError(
|
|
2546
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
2547
|
+
"input",
|
|
2548
|
+
`${this.ingest.location}: ${reason}.`
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
};
|
|
2552
|
+
|
|
2553
|
+
// src/tileset-build/read-kml.ts
|
|
2554
|
+
var maximumFieldCharacters2 = 8 * 1024 * 1024;
|
|
2555
|
+
var maximumFeatureTextCharacters = 64 * 1024 * 1024;
|
|
2556
|
+
var maximumFolderNameCharacters = 1024 * 1024;
|
|
2557
|
+
var maximumTupleCharacters = 256;
|
|
2558
|
+
var maximumSchemaFields = 65536;
|
|
2559
|
+
var maximumSchemaCharacters = 16 * 1024 * 1024;
|
|
2560
|
+
var coordinateParents = /* @__PURE__ */ new Set(["LinearRing", "LineString", "Point"]);
|
|
2561
|
+
var placemarkFields = /* @__PURE__ */ new Set(["address", "description", "name", "phoneNumber"]);
|
|
2562
|
+
var builtInFields = /* @__PURE__ */ new Set([...placemarkFields, "begin", "end", "folder", "timestamp"]);
|
|
2563
|
+
var ignoredFeatures = /* @__PURE__ */ new Set(["GroundOverlay", "NetworkLink", "PhotoOverlay", "ScreenOverlay"]);
|
|
2564
|
+
var trackElements = /* @__PURE__ */ new Set(["Track", "gx:Track"]);
|
|
2565
|
+
var coordElements = /* @__PURE__ */ new Set(["coord", "gx:coord"]);
|
|
2566
|
+
var numericSchemaTypes = /* @__PURE__ */ new Set(["double", "float", "int", "short", "uint", "ushort"]);
|
|
2567
|
+
async function readKml(source, ingest) {
|
|
2568
|
+
const reader = new KmlReader(ingest);
|
|
2569
|
+
await readXml(source, reader);
|
|
2570
|
+
reader.finish();
|
|
2571
|
+
}
|
|
2572
|
+
var KmlReader = class {
|
|
2573
|
+
constructor(ingest) {
|
|
2574
|
+
this.ingest = ingest;
|
|
2575
|
+
}
|
|
2576
|
+
ingest;
|
|
2577
|
+
#resolve = documentVocabulary(
|
|
2578
|
+
"kml",
|
|
2579
|
+
"a KML document",
|
|
2580
|
+
/* @__PURE__ */ new Map([["http://www.google.com/kml/ext/2.2", "gx"]])
|
|
2581
|
+
);
|
|
2582
|
+
#path = [];
|
|
2583
|
+
#folders = [];
|
|
2584
|
+
#schemaFields = /* @__PURE__ */ new Map();
|
|
2585
|
+
#schemaId = "";
|
|
2586
|
+
#schemaUrl = "";
|
|
2587
|
+
#schemaCharacters = 0;
|
|
2588
|
+
#dataName = null;
|
|
2589
|
+
#placemark = null;
|
|
2590
|
+
#placemarks = 0;
|
|
2591
|
+
#capture = null;
|
|
2592
|
+
#tuple = null;
|
|
2593
|
+
#positions = [];
|
|
2594
|
+
#altitudeIgnored = false;
|
|
2595
|
+
#ignoredFeatures = 0;
|
|
2596
|
+
open = (raw, attributes) => {
|
|
2597
|
+
const name = this.#resolve(raw, attributes);
|
|
2598
|
+
const parent = this.#path.at(-1) ?? "";
|
|
2599
|
+
this.#path.push(name);
|
|
2600
|
+
if (this.#capture) return;
|
|
2601
|
+
if (this.#placemark) {
|
|
2602
|
+
this.#openInPlacemark(name, parent, attributes);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
if (name === "Folder") {
|
|
2606
|
+
this.#folders.push(null);
|
|
2607
|
+
} else if (name === "name" && parent === "Folder") {
|
|
2608
|
+
this.#captureText((value) => {
|
|
2609
|
+
this.#folders[this.#folders.length - 1] = value;
|
|
2610
|
+
}, maximumFolderNameCharacters);
|
|
2611
|
+
} else if (name === "Placemark") {
|
|
2612
|
+
this.#placemarks += 1;
|
|
2613
|
+
this.ingest.location = `Placemark ${this.#placemarks}`;
|
|
2614
|
+
const properties = /* @__PURE__ */ Object.create(null);
|
|
2615
|
+
const folder = this.#folders.at(-1);
|
|
2616
|
+
if (folder) properties.folder = folder;
|
|
2617
|
+
this.#placemark = {
|
|
2618
|
+
geometries: [],
|
|
2619
|
+
polygon: null,
|
|
2620
|
+
positions: 0,
|
|
2621
|
+
properties,
|
|
2622
|
+
textCharacters: 0,
|
|
2623
|
+
track: null
|
|
2624
|
+
};
|
|
2625
|
+
} else if (name === "Schema") {
|
|
2626
|
+
this.#schemaId = attributes.get("id") ?? "";
|
|
2627
|
+
} else if (name === "SimpleField" && parent === "Schema") {
|
|
2628
|
+
this.#addSchemaField(attributes);
|
|
2629
|
+
} else if (ignoredFeatures.has(name)) {
|
|
2630
|
+
this.#ignoredFeatures += 1;
|
|
2631
|
+
}
|
|
2632
|
+
};
|
|
2633
|
+
close = () => {
|
|
2634
|
+
const name = this.#path.pop() ?? "";
|
|
2635
|
+
const parent = this.#path.at(-1) ?? "";
|
|
2636
|
+
const capture = this.#capture;
|
|
2637
|
+
if (capture && this.#path.length === capture.depth) {
|
|
2638
|
+
this.#capture = null;
|
|
2639
|
+
capture.finish(capture.text.value());
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
if (capture) return;
|
|
2643
|
+
if (name === "Folder" && !this.#placemark) {
|
|
2644
|
+
this.#folders.pop();
|
|
2645
|
+
} else if (this.#placemark) {
|
|
2646
|
+
this.#closeInPlacemark(name, parent);
|
|
2647
|
+
}
|
|
2648
|
+
};
|
|
2649
|
+
text = (fragment) => {
|
|
2650
|
+
if (this.#tuple !== null) this.#writeCoordinates(fragment);
|
|
2651
|
+
else this.#capture?.text.write(fragment);
|
|
2652
|
+
};
|
|
2653
|
+
finish() {
|
|
2654
|
+
if (this.#altitudeIgnored) this.ingest.warn("Altitude values in the KML were ignored.");
|
|
2655
|
+
if (this.#ignoredFeatures > 0) {
|
|
2656
|
+
this.ingest.warn(
|
|
2657
|
+
`${this.#ignoredFeatures} overlays and network links in the KML were ignored; only Placemarks are read, and remote content is never fetched.`
|
|
2658
|
+
);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
#openInPlacemark(name, parent, attributes) {
|
|
2662
|
+
const placemark = this.#placemark;
|
|
2663
|
+
if (parent === "Placemark" && placemarkFields.has(name)) {
|
|
2664
|
+
this.#captureField(name);
|
|
2665
|
+
} else if (name === "when" && parent === "TimeStamp" || parent === "TimeSpan") {
|
|
2666
|
+
if (name === "when") this.#captureField("timestamp");
|
|
2667
|
+
else if (name === "begin" || name === "end") this.#captureField(name);
|
|
2668
|
+
} else if (name === "Data" && parent === "ExtendedData") {
|
|
2669
|
+
this.#dataName = attributes.get("name") ?? null;
|
|
2670
|
+
} else if (name === "value" && parent === "Data" && this.#dataName) {
|
|
2671
|
+
this.#captureField(this.#dataName);
|
|
2672
|
+
} else if (name === "SchemaData") {
|
|
2673
|
+
this.#schemaUrl = (attributes.get("schemaUrl") ?? "").replace(/^.*#/u, "");
|
|
2674
|
+
} else if (name === "SimpleData" && parent === "SchemaData") {
|
|
2675
|
+
const field2 = attributes.get("name");
|
|
2676
|
+
if (field2) this.#captureField(field2, this.#schemaFields.get(this.#schemaKey(field2)));
|
|
2677
|
+
} else if (name === "coordinates" && coordinateParents.has(parent)) {
|
|
2678
|
+
this.#positions = [];
|
|
2679
|
+
this.#tuple = "";
|
|
2680
|
+
} else if (name === "Polygon") {
|
|
2681
|
+
placemark.polygon = { inner: [], outer: null };
|
|
2682
|
+
} else if (trackElements.has(name)) {
|
|
2683
|
+
placemark.track = [];
|
|
2684
|
+
} else if (coordElements.has(name) && trackElements.has(parent)) {
|
|
2685
|
+
this.#captureText((value) => {
|
|
2686
|
+
this.#addPosition(value.split(/\s+/u));
|
|
2687
|
+
}, maximumTupleCharacters);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
#closeInPlacemark(name, parent) {
|
|
2691
|
+
const placemark = this.#placemark;
|
|
2692
|
+
switch (name) {
|
|
2693
|
+
case "coordinates":
|
|
2694
|
+
if (this.#tuple) this.#addPosition(this.#tuple.split(","));
|
|
2695
|
+
this.#tuple = null;
|
|
2696
|
+
break;
|
|
2697
|
+
case "Point":
|
|
2698
|
+
if (this.#positions.length > 0) {
|
|
2699
|
+
this.#addGeometry({ coordinates: this.#positions[0], type: "Point" });
|
|
2700
|
+
}
|
|
2701
|
+
this.#positions = [];
|
|
2702
|
+
break;
|
|
2703
|
+
case "LineString":
|
|
2704
|
+
this.#addGeometry({ coordinates: this.#takePositions(), type: "LineString" });
|
|
2705
|
+
break;
|
|
2706
|
+
case "LinearRing": {
|
|
2707
|
+
const ring = this.#takePositions();
|
|
2708
|
+
if (parent === "outerBoundaryIs" && placemark.polygon) placemark.polygon.outer = ring;
|
|
2709
|
+
else if (parent === "innerBoundaryIs" && placemark.polygon) {
|
|
2710
|
+
this.#spendPosition();
|
|
2711
|
+
placemark.polygon.inner.push(ring);
|
|
2712
|
+
} else this.#addGeometry({ coordinates: ring, type: "LineString" });
|
|
2713
|
+
break;
|
|
2714
|
+
}
|
|
2715
|
+
case "Polygon":
|
|
2716
|
+
if (placemark.polygon?.outer) {
|
|
2717
|
+
this.#addGeometry({
|
|
2718
|
+
coordinates: [placemark.polygon.outer, ...placemark.polygon.inner],
|
|
2719
|
+
type: "Polygon"
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
placemark.polygon = null;
|
|
2723
|
+
break;
|
|
2724
|
+
case "Track":
|
|
2725
|
+
case "gx:Track":
|
|
2726
|
+
if (placemark.track) this.#addGeometry({ coordinates: placemark.track, type: "LineString" });
|
|
2727
|
+
placemark.track = null;
|
|
2728
|
+
break;
|
|
2729
|
+
case "Placemark":
|
|
2730
|
+
this.ingest.add(combine(placemark.geometries), placemark.properties);
|
|
2731
|
+
this.#placemark = null;
|
|
2732
|
+
break;
|
|
2733
|
+
default:
|
|
2734
|
+
break;
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
#addGeometry(geometry) {
|
|
2738
|
+
this.#spendPosition();
|
|
2739
|
+
this.#placemark.geometries.push(geometry);
|
|
2740
|
+
}
|
|
2741
|
+
#spendPosition() {
|
|
2742
|
+
const placemark = this.#placemark;
|
|
2743
|
+
placemark.positions += 1;
|
|
2744
|
+
if (placemark.positions > maximumFeaturePositions) throw featureTooLarge(this.ingest.location);
|
|
2745
|
+
}
|
|
2746
|
+
#takePositions() {
|
|
2747
|
+
const positions = this.#positions;
|
|
2748
|
+
this.#positions = [];
|
|
2749
|
+
return positions;
|
|
2750
|
+
}
|
|
2751
|
+
#captureField(field2, schemaType) {
|
|
2752
|
+
this.#captureText((value) => {
|
|
2753
|
+
if (value === "") return;
|
|
2754
|
+
const placemark = this.#placemark;
|
|
2755
|
+
placemark.textCharacters += field2.length + value.length;
|
|
2756
|
+
if (placemark.textCharacters > maximumFeatureTextCharacters) {
|
|
2757
|
+
throw new TilesetBuildError(
|
|
2758
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
2759
|
+
"input",
|
|
2760
|
+
`${this.ingest.location}: the attributes hold more than ${maximumFeatureTextCharacters} characters.`
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
if (builtInFields.has(field2) && field2 in placemark.properties) {
|
|
2764
|
+
this.ingest.warn(
|
|
2765
|
+
`A Placemark gives more than one value for "${field2}", counting ExtendedData; the last one was kept.`
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
placemark.properties[field2] = typed(value, schemaType);
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
#captureText(finish, limit2 = maximumFieldCharacters2) {
|
|
2772
|
+
this.#capture = {
|
|
2773
|
+
depth: this.#path.length - 1,
|
|
2774
|
+
finish,
|
|
2775
|
+
text: new TextCapture(limit2, () => this.ingest.location)
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
#addSchemaField(attributes) {
|
|
2779
|
+
const field2 = attributes.get("name");
|
|
2780
|
+
const type = attributes.get("type");
|
|
2781
|
+
if (!field2 || !type) return;
|
|
2782
|
+
this.#schemaCharacters += this.#schemaId.length + field2.length + type.length;
|
|
2783
|
+
if (this.#schemaFields.size >= maximumSchemaFields || this.#schemaCharacters > maximumSchemaCharacters) {
|
|
2784
|
+
throw new TilesetBuildError(
|
|
2785
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
2786
|
+
"input",
|
|
2787
|
+
`The KML schemas are too large: the limits are ${maximumSchemaFields} fields and ${maximumSchemaCharacters} characters.`
|
|
2788
|
+
);
|
|
2789
|
+
}
|
|
2790
|
+
this.#schemaFields.set(`${this.#schemaId}\0${field2}`, type);
|
|
2791
|
+
}
|
|
2792
|
+
#schemaKey(field2) {
|
|
2793
|
+
return `${this.#schemaUrl}\0${field2}`;
|
|
2794
|
+
}
|
|
2795
|
+
/** Tuples are separated by whitespace; a space after a comma is tolerated, as most tools do. */
|
|
2796
|
+
#writeCoordinates(fragment) {
|
|
2797
|
+
let tuple = this.#tuple ?? "";
|
|
2798
|
+
let start = 0;
|
|
2799
|
+
for (let index = 0; index < fragment.length; index += 1) {
|
|
2800
|
+
const code = fragment.charCodeAt(index);
|
|
2801
|
+
if (code !== 32 && code !== 10 && code !== 9 && code !== 13) continue;
|
|
2802
|
+
tuple += fragment.slice(start, index);
|
|
2803
|
+
start = index + 1;
|
|
2804
|
+
this.#checkTuple(tuple);
|
|
2805
|
+
if (tuple !== "" && !tuple.endsWith(",")) {
|
|
2806
|
+
this.#addPosition(tuple.split(","));
|
|
2807
|
+
tuple = "";
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
tuple += fragment.slice(start);
|
|
2811
|
+
this.#checkTuple(tuple);
|
|
2812
|
+
this.#tuple = tuple;
|
|
2813
|
+
}
|
|
2814
|
+
#checkTuple(tuple) {
|
|
2815
|
+
if (tuple.length > maximumTupleCharacters) {
|
|
2816
|
+
throw this.#invalid("coordinates are not longitude,latitude tuples");
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
#addPosition(parts) {
|
|
2820
|
+
const placemark = this.#placemark;
|
|
2821
|
+
if (parts.length < 2 || parts.length > 3 || parts[0] === "" || parts[1] === "") {
|
|
2822
|
+
throw this.#invalid("a position must be longitude,latitude[,altitude]");
|
|
2823
|
+
}
|
|
2824
|
+
this.#spendPosition();
|
|
2825
|
+
if (parts.length === 3 && Number(parts[2]) !== 0) this.#altitudeIgnored = true;
|
|
2826
|
+
const position = [Number(parts[0]), Number(parts[1])];
|
|
2827
|
+
if (placemark.track) placemark.track.push(position);
|
|
2828
|
+
else this.#positions.push(position);
|
|
2829
|
+
}
|
|
2830
|
+
#invalid(reason) {
|
|
2831
|
+
return new TilesetBuildError(
|
|
2832
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
2833
|
+
"input",
|
|
2834
|
+
`${this.ingest.location}: ${reason}.`
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
};
|
|
2838
|
+
function typed(value, schemaType) {
|
|
2839
|
+
if (schemaType && numericSchemaTypes.has(schemaType)) {
|
|
2840
|
+
const number = Number(value);
|
|
2841
|
+
return Number.isFinite(number) ? number : value;
|
|
2842
|
+
}
|
|
2843
|
+
if (schemaType === "bool") {
|
|
2844
|
+
if (value === "true" || value === "1") return true;
|
|
2845
|
+
if (value === "false" || value === "0") return false;
|
|
2846
|
+
}
|
|
2847
|
+
return value;
|
|
2848
|
+
}
|
|
2849
|
+
function combine(geometries) {
|
|
2850
|
+
if (geometries.length === 0) return null;
|
|
2851
|
+
if (geometries.length === 1) return geometries[0];
|
|
2852
|
+
const [{ type }] = geometries;
|
|
2853
|
+
if (!geometries.every((geometry) => geometry.type === type)) {
|
|
2854
|
+
return { geometries: [...geometries], type: "GeometryCollection" };
|
|
2855
|
+
}
|
|
2856
|
+
return {
|
|
2857
|
+
coordinates: geometries.map((geometry) => geometry.coordinates),
|
|
2858
|
+
type: `Multi${String(type)}`
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// src/tileset-build/read-shapefile.ts
|
|
2863
|
+
import { closeSync as closeSync5, existsSync, openSync as openSync5, readdirSync, readFileSync as readFileSync2, readSync as readSync5 } from "fs";
|
|
2864
|
+
import { basename, dirname, extname, join as join2 } from "path";
|
|
2865
|
+
var shapeHeaderBytes = 100;
|
|
2866
|
+
var dbfFieldBytes = 32;
|
|
2867
|
+
function shapefileSidecar(path, extension) {
|
|
2868
|
+
const stem = basename(path, extname(path));
|
|
2869
|
+
const directory = dirname(path);
|
|
2870
|
+
for (const candidate of [extension, extension.toUpperCase()]) {
|
|
2871
|
+
const direct = join2(directory, `${stem}${candidate}`);
|
|
2872
|
+
if (existsSync(direct)) return direct;
|
|
2873
|
+
}
|
|
2874
|
+
const lower = `${stem}${extension}`.toLowerCase();
|
|
2875
|
+
const match = readdirSync(directory).find((entry) => entry.toLowerCase() === lower);
|
|
2876
|
+
return match ? join2(directory, match) : null;
|
|
2877
|
+
}
|
|
2878
|
+
function shapefileCrsStatement(path) {
|
|
2879
|
+
const prj = shapefileSidecar(path, ".prj");
|
|
2880
|
+
const label = `${basename(path)}${prj ? " (.prj)" : " (no .prj file)"}`;
|
|
2881
|
+
return {
|
|
2882
|
+
authority: "declared",
|
|
2883
|
+
definition: prj ? readFileSync2(prj, "utf8").trim() || null : null,
|
|
2884
|
+
label
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
async function readShapefile(path, ingest) {
|
|
2888
|
+
const shp = openSync5(path, "r");
|
|
2889
|
+
let table = null;
|
|
2890
|
+
try {
|
|
2891
|
+
const dbfPath = shapefileSidecar(path, ".dbf");
|
|
2892
|
+
if (dbfPath) table = openDbf(dbfPath, shapefileSidecar(path, ".cpg"));
|
|
2893
|
+
else ingest.warn("No .dbf file was found, so features have no attributes.");
|
|
2894
|
+
const header = Buffer.allocUnsafe(shapeHeaderBytes);
|
|
2895
|
+
if (readSync5(shp, header, 0, shapeHeaderBytes, 0) !== shapeHeaderBytes || header.readInt32BE(0) !== 9994) {
|
|
2896
|
+
throw invalid3("file is not a Shapefile (.shp header is wrong).");
|
|
2897
|
+
}
|
|
2898
|
+
const fileBytes = header.readInt32BE(24) * 2;
|
|
2899
|
+
const shapeType = header.readInt32LE(32);
|
|
2900
|
+
if (![0, 1, 3, 5, 8, 11, 13, 15, 18, 21, 23, 25, 28].includes(shapeType)) {
|
|
2901
|
+
throw new TilesetBuildError(
|
|
2902
|
+
"TF_TILESET_BUILD_FORMAT_UNSUPPORTED",
|
|
2903
|
+
"input",
|
|
2904
|
+
`Shapefile geometry type ${shapeType} is not supported; MultiPatch has no vector tile equivalent.`
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2907
|
+
if (shapeType >= 11) ingest.warn("Z and M values in the Shapefile were ignored.");
|
|
2908
|
+
const stream = new SequentialReader(shp, shapeHeaderBytes);
|
|
2909
|
+
let index = 0;
|
|
2910
|
+
while (stream.position < fileBytes) {
|
|
2911
|
+
index += 1;
|
|
2912
|
+
if (index % 8192 === 0) await yieldToEventLoop();
|
|
2913
|
+
ingest.location = `record ${index}`;
|
|
2914
|
+
const recordHeader = stream.read(8);
|
|
2915
|
+
if (!recordHeader) throw invalid3(`record ${index}: the .shp file ends early.`);
|
|
2916
|
+
const bytes = recordHeader.readInt32BE(4) * 2;
|
|
2917
|
+
if (bytes > maximumFeatureBytes) throw featureTooManyBytes(ingest.location);
|
|
2918
|
+
const content = stream.read(bytes);
|
|
2919
|
+
if (!content) throw invalid3(`record ${index}: the .shp file ends early.`);
|
|
2920
|
+
const row = table?.next();
|
|
2921
|
+
if (row === null) continue;
|
|
2922
|
+
ingest.add(decodeShape(content, index), row ?? {});
|
|
2923
|
+
}
|
|
2924
|
+
if (table && table.remainingRows > 0) {
|
|
2925
|
+
ingest.warn(
|
|
2926
|
+
`The .dbf has ${table.remainingRows} extra attribute ${table.remainingRows === 1 ? "row" : "rows"} after the last Shapefile record.`
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
} finally {
|
|
2930
|
+
closeSync5(shp);
|
|
2931
|
+
table?.close();
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
function invalid3(message) {
|
|
2935
|
+
return new TilesetBuildError("TF_TILESET_BUILD_INPUT_INVALID", "input", message);
|
|
2936
|
+
}
|
|
2937
|
+
function decodeShape(content, index) {
|
|
2938
|
+
const type = content.readInt32LE(0);
|
|
2939
|
+
switch (type) {
|
|
2940
|
+
case 0:
|
|
2941
|
+
return null;
|
|
2942
|
+
case 1:
|
|
2943
|
+
case 11:
|
|
2944
|
+
case 21:
|
|
2945
|
+
return {
|
|
2946
|
+
coordinates: [content.readDoubleLE(4), content.readDoubleLE(12)],
|
|
2947
|
+
type: "Point"
|
|
2948
|
+
};
|
|
2949
|
+
case 8:
|
|
2950
|
+
case 18:
|
|
2951
|
+
case 28: {
|
|
2952
|
+
const count = content.readInt32LE(36);
|
|
2953
|
+
if (count > maximumFeaturePositions) throw featureTooLarge(`record ${index}`);
|
|
2954
|
+
return { coordinates: readPoints(content, 40, count), type: "MultiPoint" };
|
|
2955
|
+
}
|
|
2956
|
+
case 3:
|
|
2957
|
+
case 13:
|
|
2958
|
+
case 23:
|
|
2959
|
+
return { coordinates: readParts(content, index), type: "MultiLineString" };
|
|
2960
|
+
case 5:
|
|
2961
|
+
case 15:
|
|
2962
|
+
case 25:
|
|
2963
|
+
return { coordinates: assemblePolygons(readParts(content, index)), type: "MultiPolygon" };
|
|
2964
|
+
default:
|
|
2965
|
+
throw invalid3(`record ${index}: unsupported shape type ${type}.`);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
function readPoints(content, offset, count) {
|
|
2969
|
+
const points = new Array(count);
|
|
2970
|
+
for (let index = 0; index < count; index += 1) {
|
|
2971
|
+
points[index] = [
|
|
2972
|
+
content.readDoubleLE(offset + index * 16),
|
|
2973
|
+
content.readDoubleLE(offset + index * 16 + 8)
|
|
2974
|
+
];
|
|
2975
|
+
}
|
|
2976
|
+
return points;
|
|
2977
|
+
}
|
|
2978
|
+
function readParts(content, index) {
|
|
2979
|
+
const partCount = content.readInt32LE(36);
|
|
2980
|
+
const pointCount = content.readInt32LE(40);
|
|
2981
|
+
if (partCount > maximumFeaturePositions || pointCount > maximumFeaturePositions) {
|
|
2982
|
+
throw featureTooLarge(`record ${index}`);
|
|
2983
|
+
}
|
|
2984
|
+
const pointsOffset = 44 + partCount * 4;
|
|
2985
|
+
const parts = [];
|
|
2986
|
+
for (let part = 0; part < partCount; part += 1) {
|
|
2987
|
+
const start = content.readInt32LE(44 + part * 4);
|
|
2988
|
+
const end = part + 1 < partCount ? content.readInt32LE(44 + (part + 1) * 4) : pointCount;
|
|
2989
|
+
if (start < 0 || end < start || end > pointCount) {
|
|
2990
|
+
throw invalid3(`record ${index}: the part offsets are out of order.`);
|
|
2991
|
+
}
|
|
2992
|
+
parts.push(readPoints(content, pointsOffset + start * 16, end - start));
|
|
2993
|
+
}
|
|
2994
|
+
return parts;
|
|
2995
|
+
}
|
|
2996
|
+
function assemblePolygons(rings) {
|
|
2997
|
+
const exteriors = [];
|
|
2998
|
+
const holes = [];
|
|
2999
|
+
for (const points of rings) {
|
|
3000
|
+
if (points.length === 0) continue;
|
|
3001
|
+
const ring = describeRing(points);
|
|
3002
|
+
(ring.area <= 0 ? exteriors : holes).push(ring);
|
|
3003
|
+
}
|
|
3004
|
+
if (exteriors.length === 1 && holes.length === 0) return [[exteriors[0].points]];
|
|
3005
|
+
const polygons = exteriors.map((ring) => ({ ring, rings: [ring.points] }));
|
|
3006
|
+
for (const hole of holes) {
|
|
3007
|
+
let owner = null;
|
|
3008
|
+
for (const polygon of polygons) {
|
|
3009
|
+
if (!boundsContain(polygon.ring.bounds, hole.bounds)) continue;
|
|
3010
|
+
if (!pointInRing(hole.points[0], polygon.ring.points)) continue;
|
|
3011
|
+
if (!owner || Math.abs(polygon.ring.area) < Math.abs(owner.ring.area)) owner = polygon;
|
|
3012
|
+
}
|
|
3013
|
+
if (owner) owner.rings.push(hole.points);
|
|
3014
|
+
else polygons.push({ ring: hole, rings: [hole.points] });
|
|
3015
|
+
}
|
|
3016
|
+
return polygons.map((polygon) => polygon.rings);
|
|
3017
|
+
}
|
|
3018
|
+
function describeRing(points) {
|
|
3019
|
+
let area = 0;
|
|
3020
|
+
let minX = Infinity;
|
|
3021
|
+
let minY = Infinity;
|
|
3022
|
+
let maxX = -Infinity;
|
|
3023
|
+
let maxY = -Infinity;
|
|
3024
|
+
let previous = points[points.length - 1];
|
|
3025
|
+
for (const point of points) {
|
|
3026
|
+
area += previous[0] * point[1] - point[0] * previous[1];
|
|
3027
|
+
previous = point;
|
|
3028
|
+
if (point[0] < minX) minX = point[0];
|
|
3029
|
+
if (point[0] > maxX) maxX = point[0];
|
|
3030
|
+
if (point[1] < minY) minY = point[1];
|
|
3031
|
+
if (point[1] > maxY) maxY = point[1];
|
|
3032
|
+
}
|
|
3033
|
+
return { area, bounds: [minX, minY, maxX, maxY], points };
|
|
3034
|
+
}
|
|
3035
|
+
function boundsContain(outer, inner) {
|
|
3036
|
+
return outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3];
|
|
3037
|
+
}
|
|
3038
|
+
function pointInRing(point, ring) {
|
|
3039
|
+
const [x, y] = point;
|
|
3040
|
+
let inside = false;
|
|
3041
|
+
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
3042
|
+
const [ax, ay] = ring[index];
|
|
3043
|
+
const [bx, by] = ring[previous];
|
|
3044
|
+
if (ay > y !== by > y && x < (bx - ax) * (y - ay) / (by - ay) + ax) inside = !inside;
|
|
3045
|
+
}
|
|
3046
|
+
return inside;
|
|
3047
|
+
}
|
|
3048
|
+
var SequentialReader = class {
|
|
3049
|
+
constructor(descriptor, position) {
|
|
3050
|
+
this.descriptor = descriptor;
|
|
3051
|
+
this.position = position;
|
|
3052
|
+
}
|
|
3053
|
+
descriptor;
|
|
3054
|
+
#buffer = Buffer.allocUnsafe(4 * 1024 * 1024);
|
|
3055
|
+
#start = 0;
|
|
3056
|
+
#end = 0;
|
|
3057
|
+
position;
|
|
3058
|
+
read(size) {
|
|
3059
|
+
if (this.#end - this.#start < size) {
|
|
3060
|
+
const available = this.#end - this.#start;
|
|
3061
|
+
const target = size > this.#buffer.length ? Buffer.allocUnsafe(size * 2) : this.#buffer;
|
|
3062
|
+
this.#buffer.copy(target, 0, this.#start, this.#end);
|
|
3063
|
+
this.#buffer = target;
|
|
3064
|
+
this.#start = 0;
|
|
3065
|
+
this.#end = available;
|
|
3066
|
+
const bytes = readSync5(
|
|
3067
|
+
this.descriptor,
|
|
3068
|
+
this.#buffer,
|
|
3069
|
+
this.#end,
|
|
3070
|
+
this.#buffer.length - this.#end,
|
|
3071
|
+
this.position + available
|
|
3072
|
+
);
|
|
3073
|
+
this.#end += bytes;
|
|
3074
|
+
if (this.#end < size) return null;
|
|
3075
|
+
}
|
|
3076
|
+
const view = this.#buffer.subarray(this.#start, this.#start + size);
|
|
3077
|
+
this.#start += size;
|
|
3078
|
+
this.position += size;
|
|
3079
|
+
return view;
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
function openDbf(path, cpgPath) {
|
|
3083
|
+
const descriptor = openSync5(path, "r");
|
|
3084
|
+
try {
|
|
3085
|
+
return readDbfTable(descriptor, cpgPath);
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
closeSync5(descriptor);
|
|
3088
|
+
throw error;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
function readDbfTable(descriptor, cpgPath) {
|
|
3092
|
+
const header = Buffer.alloc(32);
|
|
3093
|
+
if (readSync5(descriptor, header, 0, header.length, 0) !== header.length) {
|
|
3094
|
+
throw invalid3("record 1: the .dbf header is incomplete.");
|
|
3095
|
+
}
|
|
3096
|
+
const records = header.readUInt32LE(4);
|
|
3097
|
+
const headerBytes2 = header.readUInt16LE(8);
|
|
3098
|
+
const recordBytes = header.readUInt16LE(10);
|
|
3099
|
+
if (headerBytes2 < 33 || recordBytes < 1)
|
|
3100
|
+
throw invalid3("record 1: the .dbf row layout is invalid.");
|
|
3101
|
+
const descriptors = Buffer.allocUnsafe(headerBytes2 - 32);
|
|
3102
|
+
if (readSync5(descriptor, descriptors, 0, descriptors.length, 32) !== descriptors.length) {
|
|
3103
|
+
throw invalid3("record 1: the .dbf field descriptors are incomplete.");
|
|
3104
|
+
}
|
|
3105
|
+
const encoding = cpgPath ? readFileSync2(cpgPath, "utf8").trim() : "utf-8";
|
|
3106
|
+
const decoder = createDecoder(encoding);
|
|
3107
|
+
const fields = [];
|
|
3108
|
+
let offset = 1;
|
|
3109
|
+
for (let cursor = 0; cursor + dbfFieldBytes <= descriptors.length; cursor += dbfFieldBytes) {
|
|
3110
|
+
if (descriptors[cursor] === 13) break;
|
|
3111
|
+
const raw = descriptors.subarray(cursor, cursor + 11);
|
|
3112
|
+
const end = raw.indexOf(0);
|
|
3113
|
+
const length = descriptors[cursor + 16];
|
|
3114
|
+
fields.push({
|
|
3115
|
+
length,
|
|
3116
|
+
name: decoder.decode(raw.subarray(0, end < 0 ? 11 : end)),
|
|
3117
|
+
offset,
|
|
3118
|
+
type: String.fromCharCode(descriptors[cursor + 11])
|
|
3119
|
+
});
|
|
3120
|
+
offset += length;
|
|
3121
|
+
}
|
|
3122
|
+
const stream = new SequentialReader(descriptor, headerBytes2);
|
|
3123
|
+
let index = 0;
|
|
3124
|
+
return {
|
|
3125
|
+
close: () => closeSync5(descriptor),
|
|
3126
|
+
get remainingRows() {
|
|
3127
|
+
return records - index;
|
|
3128
|
+
},
|
|
3129
|
+
/** One row of attributes, `null` for a deleted row. */
|
|
3130
|
+
next() {
|
|
3131
|
+
if (index >= records) throw invalid3(`record ${index + 1}: the .dbf has no matching row.`);
|
|
3132
|
+
index += 1;
|
|
3133
|
+
const row = stream.read(recordBytes);
|
|
3134
|
+
if (!row || row[0] === 26) throw invalid3(`record ${index}: the .dbf row is incomplete.`);
|
|
3135
|
+
if (row[0] === 42) return null;
|
|
3136
|
+
const properties = {};
|
|
3137
|
+
for (const item of fields) {
|
|
3138
|
+
const value = dbfValue(row, item, decoder);
|
|
3139
|
+
if (value !== null) properties[item.name] = value;
|
|
3140
|
+
}
|
|
3141
|
+
return properties;
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
function createDecoder(encoding) {
|
|
3146
|
+
try {
|
|
3147
|
+
return new TextDecoder(/^\d+$/u.test(encoding) ? `windows-${encoding}` : encoding);
|
|
3148
|
+
} catch {
|
|
3149
|
+
throw new TilesetBuildError(
|
|
3150
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
3151
|
+
"input",
|
|
3152
|
+
`The .cpg file names an unknown text encoding: ${encoding}.`
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
function dbfValue(row, item, decoder) {
|
|
3157
|
+
const bytes = row.subarray(item.offset, item.offset + item.length);
|
|
3158
|
+
switch (item.type) {
|
|
3159
|
+
case "N":
|
|
3160
|
+
case "F": {
|
|
3161
|
+
const text = bytes.toString("latin1").trim();
|
|
3162
|
+
const value = Number(text);
|
|
3163
|
+
return text === "" || !Number.isFinite(value) ? null : value;
|
|
3164
|
+
}
|
|
3165
|
+
case "L": {
|
|
3166
|
+
const flag = String.fromCharCode(bytes[0]).toUpperCase();
|
|
3167
|
+
if ("YT".includes(flag)) return true;
|
|
3168
|
+
if ("NF".includes(flag)) return false;
|
|
3169
|
+
return null;
|
|
3170
|
+
}
|
|
3171
|
+
case "D": {
|
|
3172
|
+
const text = bytes.toString("latin1").trim();
|
|
3173
|
+
return text.length === 8 ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6)}` : null;
|
|
3174
|
+
}
|
|
3175
|
+
case "C": {
|
|
3176
|
+
const text = decoder.decode(bytes).trim();
|
|
3177
|
+
return text === "" ? null : text;
|
|
3178
|
+
}
|
|
3179
|
+
default:
|
|
3180
|
+
return null;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
// src/tileset-build/read-zip.ts
|
|
3185
|
+
import { createWriteStream } from "fs";
|
|
3186
|
+
import { basename as basename2, extname as extname2, join as join3, posix } from "path";
|
|
3187
|
+
import { pipeline } from "stream/promises";
|
|
3188
|
+
import yauzl from "yauzl";
|
|
3189
|
+
var projectionExtension = ".prj";
|
|
3190
|
+
var dataExtensions = [".shp", ".dbf", ".cpg"];
|
|
3191
|
+
var maximumEntries2 = 65536;
|
|
3192
|
+
var maximumKmlBytes = 4 * 1024 ** 3;
|
|
3193
|
+
var ZippedShapefile = class _ZippedShapefile {
|
|
3194
|
+
constructor(zip, members, stem, uncompressedBytes) {
|
|
3195
|
+
this.zip = zip;
|
|
3196
|
+
this.members = members;
|
|
3197
|
+
this.stem = stem;
|
|
3198
|
+
this.uncompressedBytes = uncompressedBytes;
|
|
3199
|
+
}
|
|
3200
|
+
zip;
|
|
3201
|
+
members;
|
|
3202
|
+
stem;
|
|
3203
|
+
uncompressedBytes;
|
|
3204
|
+
static async open(path, requested) {
|
|
3205
|
+
const zip = await openZip(path);
|
|
3206
|
+
try {
|
|
3207
|
+
const entries = await listEntries(zip);
|
|
3208
|
+
const shapefiles = entries.filter(isShapefile);
|
|
3209
|
+
const chosen = chooseShapefile(shapefiles, requested);
|
|
3210
|
+
const stem = withoutExtension(chosen.fileName).toLowerCase();
|
|
3211
|
+
const members = /* @__PURE__ */ new Map();
|
|
3212
|
+
for (const entry of entries) {
|
|
3213
|
+
if (withoutExtension(entry.fileName).toLowerCase() !== stem) continue;
|
|
3214
|
+
const extension = extname2(entry.fileName).toLowerCase();
|
|
3215
|
+
if (extension === projectionExtension || dataExtensions.includes(extension)) {
|
|
3216
|
+
members.set(extension, entry);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
const uncompressedBytes = [...members.values()].reduce(
|
|
3220
|
+
(total, entry) => total + entry.uncompressedSize,
|
|
3221
|
+
0
|
|
3222
|
+
);
|
|
3223
|
+
return new _ZippedShapefile(
|
|
3224
|
+
zip,
|
|
3225
|
+
members,
|
|
3226
|
+
basename2(withoutExtension(chosen.fileName)),
|
|
3227
|
+
uncompressedBytes
|
|
3228
|
+
);
|
|
3229
|
+
} catch (error) {
|
|
3230
|
+
zip.close();
|
|
3231
|
+
throw error;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
/** The `.prj` comes first, so that a refused CRS does not pay for extracting the data. */
|
|
3235
|
+
async extractProjection(directory) {
|
|
3236
|
+
await this.#extract(directory, [projectionExtension]);
|
|
3237
|
+
return this.#shapefilePath(directory);
|
|
3238
|
+
}
|
|
3239
|
+
async extractData(directory) {
|
|
3240
|
+
await this.#extract(directory, dataExtensions);
|
|
3241
|
+
return this.#shapefilePath(directory);
|
|
3242
|
+
}
|
|
3243
|
+
close() {
|
|
3244
|
+
this.zip.close();
|
|
3245
|
+
}
|
|
3246
|
+
#shapefilePath(directory) {
|
|
3247
|
+
return join3(directory, `${this.stem}.shp`);
|
|
3248
|
+
}
|
|
3249
|
+
async #extract(directory, extensions) {
|
|
3250
|
+
for (const extension of extensions) {
|
|
3251
|
+
const entry = this.members.get(extension);
|
|
3252
|
+
if (!entry) continue;
|
|
3253
|
+
try {
|
|
3254
|
+
const stream = await openEntry(this.zip, entry);
|
|
3255
|
+
await pipeline(stream, createWriteStream(join3(directory, `${this.stem}${extension}`)));
|
|
3256
|
+
} catch (error) {
|
|
3257
|
+
if (error.code === "ENOSPC") throw error;
|
|
3258
|
+
throw unreadable2(error);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
};
|
|
3263
|
+
var ZippedKml = class _ZippedKml {
|
|
3264
|
+
constructor(zip, entry) {
|
|
3265
|
+
this.zip = zip;
|
|
3266
|
+
this.entry = entry;
|
|
3267
|
+
}
|
|
3268
|
+
zip;
|
|
3269
|
+
entry;
|
|
3270
|
+
static async open(path, requested) {
|
|
3271
|
+
const zip = await openZip(path);
|
|
3272
|
+
try {
|
|
3273
|
+
const entry = chooseKml((await listEntries(zip)).filter(isKml), requested);
|
|
3274
|
+
if (entry.uncompressedSize > maximumKmlBytes) {
|
|
3275
|
+
throw new TilesetBuildError(
|
|
3276
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
3277
|
+
"input",
|
|
3278
|
+
`${entry.fileName} expands to more than ${maximumKmlBytes / 1024 ** 3} GiB. Convert it to a format that can be streamed from disk first.`
|
|
3279
|
+
);
|
|
3280
|
+
}
|
|
3281
|
+
return new _ZippedKml(zip, entry);
|
|
3282
|
+
} catch (error) {
|
|
3283
|
+
zip.close();
|
|
3284
|
+
throw error;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
get uncompressedBytes() {
|
|
3288
|
+
return this.entry.uncompressedSize;
|
|
3289
|
+
}
|
|
3290
|
+
async *chunks() {
|
|
3291
|
+
try {
|
|
3292
|
+
for await (const chunk of await openEntry(this.zip, this.entry)) yield chunk;
|
|
3293
|
+
} catch (error) {
|
|
3294
|
+
throw unreadable2(error);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
close() {
|
|
3298
|
+
this.zip.close();
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
function isDataEntry(entry, extension) {
|
|
3302
|
+
const name = entry.fileName;
|
|
3303
|
+
return !name.endsWith("/") && extname2(name).toLowerCase() === extension && !basename2(name).startsWith(".") && !name.split("/").includes("__MACOSX");
|
|
3304
|
+
}
|
|
3305
|
+
function isShapefile(entry) {
|
|
3306
|
+
return isDataEntry(entry, ".shp");
|
|
3307
|
+
}
|
|
3308
|
+
function isKml(entry) {
|
|
3309
|
+
return isDataEntry(entry, ".kml");
|
|
3310
|
+
}
|
|
3311
|
+
function chooseKml(documents, requested) {
|
|
3312
|
+
const names = documents.map((entry) => entry.fileName).sort();
|
|
3313
|
+
if (names.length === 0) {
|
|
3314
|
+
throw new TilesetBuildError(
|
|
3315
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
3316
|
+
"input",
|
|
3317
|
+
"The KMZ has no .kml file."
|
|
3318
|
+
);
|
|
3319
|
+
}
|
|
3320
|
+
if (requested !== void 0) {
|
|
3321
|
+
const match = documents.find((entry) => entry.fileName === requested);
|
|
3322
|
+
if (match) return match;
|
|
3323
|
+
throw new TilesetBuildError(
|
|
3324
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
3325
|
+
"zipEntry",
|
|
3326
|
+
`The KMZ has no KML document named "${requested}". It has: ${listChoices(names)}.`
|
|
3327
|
+
);
|
|
3328
|
+
}
|
|
3329
|
+
const main = documents.find((entry) => entry.fileName.toLowerCase() === "doc.kml");
|
|
3330
|
+
if (main) return main;
|
|
3331
|
+
if (documents.length > 1) {
|
|
3332
|
+
throw new TilesetBuildError(
|
|
3333
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
3334
|
+
"zipEntry",
|
|
3335
|
+
`The KMZ has ${names.length} KML documents and no doc.kml. Choose one with --zip-entry: ${listChoices(names)}.`,
|
|
3336
|
+
names
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
return documents[0];
|
|
3340
|
+
}
|
|
3341
|
+
function chooseShapefile(shapefiles, requested) {
|
|
3342
|
+
const names = shapefiles.map((entry) => entry.fileName).sort();
|
|
3343
|
+
if (names.length === 0) {
|
|
3344
|
+
throw new TilesetBuildError(
|
|
3345
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
3346
|
+
"input",
|
|
3347
|
+
"The zip has no .shp file. Only Shapefiles can be read from a zip; extract other formats first."
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
const listed = listChoices(names);
|
|
3351
|
+
if (requested !== void 0) {
|
|
3352
|
+
const match = shapefiles.find((entry) => entry.fileName === requested);
|
|
3353
|
+
if (match) return match;
|
|
3354
|
+
throw new TilesetBuildError(
|
|
3355
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
3356
|
+
"zipEntry",
|
|
3357
|
+
`The zip has no Shapefile named "${requested}". It has: ${listed}.`
|
|
3358
|
+
);
|
|
3359
|
+
}
|
|
3360
|
+
if (shapefiles.length > 1) {
|
|
3361
|
+
throw new TilesetBuildError(
|
|
3362
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
3363
|
+
"zipEntry",
|
|
3364
|
+
`The zip has ${names.length} Shapefiles. Choose one with --zip-entry: ${listed}.`,
|
|
3365
|
+
names
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3368
|
+
return shapefiles[0];
|
|
3369
|
+
}
|
|
3370
|
+
function withoutExtension(name) {
|
|
3371
|
+
return name.slice(0, name.length - posix.extname(name).length);
|
|
3372
|
+
}
|
|
3373
|
+
function openZip(path) {
|
|
3374
|
+
return new Promise((resolve2, reject) => {
|
|
3375
|
+
yauzl.open(path, { autoClose: false, lazyEntries: true }, (error, zip) => {
|
|
3376
|
+
if (error) reject(unreadable2(error));
|
|
3377
|
+
else resolve2(zip);
|
|
3378
|
+
});
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
function listEntries(zip) {
|
|
3382
|
+
return new Promise((resolve2, reject) => {
|
|
3383
|
+
const entries = [];
|
|
3384
|
+
zip.on("entry", (entry) => {
|
|
3385
|
+
entries.push(entry);
|
|
3386
|
+
if (entries.length > maximumEntries2) {
|
|
3387
|
+
reject(
|
|
3388
|
+
new TilesetBuildError(
|
|
3389
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
3390
|
+
"input",
|
|
3391
|
+
`The archive has more than ${maximumEntries2} entries.`
|
|
3392
|
+
)
|
|
3393
|
+
);
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
zip.readEntry();
|
|
3397
|
+
});
|
|
3398
|
+
zip.once("end", () => resolve2(entries));
|
|
3399
|
+
zip.once("error", (error) => reject(unreadable2(error)));
|
|
3400
|
+
zip.readEntry();
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
function openEntry(zip, entry) {
|
|
3404
|
+
return new Promise((resolve2, reject) => {
|
|
3405
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
3406
|
+
if (error) reject(error);
|
|
3407
|
+
else resolve2(stream);
|
|
3408
|
+
});
|
|
3409
|
+
});
|
|
3410
|
+
}
|
|
3411
|
+
function unreadable2(error) {
|
|
3412
|
+
if (error instanceof TilesetBuildError) return error;
|
|
3413
|
+
const detail = error instanceof Error ? error.message : "unknown error";
|
|
3414
|
+
return new TilesetBuildError(
|
|
3415
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
3416
|
+
"input",
|
|
3417
|
+
`The zip could not be read: ${detail}.`
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// src/tileset-build/signature.ts
|
|
3422
|
+
import { closeSync as closeSync6, openSync as openSync6, readSync as readSync6 } from "fs";
|
|
3423
|
+
var probeBytes = 64 * 1024;
|
|
3424
|
+
var sqliteHeader = Buffer.from("SQLite format 3\0", "latin1");
|
|
3425
|
+
var shapefileFileCode = 9994;
|
|
3426
|
+
var utf8Bom = [239, 187, 191];
|
|
3427
|
+
var recordSeparator = 30;
|
|
3428
|
+
var formatNames = {
|
|
3429
|
+
csv: "CSV",
|
|
3430
|
+
geojson: "GeoJSON",
|
|
3431
|
+
geojsonseq: "newline-delimited GeoJSON",
|
|
3432
|
+
geopackage: "GeoPackage",
|
|
3433
|
+
gpx: "GPX",
|
|
3434
|
+
kml: "KML",
|
|
3435
|
+
shapefile: "a Shapefile"
|
|
3436
|
+
};
|
|
3437
|
+
function firstCharacter(bytes) {
|
|
3438
|
+
let index = utf8Bom.every((byte, position) => bytes[position] === byte) ? 3 : 0;
|
|
3439
|
+
while (index < bytes.length && isWhitespace(bytes[index])) index += 1;
|
|
3440
|
+
return bytes[index];
|
|
3441
|
+
}
|
|
3442
|
+
function isWhitespace(byte) {
|
|
3443
|
+
return byte === 32 || byte === 9 || byte === 10 || byte === 13 || byte === recordSeparator;
|
|
3444
|
+
}
|
|
3445
|
+
function matches(bytes, format) {
|
|
3446
|
+
switch (format) {
|
|
3447
|
+
case "csv":
|
|
3448
|
+
return !bytes.includes(0);
|
|
3449
|
+
case "geojson":
|
|
3450
|
+
case "geojsonseq":
|
|
3451
|
+
return firstCharacter(bytes) === 123;
|
|
3452
|
+
case "geopackage":
|
|
3453
|
+
return bytes.subarray(0, sqliteHeader.length).equals(sqliteHeader);
|
|
3454
|
+
case "gpx":
|
|
3455
|
+
case "kml":
|
|
3456
|
+
return firstCharacter(bytes) === 60 || hasUtf16Bom(bytes);
|
|
3457
|
+
case "shapefile":
|
|
3458
|
+
return bytes.length >= 4 && bytes.readInt32BE(0) === shapefileFileCode;
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
function hasUtf16Bom(bytes) {
|
|
3462
|
+
return bytes[0] === 255 && bytes[1] === 254 || bytes[0] === 254 && bytes[1] === 255;
|
|
3463
|
+
}
|
|
3464
|
+
function isZipHeader(bytes) {
|
|
3465
|
+
return bytes[0] === 80 && bytes[1] === 75 && [3, 5].includes(bytes[2]);
|
|
3466
|
+
}
|
|
3467
|
+
function checkInputSignature(path, format, container) {
|
|
3468
|
+
const descriptor = openSync6(path, "r");
|
|
3469
|
+
try {
|
|
3470
|
+
const buffer = Buffer.allocUnsafe(probeBytes);
|
|
3471
|
+
const bytes = buffer.subarray(0, readSync6(descriptor, buffer, 0, probeBytes, 0));
|
|
3472
|
+
if (bytes.length === 0) return;
|
|
3473
|
+
if (container ? isZipHeader(bytes) : matches(bytes, format)) return;
|
|
3474
|
+
throw new TilesetBuildError(
|
|
3475
|
+
"TF_TILESET_BUILD_INPUT_INVALID",
|
|
3476
|
+
"input",
|
|
3477
|
+
`The file does not look like ${container ? `a ${container.toUpperCase()} archive` : formatNames[format]}. Check the file, or pass --format if its extension is misleading.`
|
|
3478
|
+
);
|
|
3479
|
+
} finally {
|
|
3480
|
+
closeSync6(descriptor);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
// src/tileset-build/source-hash.ts
|
|
3485
|
+
import { createHash as createHash3 } from "crypto";
|
|
3486
|
+
import { createReadStream as createReadStream2 } from "fs";
|
|
3487
|
+
async function hashSource(input, format, zipped) {
|
|
3488
|
+
const hash = createHash3("sha256");
|
|
3489
|
+
const sidecars = format === "shapefile" && !zipped ? [".dbf", ".prj", ".cpg"].flatMap((extension) => shapefileSidecar(input, extension) ?? []) : [];
|
|
3490
|
+
for (const path of [input, ...sidecars]) {
|
|
3491
|
+
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
3492
|
+
}
|
|
3493
|
+
return hash.digest("hex");
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// src/tileset-build/clip.ts
|
|
3497
|
+
var Geometry = class {
|
|
3498
|
+
coordinates = new Int32Array(4096);
|
|
3499
|
+
parts = new Int32Array(64);
|
|
3500
|
+
partCount = 0;
|
|
3501
|
+
pointCount = 0;
|
|
3502
|
+
reset() {
|
|
3503
|
+
this.partCount = 0;
|
|
3504
|
+
this.pointCount = 0;
|
|
3505
|
+
}
|
|
3506
|
+
push(x, y) {
|
|
3507
|
+
if ((this.pointCount + 1) * 2 > this.coordinates.length) {
|
|
3508
|
+
const grown = new Int32Array(this.coordinates.length * 2);
|
|
3509
|
+
grown.set(this.coordinates);
|
|
3510
|
+
this.coordinates = grown;
|
|
3511
|
+
}
|
|
3512
|
+
this.coordinates[this.pointCount * 2] = x;
|
|
3513
|
+
this.coordinates[this.pointCount * 2 + 1] = y;
|
|
3514
|
+
this.pointCount += 1;
|
|
3515
|
+
}
|
|
3516
|
+
endPart(start, minimumPoints) {
|
|
3517
|
+
const size = this.pointCount - start;
|
|
3518
|
+
if (size < minimumPoints) {
|
|
3519
|
+
this.pointCount = start;
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
if (this.partCount === this.parts.length) {
|
|
3523
|
+
const grown = new Int32Array(this.parts.length * 2);
|
|
3524
|
+
grown.set(this.parts);
|
|
3525
|
+
this.parts = grown;
|
|
3526
|
+
}
|
|
3527
|
+
this.parts[this.partCount] = size;
|
|
3528
|
+
this.partCount += 1;
|
|
3529
|
+
}
|
|
3530
|
+
};
|
|
3531
|
+
var current = new Geometry();
|
|
3532
|
+
var next = new Geometry();
|
|
3533
|
+
function clipRecordInto(words, offset, box, target) {
|
|
3534
|
+
if (words[offset + field.maxX] < box.minX || words[offset + field.minX] > box.maxX || words[offset + field.maxY] < box.minY || words[offset + field.minY] > box.maxY) {
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
if (words[offset + field.minX] >= box.minX && words[offset + field.maxX] <= box.maxX && words[offset + field.minY] >= box.minY && words[offset + field.maxY] <= box.maxY) {
|
|
3538
|
+
target.append(words, offset, words[offset + field.words]);
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
const type = words[offset + field.type];
|
|
3542
|
+
const partCount = words[offset + field.parts];
|
|
3543
|
+
const pointCount = words[offset + field.points];
|
|
3544
|
+
const partsStart = offset + headerWords;
|
|
3545
|
+
const coordinatesStart = partsStart + partCount;
|
|
3546
|
+
current.reset();
|
|
3547
|
+
let cursor = coordinatesStart;
|
|
3548
|
+
for (let part = 0; part < partCount; part += 1) {
|
|
3549
|
+
const size2 = words[partsStart + part];
|
|
3550
|
+
const start2 = current.pointCount;
|
|
3551
|
+
for (let point = 0; point < size2; point += 1) {
|
|
3552
|
+
current.push(words[cursor], words[cursor + 1]);
|
|
3553
|
+
cursor += 2;
|
|
3554
|
+
}
|
|
3555
|
+
current.endPart(start2, 1);
|
|
3556
|
+
}
|
|
3557
|
+
clipAxis(type, 0, box.minX, 1);
|
|
3558
|
+
clipAxis(type, 0, box.maxX, -1);
|
|
3559
|
+
clipAxis(type, 1, box.minY, 1);
|
|
3560
|
+
clipAxis(type, 1, box.maxY, -1);
|
|
3561
|
+
if (current.partCount === 0) return;
|
|
3562
|
+
const propertyBytes = words[offset + field.propertyBytes];
|
|
3563
|
+
const propertyWords = Math.ceil(propertyBytes / 4);
|
|
3564
|
+
const size = headerWords + current.partCount + current.pointCount * 2 + propertyWords;
|
|
3565
|
+
const start = target.reserve(size);
|
|
3566
|
+
const output = target.words;
|
|
3567
|
+
let minX = Infinity;
|
|
3568
|
+
let minY = Infinity;
|
|
3569
|
+
let maxX = -Infinity;
|
|
3570
|
+
let maxY = -Infinity;
|
|
3571
|
+
for (let index = 0; index < current.pointCount; index += 1) {
|
|
3572
|
+
const x = current.coordinates[index * 2];
|
|
3573
|
+
const y = current.coordinates[index * 2 + 1];
|
|
3574
|
+
if (x < minX) minX = x;
|
|
3575
|
+
if (x > maxX) maxX = x;
|
|
3576
|
+
if (y < minY) minY = y;
|
|
3577
|
+
if (y > maxY) maxY = y;
|
|
3578
|
+
}
|
|
3579
|
+
output[start + field.words] = size;
|
|
3580
|
+
output[start + field.type] = type;
|
|
3581
|
+
output[start + field.minX] = minX;
|
|
3582
|
+
output[start + field.minY] = minY;
|
|
3583
|
+
output[start + field.maxX] = maxX;
|
|
3584
|
+
output[start + field.maxY] = maxY;
|
|
3585
|
+
output[start + field.parts] = current.partCount;
|
|
3586
|
+
output[start + field.points] = current.pointCount;
|
|
3587
|
+
output[start + field.propertyBytes] = propertyBytes;
|
|
3588
|
+
output[start + field.idLow] = words[offset + field.idLow];
|
|
3589
|
+
output[start + field.idHigh] = words[offset + field.idHigh];
|
|
3590
|
+
output[start + field.kept] = words[offset + field.kept];
|
|
3591
|
+
output.set(current.parts.subarray(0, current.partCount), start + headerWords);
|
|
3592
|
+
output.set(
|
|
3593
|
+
current.coordinates.subarray(0, current.pointCount * 2),
|
|
3594
|
+
start + headerWords + current.partCount
|
|
3595
|
+
);
|
|
3596
|
+
output.set(
|
|
3597
|
+
words.subarray(
|
|
3598
|
+
coordinatesStart + pointCount * 2,
|
|
3599
|
+
coordinatesStart + pointCount * 2 + propertyWords
|
|
3600
|
+
),
|
|
3601
|
+
start + headerWords + current.partCount + current.pointCount * 2
|
|
3602
|
+
);
|
|
3603
|
+
}
|
|
3604
|
+
function clipAxis(type, axis, bound, sign) {
|
|
3605
|
+
if (type === geometryType.point) clipPoints(axis, bound, sign);
|
|
3606
|
+
else if (type === geometryType.line) clipLines(axis, bound, sign);
|
|
3607
|
+
else clipRings(axis, bound, sign);
|
|
3608
|
+
const swap = current;
|
|
3609
|
+
current = next;
|
|
3610
|
+
next = swap;
|
|
3611
|
+
}
|
|
3612
|
+
function clipPoints(axis, bound, sign) {
|
|
3613
|
+
next.reset();
|
|
3614
|
+
const start = 0;
|
|
3615
|
+
for (let index = 0; index < current.pointCount; index += 1) {
|
|
3616
|
+
const x = current.coordinates[index * 2];
|
|
3617
|
+
const y = current.coordinates[index * 2 + 1];
|
|
3618
|
+
if (sign * ((axis === 0 ? x : y) - bound) >= 0) next.push(x, y);
|
|
3619
|
+
}
|
|
3620
|
+
next.endPart(start, 1);
|
|
3621
|
+
}
|
|
3622
|
+
function clipLines(axis, bound, sign) {
|
|
3623
|
+
next.reset();
|
|
3624
|
+
let cursor = 0;
|
|
3625
|
+
for (let part = 0; part < current.partCount; part += 1) {
|
|
3626
|
+
const size = current.parts[part];
|
|
3627
|
+
let start = next.pointCount;
|
|
3628
|
+
let previousX = 0;
|
|
3629
|
+
let previousY = 0;
|
|
3630
|
+
let previousInside = false;
|
|
3631
|
+
for (let index = 0; index < size; index += 1) {
|
|
3632
|
+
const x = current.coordinates[cursor * 2];
|
|
3633
|
+
const y = current.coordinates[cursor * 2 + 1];
|
|
3634
|
+
cursor += 1;
|
|
3635
|
+
const inside = sign * ((axis === 0 ? x : y) - bound) >= 0;
|
|
3636
|
+
if (index > 0 && inside !== previousInside) {
|
|
3637
|
+
pushCrossing(axis, bound, previousX, previousY, x, y);
|
|
3638
|
+
if (!inside) {
|
|
3639
|
+
next.endPart(start, 2);
|
|
3640
|
+
start = next.pointCount;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
if (inside) next.push(x, y);
|
|
3644
|
+
previousX = x;
|
|
3645
|
+
previousY = y;
|
|
3646
|
+
previousInside = inside;
|
|
3647
|
+
}
|
|
3648
|
+
next.endPart(start, 2);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
function clipRings(axis, bound, sign) {
|
|
3652
|
+
next.reset();
|
|
3653
|
+
let cursor = 0;
|
|
3654
|
+
for (let part = 0; part < current.partCount; part += 1) {
|
|
3655
|
+
const size = current.parts[part];
|
|
3656
|
+
const start = next.pointCount;
|
|
3657
|
+
let previousX = current.coordinates[(cursor + size - 1) * 2];
|
|
3658
|
+
let previousY = current.coordinates[(cursor + size - 1) * 2 + 1];
|
|
3659
|
+
let previousInside = sign * ((axis === 0 ? previousX : previousY) - bound) >= 0;
|
|
3660
|
+
for (let index = 0; index < size; index += 1) {
|
|
3661
|
+
const x = current.coordinates[cursor * 2];
|
|
3662
|
+
const y = current.coordinates[cursor * 2 + 1];
|
|
3663
|
+
cursor += 1;
|
|
3664
|
+
const inside = sign * ((axis === 0 ? x : y) - bound) >= 0;
|
|
3665
|
+
if (inside !== previousInside) pushCrossing(axis, bound, previousX, previousY, x, y);
|
|
3666
|
+
if (inside) next.push(x, y);
|
|
3667
|
+
previousX = x;
|
|
3668
|
+
previousY = y;
|
|
3669
|
+
previousInside = inside;
|
|
3670
|
+
}
|
|
3671
|
+
next.endPart(start, 3);
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
function pushCrossing(axis, bound, fromX, fromY, toX, toY) {
|
|
3675
|
+
if (axis === 0) {
|
|
3676
|
+
next.push(bound, Math.round(fromY + (bound - fromX) * (toY - fromY) / (toX - fromX)));
|
|
3677
|
+
} else {
|
|
3678
|
+
next.push(Math.round(fromX + (bound - fromY) * (toX - fromX) / (toY - fromY)), bound);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
// src/tileset-build/mvt.ts
|
|
3683
|
+
var tileExtent = 4096;
|
|
3684
|
+
var tileBufferUnits = 64;
|
|
3685
|
+
var extentBits = 12;
|
|
3686
|
+
var markSize = 2;
|
|
3687
|
+
var ByteWriter = class {
|
|
3688
|
+
bytes = new Uint8Array(4096);
|
|
3689
|
+
length = 0;
|
|
3690
|
+
#ensure(size) {
|
|
3691
|
+
if (this.length + size <= this.bytes.length) return;
|
|
3692
|
+
const grown = new Uint8Array(Math.max(this.bytes.length * 2, this.length + size));
|
|
3693
|
+
grown.set(this.bytes.subarray(0, this.length));
|
|
3694
|
+
this.bytes = grown;
|
|
3695
|
+
}
|
|
3696
|
+
byte(value) {
|
|
3697
|
+
this.#ensure(1);
|
|
3698
|
+
this.bytes[this.length] = value;
|
|
3699
|
+
this.length += 1;
|
|
3700
|
+
}
|
|
3701
|
+
varint(value) {
|
|
3702
|
+
this.#ensure(10);
|
|
3703
|
+
while (value >= 128) {
|
|
3704
|
+
this.bytes[this.length] = value % 128 | 128;
|
|
3705
|
+
this.length += 1;
|
|
3706
|
+
value = Math.floor(value / 128);
|
|
3707
|
+
}
|
|
3708
|
+
this.bytes[this.length] = value;
|
|
3709
|
+
this.length += 1;
|
|
3710
|
+
}
|
|
3711
|
+
raw(source, start, end) {
|
|
3712
|
+
this.#ensure(end - start);
|
|
3713
|
+
this.bytes.set(source.subarray(start, end), this.length);
|
|
3714
|
+
this.length += end - start;
|
|
3715
|
+
}
|
|
3716
|
+
string(value) {
|
|
3717
|
+
const encoded = Buffer.from(value, "utf8");
|
|
3718
|
+
this.varint(encoded.length);
|
|
3719
|
+
this.raw(encoded, 0, encoded.length);
|
|
3720
|
+
}
|
|
3721
|
+
reset() {
|
|
3722
|
+
this.length = 0;
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
function varintLength(value) {
|
|
3726
|
+
let length = 1;
|
|
3727
|
+
while (value >= 128) {
|
|
3728
|
+
length += 1;
|
|
3729
|
+
value = Math.floor(value / 128);
|
|
3730
|
+
}
|
|
3731
|
+
return length;
|
|
3732
|
+
}
|
|
3733
|
+
var TileEncoder = class {
|
|
3734
|
+
constructor(layer, keys) {
|
|
3735
|
+
this.layer = layer;
|
|
3736
|
+
this.keys = keys;
|
|
3737
|
+
this.#keyIndex = new Int32Array(keys.length).fill(-1);
|
|
3738
|
+
}
|
|
3739
|
+
layer;
|
|
3740
|
+
keys;
|
|
3741
|
+
#features = new ByteWriter();
|
|
3742
|
+
#tags = new ByteWriter();
|
|
3743
|
+
#geometry = new ByteWriter();
|
|
3744
|
+
#values = new ByteWriter();
|
|
3745
|
+
#output = new ByteWriter();
|
|
3746
|
+
#keyIndex;
|
|
3747
|
+
/** Open-addressing table of value indexes, so interning allocates nothing per feature. */
|
|
3748
|
+
#valueSlots = new Int32Array(1 << 12).fill(-1);
|
|
3749
|
+
#valueStarts = [];
|
|
3750
|
+
#valueLengths = [];
|
|
3751
|
+
#usedKeys = [];
|
|
3752
|
+
#shift = 0;
|
|
3753
|
+
#originX = 0;
|
|
3754
|
+
#originY = 0;
|
|
3755
|
+
#tolerance = 0;
|
|
3756
|
+
#minimumArea = 0;
|
|
3757
|
+
#keepCollapsedLines = false;
|
|
3758
|
+
#scratch = new Int32Array(8192);
|
|
3759
|
+
#keep = new Uint8Array(4096);
|
|
3760
|
+
#stack = [];
|
|
3761
|
+
featureCount = 0;
|
|
3762
|
+
/** The number of bytes `finish` would need, ignoring the small key and header overhead. */
|
|
3763
|
+
get approximateBytes() {
|
|
3764
|
+
return this.#features.length + this.#values.length;
|
|
3765
|
+
}
|
|
3766
|
+
begin(z, x, y, options) {
|
|
3767
|
+
this.#features.reset();
|
|
3768
|
+
this.#values.reset();
|
|
3769
|
+
if (this.#valueStarts.length > 0) this.#valueSlots.fill(-1);
|
|
3770
|
+
this.#valueStarts.length = 0;
|
|
3771
|
+
this.#valueLengths.length = 0;
|
|
3772
|
+
for (const key of this.#usedKeys) this.#keyIndex[key] = -1;
|
|
3773
|
+
this.#usedKeys = [];
|
|
3774
|
+
this.featureCount = 0;
|
|
3775
|
+
this.#shift = 31 - z - extentBits;
|
|
3776
|
+
this.#originX = x * 2 ** (31 - z);
|
|
3777
|
+
this.#originY = y * 2 ** (31 - z);
|
|
3778
|
+
this.#tolerance = options.tolerance;
|
|
3779
|
+
this.#minimumArea = options.minimumArea;
|
|
3780
|
+
this.#keepCollapsedLines = options.keepCollapsedLines;
|
|
3781
|
+
}
|
|
3782
|
+
/** Add one record. Returns false when its geometry collapses at this zoom and has no mark. */
|
|
3783
|
+
add(words, offset) {
|
|
3784
|
+
this.#geometry.reset();
|
|
3785
|
+
const type = words[offset + field.type];
|
|
3786
|
+
const partCount = words[offset + field.parts];
|
|
3787
|
+
const pointCount = words[offset + field.points];
|
|
3788
|
+
const partsStart = offset + headerWords;
|
|
3789
|
+
const coordinatesStart = partsStart + partCount;
|
|
3790
|
+
const encoded = type === geometryType.point ? this.#encodePoints(words, coordinatesStart, pointCount) : this.#encodeParts(type, words, partsStart, partCount, coordinatesStart);
|
|
3791
|
+
if (!encoded) return false;
|
|
3792
|
+
this.#tags.reset();
|
|
3793
|
+
this.#encodeTags(words, coordinatesStart + pointCount * 2, words[offset + field.propertyBytes]);
|
|
3794
|
+
const id = idOf(words, offset);
|
|
3795
|
+
const body = (id > 0 ? 1 + varintLength(id - 1) : 0) + (this.#tags.length > 0 ? 1 + varintLength(this.#tags.length) + this.#tags.length : 0) + 2 + 1 + varintLength(this.#geometry.length) + this.#geometry.length;
|
|
3796
|
+
const features = this.#features;
|
|
3797
|
+
features.byte(18);
|
|
3798
|
+
features.varint(body);
|
|
3799
|
+
if (id > 0) {
|
|
3800
|
+
features.byte(8);
|
|
3801
|
+
features.varint(id - 1);
|
|
3802
|
+
}
|
|
3803
|
+
if (this.#tags.length > 0) {
|
|
3804
|
+
features.byte(18);
|
|
3805
|
+
features.varint(this.#tags.length);
|
|
3806
|
+
features.raw(this.#tags.bytes, 0, this.#tags.length);
|
|
3807
|
+
}
|
|
3808
|
+
features.byte(24);
|
|
3809
|
+
features.byte(type);
|
|
3810
|
+
features.byte(34);
|
|
3811
|
+
features.varint(this.#geometry.length);
|
|
3812
|
+
features.raw(this.#geometry.bytes, 0, this.#geometry.length);
|
|
3813
|
+
this.featureCount += 1;
|
|
3814
|
+
return true;
|
|
3815
|
+
}
|
|
3816
|
+
finish() {
|
|
3817
|
+
const output = this.#output;
|
|
3818
|
+
output.reset();
|
|
3819
|
+
const keySection = new ByteWriter();
|
|
3820
|
+
for (const key of this.#usedKeys) {
|
|
3821
|
+
keySection.byte(26);
|
|
3822
|
+
keySection.string(this.keys[key]);
|
|
3823
|
+
}
|
|
3824
|
+
const name = Buffer.from(this.layer, "utf8");
|
|
3825
|
+
const layerLength = 2 + 1 + varintLength(name.length) + name.length + this.#features.length + keySection.length + this.#values.length + 3;
|
|
3826
|
+
output.byte(26);
|
|
3827
|
+
output.varint(layerLength);
|
|
3828
|
+
output.byte(120);
|
|
3829
|
+
output.byte(2);
|
|
3830
|
+
output.byte(10);
|
|
3831
|
+
output.varint(name.length);
|
|
3832
|
+
output.raw(name, 0, name.length);
|
|
3833
|
+
output.raw(this.#features.bytes, 0, this.#features.length);
|
|
3834
|
+
output.raw(keySection.bytes, 0, keySection.length);
|
|
3835
|
+
output.raw(this.#values.bytes, 0, this.#values.length);
|
|
3836
|
+
output.byte(40);
|
|
3837
|
+
output.varint(tileExtent);
|
|
3838
|
+
return output.bytes.slice(0, output.length);
|
|
3839
|
+
}
|
|
3840
|
+
#encodeTags(words, start, byteLength) {
|
|
3841
|
+
if (byteLength === 0) return;
|
|
3842
|
+
const bytes = new Uint8Array(words.buffer, words.byteOffset + start * 4, byteLength);
|
|
3843
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, byteLength);
|
|
3844
|
+
let cursor = 0;
|
|
3845
|
+
while (cursor < byteLength) {
|
|
3846
|
+
const key = view.getUint16(cursor, true);
|
|
3847
|
+
const length = view.getUint32(cursor + 2, true);
|
|
3848
|
+
cursor += 6;
|
|
3849
|
+
let keyIndex = this.#keyIndex[key];
|
|
3850
|
+
if (keyIndex < 0) {
|
|
3851
|
+
keyIndex = this.#usedKeys.length;
|
|
3852
|
+
this.#keyIndex[key] = keyIndex;
|
|
3853
|
+
this.#usedKeys.push(key);
|
|
3854
|
+
}
|
|
3855
|
+
const valueIndex = this.#internValue(bytes, cursor, length);
|
|
3856
|
+
this.#tags.varint(keyIndex);
|
|
3857
|
+
this.#tags.varint(valueIndex);
|
|
3858
|
+
cursor += length;
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
/** Attribute values are spooled as encoded Value messages, so interning needs no decoding. */
|
|
3862
|
+
#internValue(bytes, start, length) {
|
|
3863
|
+
let hash = 2166136261;
|
|
3864
|
+
for (let index2 = start; index2 < start + length; index2 += 1) {
|
|
3865
|
+
hash = Math.imul(hash ^ bytes[index2], 16777619);
|
|
3866
|
+
}
|
|
3867
|
+
const mask = this.#valueSlots.length - 1;
|
|
3868
|
+
let slot = hash & mask;
|
|
3869
|
+
for (; ; ) {
|
|
3870
|
+
const candidate = this.#valueSlots[slot];
|
|
3871
|
+
if (candidate < 0) break;
|
|
3872
|
+
if (this.#sameValue(candidate, bytes, start, length)) return candidate;
|
|
3873
|
+
slot = slot + 1 & mask;
|
|
3874
|
+
}
|
|
3875
|
+
const index = this.#valueStarts.length;
|
|
3876
|
+
this.#valueSlots[slot] = index;
|
|
3877
|
+
this.#values.byte(34);
|
|
3878
|
+
this.#values.varint(length);
|
|
3879
|
+
this.#valueStarts.push(this.#values.length);
|
|
3880
|
+
this.#valueLengths.push(length);
|
|
3881
|
+
this.#values.raw(bytes, start, start + length);
|
|
3882
|
+
if ((index + 1) * 2 > this.#valueSlots.length) this.#growValueSlots();
|
|
3883
|
+
return index;
|
|
3884
|
+
}
|
|
3885
|
+
#sameValue(index, bytes, start, length) {
|
|
3886
|
+
if (this.#valueLengths[index] !== length) return false;
|
|
3887
|
+
const stored = this.#values.bytes;
|
|
3888
|
+
const offset = this.#valueStarts[index];
|
|
3889
|
+
for (let cursor = 0; cursor < length; cursor += 1) {
|
|
3890
|
+
if (stored[offset + cursor] !== bytes[start + cursor]) return false;
|
|
3891
|
+
}
|
|
3892
|
+
return true;
|
|
3893
|
+
}
|
|
3894
|
+
#growValueSlots() {
|
|
3895
|
+
const slots = new Int32Array(this.#valueSlots.length * 2).fill(-1);
|
|
3896
|
+
const mask = slots.length - 1;
|
|
3897
|
+
const stored = this.#values.bytes;
|
|
3898
|
+
for (let index = 0; index < this.#valueStarts.length; index += 1) {
|
|
3899
|
+
const start = this.#valueStarts[index];
|
|
3900
|
+
let hash = 2166136261;
|
|
3901
|
+
for (let cursor = start; cursor < start + this.#valueLengths[index]; cursor += 1) {
|
|
3902
|
+
hash = Math.imul(hash ^ stored[cursor], 16777619);
|
|
3903
|
+
}
|
|
3904
|
+
let slot = hash & mask;
|
|
3905
|
+
while (slots[slot] >= 0) slot = slot + 1 & mask;
|
|
3906
|
+
slots[slot] = index;
|
|
3907
|
+
}
|
|
3908
|
+
this.#valueSlots = slots;
|
|
3909
|
+
}
|
|
3910
|
+
#encodePoints(words, start, count) {
|
|
3911
|
+
const geometry = this.#geometry;
|
|
3912
|
+
geometry.varint((count << 3 | 1) >>> 0);
|
|
3913
|
+
let lastX = 0;
|
|
3914
|
+
let lastY = 0;
|
|
3915
|
+
for (let index = 0; index < count; index += 1) {
|
|
3916
|
+
const x = this.#tileX(words[start + index * 2]);
|
|
3917
|
+
const y = this.#tileY(words[start + index * 2 + 1]);
|
|
3918
|
+
geometry.varint(zigzag(x - lastX));
|
|
3919
|
+
geometry.varint(zigzag(y - lastY));
|
|
3920
|
+
lastX = x;
|
|
3921
|
+
lastY = y;
|
|
3922
|
+
}
|
|
3923
|
+
return true;
|
|
3924
|
+
}
|
|
3925
|
+
#encodeParts(type, words, partsStart, partCount, coordinatesStart) {
|
|
3926
|
+
const polygon = type === geometryType.polygon;
|
|
3927
|
+
const minimumPoints = polygon ? 3 : 2;
|
|
3928
|
+
const geometry = this.#geometry;
|
|
3929
|
+
let cursor = coordinatesStart;
|
|
3930
|
+
let lastX = 0;
|
|
3931
|
+
let lastY = 0;
|
|
3932
|
+
let exteriorKept = false;
|
|
3933
|
+
for (let part = 0; part < partCount; part += 1) {
|
|
3934
|
+
const size = words[partsStart + part];
|
|
3935
|
+
const count = this.#projectPart(words, cursor, size, polygon);
|
|
3936
|
+
cursor += size * 2;
|
|
3937
|
+
if (polygon) {
|
|
3938
|
+
const area = count < 3 ? 0 : doubledArea(this.#scratch, count);
|
|
3939
|
+
const exterior = area > 0;
|
|
3940
|
+
if (area === 0 || !exterior && !exteriorKept) continue;
|
|
3941
|
+
if (Math.abs(area) < this.#minimumArea) {
|
|
3942
|
+
if (exterior) exteriorKept = false;
|
|
3943
|
+
continue;
|
|
3944
|
+
}
|
|
3945
|
+
if (exterior) exteriorKept = true;
|
|
3946
|
+
} else if (count < minimumPoints) {
|
|
3947
|
+
continue;
|
|
3948
|
+
}
|
|
3949
|
+
const scratch = this.#scratch;
|
|
3950
|
+
geometry.varint(9);
|
|
3951
|
+
geometry.varint(zigzag(scratch[0] - lastX));
|
|
3952
|
+
geometry.varint(zigzag(scratch[1] - lastY));
|
|
3953
|
+
lastX = scratch[0];
|
|
3954
|
+
lastY = scratch[1];
|
|
3955
|
+
geometry.varint((count - 1 << 3 | 2) >>> 0);
|
|
3956
|
+
for (let index = 1; index < count; index += 1) {
|
|
3957
|
+
const x = scratch[index * 2];
|
|
3958
|
+
const y = scratch[index * 2 + 1];
|
|
3959
|
+
geometry.varint(zigzag(x - lastX));
|
|
3960
|
+
geometry.varint(zigzag(y - lastY));
|
|
3961
|
+
lastX = x;
|
|
3962
|
+
lastY = y;
|
|
3963
|
+
}
|
|
3964
|
+
if (polygon) geometry.varint(15);
|
|
3965
|
+
}
|
|
3966
|
+
if (geometry.length > 0) return true;
|
|
3967
|
+
if (!polygon && !this.#keepCollapsedLines) return false;
|
|
3968
|
+
this.#encodeMark(
|
|
3969
|
+
polygon,
|
|
3970
|
+
this.#tileX(words[coordinatesStart]),
|
|
3971
|
+
this.#tileY(words[coordinatesStart + 1])
|
|
3972
|
+
);
|
|
3973
|
+
return true;
|
|
3974
|
+
}
|
|
3975
|
+
/** The smallest visible shape, so that a feature too small for this zoom does not disappear. */
|
|
3976
|
+
#encodeMark(polygon, x, y) {
|
|
3977
|
+
const geometry = this.#geometry;
|
|
3978
|
+
geometry.varint(9);
|
|
3979
|
+
geometry.varint(zigzag(x));
|
|
3980
|
+
geometry.varint(zigzag(y));
|
|
3981
|
+
if (polygon) {
|
|
3982
|
+
geometry.varint((3 << 3 | 2) >>> 0);
|
|
3983
|
+
for (const [dx, dy] of [
|
|
3984
|
+
[markSize, 0],
|
|
3985
|
+
[0, markSize],
|
|
3986
|
+
[-markSize, 0]
|
|
3987
|
+
]) {
|
|
3988
|
+
geometry.varint(zigzag(dx));
|
|
3989
|
+
geometry.varint(zigzag(dy));
|
|
3990
|
+
}
|
|
3991
|
+
geometry.varint(15);
|
|
3992
|
+
} else {
|
|
3993
|
+
geometry.varint(10);
|
|
3994
|
+
geometry.varint(zigzag(markSize));
|
|
3995
|
+
geometry.varint(zigzag(0));
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
/** Project one part to tile units, drop repeats and simplify. Returns the kept point count. */
|
|
3999
|
+
#projectPart(words, start, size, ring) {
|
|
4000
|
+
if (size * 2 > this.#scratch.length) {
|
|
4001
|
+
this.#scratch = new Int32Array(size * 4);
|
|
4002
|
+
this.#keep = new Uint8Array(size * 2);
|
|
4003
|
+
}
|
|
4004
|
+
const scratch = this.#scratch;
|
|
4005
|
+
let count = 0;
|
|
4006
|
+
for (let index = 0; index < size; index += 1) {
|
|
4007
|
+
const x = this.#tileX(words[start + index * 2]);
|
|
4008
|
+
const y = this.#tileY(words[start + index * 2 + 1]);
|
|
4009
|
+
if (count > 0 && scratch[count * 2 - 2] === x && scratch[count * 2 - 1] === y) continue;
|
|
4010
|
+
scratch[count * 2] = x;
|
|
4011
|
+
scratch[count * 2 + 1] = y;
|
|
4012
|
+
count += 1;
|
|
4013
|
+
}
|
|
4014
|
+
if (ring) {
|
|
4015
|
+
while (count > 1 && scratch[0] === scratch[count * 2 - 2] && scratch[1] === scratch[count * 2 - 1]) {
|
|
4016
|
+
count -= 1;
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
if (this.#tolerance === 0 || count < 3) return count;
|
|
4020
|
+
return this.#simplify(count, ring);
|
|
4021
|
+
}
|
|
4022
|
+
/** Douglas-Peucker. Rings keep their first point and the point farthest from it. */
|
|
4023
|
+
#simplify(count, ring) {
|
|
4024
|
+
const scratch = this.#scratch;
|
|
4025
|
+
const keep = this.#keep;
|
|
4026
|
+
keep.fill(0, 0, count + 1);
|
|
4027
|
+
const tolerance = this.#tolerance * this.#tolerance;
|
|
4028
|
+
const stack = this.#stack;
|
|
4029
|
+
let last = count - 1;
|
|
4030
|
+
keep[0] = 1;
|
|
4031
|
+
keep[last] = 1;
|
|
4032
|
+
if (ring) {
|
|
4033
|
+
let farthest = 1;
|
|
4034
|
+
let distance = -1;
|
|
4035
|
+
for (let index = 1; index < count; index += 1) {
|
|
4036
|
+
const dx = scratch[index * 2] - scratch[0];
|
|
4037
|
+
const dy = scratch[index * 2 + 1] - scratch[1];
|
|
4038
|
+
if (dx * dx + dy * dy > distance) {
|
|
4039
|
+
distance = dx * dx + dy * dy;
|
|
4040
|
+
farthest = index;
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
keep[last] = 0;
|
|
4044
|
+
keep[farthest] = 1;
|
|
4045
|
+
stack.push(farthest, last + 1);
|
|
4046
|
+
last = farthest;
|
|
4047
|
+
}
|
|
4048
|
+
stack.push(0, last);
|
|
4049
|
+
while (stack.length > 0) {
|
|
4050
|
+
const end = stack.pop();
|
|
4051
|
+
const start = stack.pop();
|
|
4052
|
+
if (end - start < 2) continue;
|
|
4053
|
+
const ax = scratch[start * 2];
|
|
4054
|
+
const ay = scratch[start * 2 + 1];
|
|
4055
|
+
const closing = end === count;
|
|
4056
|
+
const bx = scratch[(closing ? 0 : end) * 2];
|
|
4057
|
+
const by = scratch[(closing ? 0 : end) * 2 + 1];
|
|
4058
|
+
let farthest = -1;
|
|
4059
|
+
let distance = tolerance;
|
|
4060
|
+
for (let index = start + 1; index < end; index += 1) {
|
|
4061
|
+
const value = segmentDistanceSquared(
|
|
4062
|
+
scratch[index * 2],
|
|
4063
|
+
scratch[index * 2 + 1],
|
|
4064
|
+
ax,
|
|
4065
|
+
ay,
|
|
4066
|
+
bx,
|
|
4067
|
+
by
|
|
4068
|
+
);
|
|
4069
|
+
if (value > distance) {
|
|
4070
|
+
distance = value;
|
|
4071
|
+
farthest = index;
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
if (farthest >= 0) {
|
|
4075
|
+
keep[farthest] = 1;
|
|
4076
|
+
stack.push(start, farthest, farthest, end);
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
let kept = 0;
|
|
4080
|
+
for (let index = 0; index < count; index += 1) {
|
|
4081
|
+
if (keep[index] === 0) continue;
|
|
4082
|
+
scratch[kept * 2] = scratch[index * 2];
|
|
4083
|
+
scratch[kept * 2 + 1] = scratch[index * 2 + 1];
|
|
4084
|
+
kept += 1;
|
|
4085
|
+
}
|
|
4086
|
+
return kept;
|
|
4087
|
+
}
|
|
4088
|
+
#tileX(value) {
|
|
4089
|
+
return roundShift(value - this.#originX, this.#shift);
|
|
4090
|
+
}
|
|
4091
|
+
#tileY(value) {
|
|
4092
|
+
return roundShift(value - this.#originY, this.#shift);
|
|
4093
|
+
}
|
|
4094
|
+
};
|
|
4095
|
+
function idOf(words, offset) {
|
|
4096
|
+
return (words[offset + field.idHigh] >>> 0) * 2 ** 32 + (words[offset + field.idLow] >>> 0);
|
|
4097
|
+
}
|
|
4098
|
+
function roundShift(value, shift) {
|
|
4099
|
+
return Math.round(value / 2 ** shift);
|
|
4100
|
+
}
|
|
4101
|
+
function zigzag(value) {
|
|
4102
|
+
return value >= 0 ? value * 2 : -value * 2 - 1;
|
|
4103
|
+
}
|
|
4104
|
+
function doubledArea(points, count) {
|
|
4105
|
+
let area = 0;
|
|
4106
|
+
let previousX = points[(count - 1) * 2];
|
|
4107
|
+
let previousY = points[(count - 1) * 2 + 1];
|
|
4108
|
+
for (let index = 0; index < count; index += 1) {
|
|
4109
|
+
const x = points[index * 2];
|
|
4110
|
+
const y = points[index * 2 + 1];
|
|
4111
|
+
area += previousX * y - x * previousY;
|
|
4112
|
+
previousX = x;
|
|
4113
|
+
previousY = y;
|
|
4114
|
+
}
|
|
4115
|
+
return area;
|
|
4116
|
+
}
|
|
4117
|
+
function segmentDistanceSquared(x, y, ax, ay, bx, by) {
|
|
4118
|
+
let dx = bx - ax;
|
|
4119
|
+
let dy = by - ay;
|
|
4120
|
+
if (dx !== 0 || dy !== 0) {
|
|
4121
|
+
const t = ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy);
|
|
4122
|
+
if (t > 1) {
|
|
4123
|
+
ax = bx;
|
|
4124
|
+
ay = by;
|
|
4125
|
+
} else if (t > 0) {
|
|
4126
|
+
ax += dx * t;
|
|
4127
|
+
ay += dy * t;
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
dx = x - ax;
|
|
4131
|
+
dy = y - ay;
|
|
4132
|
+
return dx * dx + dy * dy;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
// src/tileset-build/tiler.ts
|
|
4136
|
+
var maximumTileBytes = 6e6;
|
|
4137
|
+
var targetCompressedBytes = 500 * 1024;
|
|
4138
|
+
var maximumBufferedBytes = 64 * 1024 * 1024;
|
|
4139
|
+
var maximumAttempts = 7;
|
|
4140
|
+
var tileCellsPerAxis = 512;
|
|
4141
|
+
var bufferCells = tileBufferUnits * tileCellsPerAxis / 4096;
|
|
4142
|
+
var selectionCellsPerAxis = tileCellsPerAxis + bufferCells * 2;
|
|
4143
|
+
var pointsPerCell = 1;
|
|
4144
|
+
var minimumFeaturesToThinPoints = 2048;
|
|
4145
|
+
var simplifyTolerance = 4;
|
|
4146
|
+
var lastZoomTolerance = 1;
|
|
4147
|
+
var dotSpan = 8;
|
|
4148
|
+
var minimumExteriorArea = 8;
|
|
4149
|
+
var yieldEveryTiles = 256;
|
|
4150
|
+
async function buildPyramid(root, options) {
|
|
4151
|
+
const statistics = [];
|
|
4152
|
+
for (let zoom = 0; zoom <= options.maxzoom; zoom += 1) {
|
|
4153
|
+
statistics.push({ features: 0, largestTileBytes: 0, thinnedFeatures: 0, tiles: 0, zoom });
|
|
4154
|
+
}
|
|
4155
|
+
const pyramid = new Pyramid(options, statistics);
|
|
4156
|
+
await pyramid.visit(0, 0, 0, root);
|
|
4157
|
+
return statistics;
|
|
4158
|
+
}
|
|
4159
|
+
var Pyramid = class {
|
|
4160
|
+
constructor(options, statistics) {
|
|
4161
|
+
this.options = options;
|
|
4162
|
+
this.statistics = statistics;
|
|
4163
|
+
this.#encoder = new TileEncoder(options.layer, options.keys);
|
|
4164
|
+
}
|
|
4165
|
+
options;
|
|
4166
|
+
statistics;
|
|
4167
|
+
#encoder;
|
|
4168
|
+
#cellCounts = new Uint32Array(selectionCellsPerAxis ** 2);
|
|
4169
|
+
#cellSeen = new Uint32Array(selectionCellsPerAxis ** 2);
|
|
4170
|
+
/** Which records of the tile just encoded were drawn; read while clipping its children. */
|
|
4171
|
+
#drawn = new Uint8Array(0);
|
|
4172
|
+
#visited = 0;
|
|
4173
|
+
async visit(z, x, y, spool) {
|
|
4174
|
+
const { archive, maxzoom, minzoom } = this.options;
|
|
4175
|
+
let content = -1;
|
|
4176
|
+
if (z >= minzoom) {
|
|
4177
|
+
const tile = this.#encodeTile(z, x, y, spool);
|
|
4178
|
+
if (tile !== null) content = await archive.addTile(z, x, y, tile);
|
|
4179
|
+
}
|
|
4180
|
+
this.#visited += 1;
|
|
4181
|
+
if (this.#visited % yieldEveryTiles === 0) {
|
|
4182
|
+
this.options.onTile?.(archive.tileCount);
|
|
4183
|
+
await new Promise((resolve2) => setImmediate(resolve2));
|
|
4184
|
+
}
|
|
4185
|
+
if (z === maxzoom) {
|
|
4186
|
+
spool.dispose();
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
if (content >= 0 && isSolidTile(spool, tileBox(z, x, y))) {
|
|
4190
|
+
for (let depth = 1; depth <= maxzoom - z; depth += 1) {
|
|
4191
|
+
archive.addSubtreeLevel(z, x, y, depth, content);
|
|
4192
|
+
}
|
|
4193
|
+
spool.dispose();
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
const children = [];
|
|
4197
|
+
const boxes = [];
|
|
4198
|
+
for (let index = 0; index < 4; index += 1) {
|
|
4199
|
+
children.push(new FeatureSpool(this.options.directory));
|
|
4200
|
+
boxes.push(tileBox(z + 1, x * 2 + (index & 1), y * 2 + (index >> 1)));
|
|
4201
|
+
}
|
|
4202
|
+
const encoded = z >= minzoom;
|
|
4203
|
+
let record = 0;
|
|
4204
|
+
spool.forEach((words, offset) => {
|
|
4205
|
+
words[offset + field.kept] = encoded ? this.#drawn[record] : 0;
|
|
4206
|
+
record += 1;
|
|
4207
|
+
for (let index = 0; index < 4; index += 1) {
|
|
4208
|
+
clipRecordInto(words, offset, boxes[index], children[index]);
|
|
4209
|
+
}
|
|
4210
|
+
});
|
|
4211
|
+
spool.dispose();
|
|
4212
|
+
for (let index = 0; index < 4; index += 1) {
|
|
4213
|
+
const child = children[index];
|
|
4214
|
+
if (child.count > 0) {
|
|
4215
|
+
await this.visit(z + 1, x * 2 + (index & 1), y * 2 + (index >> 1), child);
|
|
4216
|
+
} else {
|
|
4217
|
+
child.dispose();
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
#encodeTile(z, x, y, spool) {
|
|
4222
|
+
const { maxzoom } = this.options;
|
|
4223
|
+
const encoder = this.#encoder;
|
|
4224
|
+
const lastZoom = z === maxzoom;
|
|
4225
|
+
const stats = this.statistics[z];
|
|
4226
|
+
const encodeOptions = lastZoom ? { keepCollapsedLines: true, minimumArea: 0, tolerance: lastZoomTolerance } : {
|
|
4227
|
+
keepCollapsedLines: false,
|
|
4228
|
+
minimumArea: minimumExteriorArea,
|
|
4229
|
+
tolerance: simplifyTolerance
|
|
4230
|
+
};
|
|
4231
|
+
const pointCap = !lastZoom && spool.count > minimumFeaturesToThinPoints ? pointsPerCell : Infinity;
|
|
4232
|
+
const unit = 2 ** (31 - z) / tileCellsPerAxis;
|
|
4233
|
+
const originX = x * 2 ** (31 - z) - bufferCells * unit;
|
|
4234
|
+
const originY = y * 2 ** (31 - z) - bufferCells * unit;
|
|
4235
|
+
const dotWorldSpan = dotSpan * 2 ** (19 - z);
|
|
4236
|
+
let featureLimit = Infinity;
|
|
4237
|
+
let honorParent = true;
|
|
4238
|
+
if (this.#drawn.length < spool.count) this.#drawn = new Uint8Array(spool.count * 2);
|
|
4239
|
+
const drawn = this.#drawn;
|
|
4240
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
4241
|
+
drawn.fill(0, 0, spool.count);
|
|
4242
|
+
const quota = featureLimit === Infinity ? Infinity : this.#quotaFor(featureLimit);
|
|
4243
|
+
const cellShare = quota === Infinity ? 1 : this.#cellShareFor(quota, featureLimit);
|
|
4244
|
+
const trackCells = quota !== Infinity || pointCap !== Infinity;
|
|
4245
|
+
if (trackCells) this.#cellSeen.fill(0);
|
|
4246
|
+
encoder.begin(z, x, y, encodeOptions);
|
|
4247
|
+
let thinned = 0;
|
|
4248
|
+
let abandoned = false;
|
|
4249
|
+
let record = -1;
|
|
4250
|
+
let optional = 0;
|
|
4251
|
+
spool.forEach((words, offset) => {
|
|
4252
|
+
record += 1;
|
|
4253
|
+
if (abandoned) return;
|
|
4254
|
+
const required = honorParent && words[offset + field.kept] === 1;
|
|
4255
|
+
if (trackCells) {
|
|
4256
|
+
const cell = selectionCell(words, offset, originX, originY, unit);
|
|
4257
|
+
const cap = isDot(words, offset, dotWorldSpan) ? Math.min(pointCap, quota) : quota;
|
|
4258
|
+
if (!required && (this.#cellSeen[cell] >= cap || cellRank(cell) >= cellShare)) {
|
|
4259
|
+
thinned += 1;
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
this.#cellSeen[cell] += 1;
|
|
4263
|
+
}
|
|
4264
|
+
if (encoder.add(words, offset)) {
|
|
4265
|
+
drawn[record] = 1;
|
|
4266
|
+
if (!required) optional += 1;
|
|
4267
|
+
if (encoder.approximateBytes <= maximumBufferedBytes) {
|
|
4268
|
+
if (!lastZoom || encoder.approximateBytes <= maximumTileBytes) return;
|
|
4269
|
+
}
|
|
4270
|
+
if (lastZoom) throw tooLarge(z, x, y);
|
|
4271
|
+
abandoned = true;
|
|
4272
|
+
} else {
|
|
4273
|
+
thinned += 1;
|
|
4274
|
+
}
|
|
4275
|
+
});
|
|
4276
|
+
if (encoder.featureCount === 0) return null;
|
|
4277
|
+
const tile = encoder.finish();
|
|
4278
|
+
const size = lastZoom ? tile.length : measuredSize(tile);
|
|
4279
|
+
const decodable = !abandoned && tile.length <= maximumTileBytes;
|
|
4280
|
+
const settled = optional === 0 || attempt === maximumAttempts;
|
|
4281
|
+
if (lastZoom || decodable && (size <= targetCompressedBytes || settled)) {
|
|
4282
|
+
stats.tiles += 1;
|
|
4283
|
+
stats.features += encoder.featureCount;
|
|
4284
|
+
stats.thinnedFeatures += thinned;
|
|
4285
|
+
if (tile.length > stats.largestTileBytes) stats.largestTileBytes = tile.length;
|
|
4286
|
+
return tile;
|
|
4287
|
+
}
|
|
4288
|
+
if (attempt === maximumAttempts) throw tooLarge(z, x, y);
|
|
4289
|
+
if (optional === 0) honorParent = false;
|
|
4290
|
+
if (featureLimit === Infinity) {
|
|
4291
|
+
this.#countCells(spool, originX, originY, unit, pointCap, dotWorldSpan);
|
|
4292
|
+
}
|
|
4293
|
+
const ratio = Math.min(targetCompressedBytes / size, maximumTileBytes / tile.length);
|
|
4294
|
+
featureLimit = Math.max(1, Math.floor(encoder.featureCount * ratio * 0.9));
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
#countCells(spool, originX, originY, unit, pointCap, dotWorldSpan) {
|
|
4298
|
+
this.#cellCounts.fill(0);
|
|
4299
|
+
spool.forEach((words, offset) => {
|
|
4300
|
+
const cell = selectionCell(words, offset, originX, originY, unit);
|
|
4301
|
+
if (!isDot(words, offset, dotWorldSpan) || this.#cellCounts[cell] < pointCap) {
|
|
4302
|
+
this.#cellCounts[cell] += 1;
|
|
4303
|
+
}
|
|
4304
|
+
});
|
|
4305
|
+
}
|
|
4306
|
+
/** When one feature per cell is still too many, the share of cells that may keep theirs. */
|
|
4307
|
+
#cellShareFor(quota, limit2) {
|
|
4308
|
+
if (quota > 1) return 1;
|
|
4309
|
+
let occupied = 0;
|
|
4310
|
+
for (const count of this.#cellCounts) if (count > 0) occupied += 1;
|
|
4311
|
+
return Math.min(1, limit2 / occupied);
|
|
4312
|
+
}
|
|
4313
|
+
/** The largest per-cell allowance that fits the limit, which thins dense areas first. */
|
|
4314
|
+
#quotaFor(limit2) {
|
|
4315
|
+
let low = 1;
|
|
4316
|
+
let high = 1;
|
|
4317
|
+
for (const count of this.#cellCounts) if (count > high) high = count;
|
|
4318
|
+
while (low < high) {
|
|
4319
|
+
const candidate = Math.ceil((low + high) / 2);
|
|
4320
|
+
let total = 0;
|
|
4321
|
+
for (const count of this.#cellCounts) total += Math.min(count, candidate);
|
|
4322
|
+
if (total <= limit2) low = candidate;
|
|
4323
|
+
else high = candidate - 1;
|
|
4324
|
+
}
|
|
4325
|
+
return low;
|
|
4326
|
+
}
|
|
4327
|
+
};
|
|
4328
|
+
function tileBox(z, x, y) {
|
|
4329
|
+
const size = 2 ** (31 - z);
|
|
4330
|
+
const buffer = tileBufferUnits * 2 ** (19 - z);
|
|
4331
|
+
return {
|
|
4332
|
+
maxX: (x + 1) * size + buffer,
|
|
4333
|
+
maxY: (y + 1) * size + buffer,
|
|
4334
|
+
minX: x * size - buffer,
|
|
4335
|
+
minY: y * size - buffer
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
4338
|
+
function selectionCell(words, offset, originX, originY, unit) {
|
|
4339
|
+
const centerX = (words[offset + field.minX] + words[offset + field.maxX]) / 2;
|
|
4340
|
+
const centerY = (words[offset + field.minY] + words[offset + field.maxY]) / 2;
|
|
4341
|
+
const limit2 = selectionCellsPerAxis - 1;
|
|
4342
|
+
const cellX = Math.max(0, Math.min(limit2, Math.floor((centerX - originX) / unit)));
|
|
4343
|
+
const cellY = Math.max(0, Math.min(limit2, Math.floor((centerY - originY) / unit)));
|
|
4344
|
+
return cellY * selectionCellsPerAxis + cellX;
|
|
4345
|
+
}
|
|
4346
|
+
function isDot(words, offset, span) {
|
|
4347
|
+
const type = words[offset + field.type];
|
|
4348
|
+
if (type === geometryType.point) return true;
|
|
4349
|
+
return type === geometryType.polygon && words[offset + field.maxX] - words[offset + field.minX] < span && words[offset + field.maxY] - words[offset + field.minY] < span;
|
|
4350
|
+
}
|
|
4351
|
+
function cellRank(cell) {
|
|
4352
|
+
return (Math.imul(cell + 1, 2654435761) >>> 0) / 2 ** 32;
|
|
4353
|
+
}
|
|
4354
|
+
function isSolidTile(spool, box) {
|
|
4355
|
+
if (spool.count !== 1) return false;
|
|
4356
|
+
let solid = false;
|
|
4357
|
+
spool.forEach((words, offset) => {
|
|
4358
|
+
if (words[offset + field.type] !== geometryType.polygon || words[offset + field.parts] !== 1 || words[offset + field.minX] !== box.minX || words[offset + field.minY] !== box.minY || words[offset + field.maxX] !== box.maxX || words[offset + field.maxY] !== box.maxY) {
|
|
4359
|
+
return;
|
|
4360
|
+
}
|
|
4361
|
+
const start = offset + headerWords + 1;
|
|
4362
|
+
const end = start + words[offset + field.points] * 2;
|
|
4363
|
+
let previousX = words[end - 2];
|
|
4364
|
+
let previousY = words[end - 1];
|
|
4365
|
+
let leftEdgeTravel = 0;
|
|
4366
|
+
for (let cursor = start; cursor < end; cursor += 2) {
|
|
4367
|
+
const x = words[cursor];
|
|
4368
|
+
const y = words[cursor + 1];
|
|
4369
|
+
if (previousX === x && (x === box.minX || x === box.maxX)) {
|
|
4370
|
+
if (x === box.minX) leftEdgeTravel += y - previousY;
|
|
4371
|
+
} else if (previousY !== y || y !== box.minY && y !== box.maxY) {
|
|
4372
|
+
return;
|
|
4373
|
+
}
|
|
4374
|
+
previousX = x;
|
|
4375
|
+
previousY = y;
|
|
4376
|
+
}
|
|
4377
|
+
solid = Math.abs(leftEdgeTravel) === box.maxY - box.minY;
|
|
4378
|
+
});
|
|
4379
|
+
return solid;
|
|
4380
|
+
}
|
|
4381
|
+
function measuredSize(tile) {
|
|
4382
|
+
return tile.length <= targetCompressedBytes ? tile.length : compressedSizeForBudget(tile);
|
|
4383
|
+
}
|
|
4384
|
+
function tooLarge(z, x, y) {
|
|
4385
|
+
return new TilesetBuildError(
|
|
4386
|
+
"TF_TILESET_BUILD_TILE_TOO_LARGE",
|
|
4387
|
+
"zoom",
|
|
4388
|
+
`Tile ${z}/${x}/${y} cannot fit in ${maximumTileBytes} bytes without losing features. Raise --maxzoom, or remove attributes with --include or --exclude.`
|
|
4389
|
+
);
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
// src/tileset-build/zoom.ts
|
|
4393
|
+
var maximumAutoZoom = 14;
|
|
4394
|
+
var pointMinimumZoom = 10;
|
|
4395
|
+
var shapeMinimumZoom = 4;
|
|
4396
|
+
var detailBits = 12;
|
|
4397
|
+
var retainedFraction = 0.9;
|
|
4398
|
+
var maximumVerticesPerTile = 5e4;
|
|
4399
|
+
function chooseMaxzoom(ingest, summary) {
|
|
4400
|
+
const count = ingest.sampleCount;
|
|
4401
|
+
const sample = ingest.sample;
|
|
4402
|
+
const floor = summary.geometryTypes.every((type) => type === "Point") ? pointMinimumZoom : shapeMinimumZoom;
|
|
4403
|
+
const fullResolution = distinctCells(sample, count, maximumAutoZoom + detailBits);
|
|
4404
|
+
let resolved = maximumAutoZoom;
|
|
4405
|
+
for (let zoom = 0; zoom < maximumAutoZoom; zoom += 1) {
|
|
4406
|
+
if (distinctCells(sample, count, zoom + detailBits) >= fullResolution * retainedFraction) {
|
|
4407
|
+
resolved = zoom;
|
|
4408
|
+
break;
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
let density = maximumAutoZoom;
|
|
4412
|
+
for (let zoom = 0; zoom <= maximumAutoZoom; zoom += 1) {
|
|
4413
|
+
if (densestCell(sample, count, zoom) * ingest.sampleStride <= maximumVerticesPerTile) {
|
|
4414
|
+
density = zoom;
|
|
4415
|
+
break;
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
return Math.max(floor, resolved, density);
|
|
4419
|
+
}
|
|
4420
|
+
function cellKeys(sample, count, level) {
|
|
4421
|
+
const shift = 2 ** (31 - level);
|
|
4422
|
+
const keys = new Float64Array(count);
|
|
4423
|
+
for (let index = 0; index < count; index += 1) {
|
|
4424
|
+
const x = Math.floor(sample[index * 2] / shift);
|
|
4425
|
+
const y = Math.floor(sample[index * 2 + 1] / shift);
|
|
4426
|
+
keys[index] = x * 2 ** level + y;
|
|
4427
|
+
}
|
|
4428
|
+
return keys.sort();
|
|
4429
|
+
}
|
|
4430
|
+
function distinctCells(sample, count, level) {
|
|
4431
|
+
const keys = cellKeys(sample, count, Math.min(level, 26));
|
|
4432
|
+
let distinct = 0;
|
|
4433
|
+
for (let index = 0; index < count; index += 1) {
|
|
4434
|
+
if (index === 0 || keys[index] !== keys[index - 1]) distinct += 1;
|
|
4435
|
+
}
|
|
4436
|
+
return distinct;
|
|
4437
|
+
}
|
|
4438
|
+
function densestCell(sample, count, level) {
|
|
4439
|
+
const keys = cellKeys(sample, count, level);
|
|
4440
|
+
let densest = 0;
|
|
4441
|
+
let run = 0;
|
|
4442
|
+
for (let index = 0; index < count; index += 1) {
|
|
4443
|
+
run = index > 0 && keys[index] === keys[index - 1] ? run + 1 : 1;
|
|
4444
|
+
if (run > densest) densest = run;
|
|
4445
|
+
}
|
|
4446
|
+
return densest;
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
// src/tileset-build/build.ts
|
|
4450
|
+
var minimumFreeBytes = 64 * 1024 * 1024;
|
|
4451
|
+
var readingProgressMilliseconds = 5e3;
|
|
4452
|
+
var extractedExtensions = /* @__PURE__ */ new Set([".cpg", ".dbf", ".prj", ".shp"]);
|
|
4453
|
+
var maximumTileStatisticsCharacters = 128 * 1024;
|
|
4454
|
+
var maximumArchiveBytes = 8e9;
|
|
4455
|
+
var nullIslandDegrees = 1;
|
|
4456
|
+
async function buildTileset(options) {
|
|
4457
|
+
const input = resolve(options.input);
|
|
4458
|
+
const format = options.format ?? detectFormat(input);
|
|
4459
|
+
const inputStat = statSync2(input, { throwIfNoEntry: false });
|
|
4460
|
+
if (!inputStat?.isFile()) {
|
|
4461
|
+
throw new TilesetBuildError(
|
|
4462
|
+
"TF_TILESET_BUILD_INPUT_MISSING",
|
|
4463
|
+
"input",
|
|
4464
|
+
`Input file not found: ${options.input}.`
|
|
4465
|
+
);
|
|
4466
|
+
}
|
|
4467
|
+
const zipped = isZip(input);
|
|
4468
|
+
const kmz = isKmz(input);
|
|
4469
|
+
checkContainerFormat(format, zipped, kmz);
|
|
4470
|
+
if (!zipped && !kmz && options.zipEntry !== void 0) {
|
|
4471
|
+
throw new TilesetBuildError(
|
|
4472
|
+
"TF_TILESET_BUILD_ZIP_ENTRY_INVALID",
|
|
4473
|
+
"zipEntry",
|
|
4474
|
+
"--zip-entry only applies to a .zip or .kmz input."
|
|
4475
|
+
);
|
|
4476
|
+
}
|
|
4477
|
+
if (format !== "geopackage" && options.inputLayer !== void 0) {
|
|
4478
|
+
throw new TilesetBuildError(
|
|
4479
|
+
"TF_TILESET_BUILD_INPUT_LAYER_INVALID",
|
|
4480
|
+
"inputLayer",
|
|
4481
|
+
"--input-layer only applies to a GeoPackage input."
|
|
4482
|
+
);
|
|
4483
|
+
}
|
|
4484
|
+
if (options.rankAscending && options.rankBy === void 0) {
|
|
4485
|
+
throw new TilesetBuildError(
|
|
4486
|
+
"TF_TILESET_BUILD_RANK_INVALID",
|
|
4487
|
+
"rankBy",
|
|
4488
|
+
"--rank-ascending needs --rank-by."
|
|
4489
|
+
);
|
|
4490
|
+
}
|
|
4491
|
+
const outputOption = options.output ?? join4(dirname2(options.input), `${inputStem(input)}.pmtiles`);
|
|
4492
|
+
const output = resolve(outputOption);
|
|
4493
|
+
if (output === input) {
|
|
4494
|
+
throw new TilesetBuildError(
|
|
4495
|
+
"TF_TILESET_BUILD_OUTPUT_INVALID",
|
|
4496
|
+
"output",
|
|
4497
|
+
"The output path must differ from the input."
|
|
4498
|
+
);
|
|
4499
|
+
}
|
|
4500
|
+
if (existsSync2(output) && !options.force) {
|
|
4501
|
+
throw new TilesetBuildError(
|
|
4502
|
+
"TF_TILESET_BUILD_OUTPUT_EXISTS",
|
|
4503
|
+
"output",
|
|
4504
|
+
`${outputOption} already exists. Pass --force to replace it.`
|
|
4505
|
+
);
|
|
4506
|
+
}
|
|
4507
|
+
const layer = chooseLayer2(options.layer, options.inputLayer, input);
|
|
4508
|
+
const zoomRange = checkZooms(options);
|
|
4509
|
+
const outputDirectory = dirname2(output);
|
|
4510
|
+
checkFreeSpace(outputDirectory, zipped ? 0 : inputStat.size);
|
|
4511
|
+
checkInputSignature(input, format, zipped ? "zip" : kmz ? "kmz" : null);
|
|
4512
|
+
const zip = zipped ? await ZippedShapefile.open(input, options.zipEntry) : null;
|
|
4513
|
+
const zippedKml = kmz ? await ZippedKml.open(input, options.zipEntry) : null;
|
|
4514
|
+
checkExtractedSize(zip?.uncompressedBytes ?? zippedKml?.uncompressedBytes, options);
|
|
4515
|
+
const geopackage = format === "geopackage" ? GeoPackageLayer.open(input, options.inputLayer) : null;
|
|
4516
|
+
const temporary = mkdtempSync(join4(outputDirectory, ".tileflow-tileset-build-"));
|
|
4517
|
+
const removeTemporary = () => rmSync(temporary, { force: true, recursive: true });
|
|
4518
|
+
const interrupt = () => {
|
|
4519
|
+
removeTemporary();
|
|
4520
|
+
process.exit(130);
|
|
4521
|
+
};
|
|
4522
|
+
process.once("SIGINT", interrupt);
|
|
4523
|
+
process.once("SIGTERM", interrupt);
|
|
4524
|
+
let archive = null;
|
|
4525
|
+
try {
|
|
4526
|
+
if (zip) checkFreeSpace(outputDirectory, zip.uncompressedBytes, zip.uncompressedBytes);
|
|
4527
|
+
if (zippedKml) checkFreeSpace(outputDirectory, zippedKml.uncompressedBytes);
|
|
4528
|
+
const shapefile = zip ? await zip.extractProjection(temporary) : input;
|
|
4529
|
+
const statement = await chooseCrsStatement(format, shapefile, options, geopackage);
|
|
4530
|
+
const crs = resolveCrs(statement, options.reproject === true);
|
|
4531
|
+
if (zip) await zip.extractData(temporary);
|
|
4532
|
+
const directory = new SpoolDirectory(temporary);
|
|
4533
|
+
const spool = new FeatureSpool(directory);
|
|
4534
|
+
const ingest = new FeatureIngest(
|
|
4535
|
+
spool,
|
|
4536
|
+
{
|
|
4537
|
+
exclude: options.exclude ? new Set(options.exclude) : null,
|
|
4538
|
+
include: options.include ? new Set(options.include) : null
|
|
4539
|
+
},
|
|
4540
|
+
crs.transform,
|
|
4541
|
+
options.rankBy === void 0 ? null : { ascending: options.rankAscending === true, field: options.rankBy },
|
|
4542
|
+
options.featureId ?? null
|
|
4543
|
+
);
|
|
4544
|
+
if (crs.warning) ingest.warn(crs.warning);
|
|
4545
|
+
async function readFeatures() {
|
|
4546
|
+
switch (format) {
|
|
4547
|
+
case "csv":
|
|
4548
|
+
await readCsv(input, ingest, options);
|
|
4549
|
+
break;
|
|
4550
|
+
case "geojson":
|
|
4551
|
+
await readGeojson(input, ingest, (member) => checkDocumentCrs(member, options));
|
|
4552
|
+
break;
|
|
4553
|
+
case "geojsonseq":
|
|
4554
|
+
await readGeojsonSequence(input, ingest);
|
|
4555
|
+
break;
|
|
4556
|
+
case "geopackage":
|
|
4557
|
+
await geopackage.read(ingest, options.sourceCrs === void 0);
|
|
4558
|
+
break;
|
|
4559
|
+
case "gpx":
|
|
4560
|
+
await readGpx(fileChunks(input), ingest);
|
|
4561
|
+
break;
|
|
4562
|
+
case "kml":
|
|
4563
|
+
await readKml(zippedKml ? zippedKml.chunks() : fileChunks(input), ingest);
|
|
4564
|
+
break;
|
|
4565
|
+
case "shapefile":
|
|
4566
|
+
await readShapefile(shapefile, ingest);
|
|
4567
|
+
break;
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
const sourceSha256 = await hashSource(input, format, zipped);
|
|
4571
|
+
options.onProgress?.({ message: "Reading features.", stage: "reading" });
|
|
4572
|
+
const readingProgress = setInterval(() => {
|
|
4573
|
+
const features = ingest.featureCount;
|
|
4574
|
+
options.onProgress?.({ features, message: `Read ${features} features.`, stage: "reading" });
|
|
4575
|
+
}, readingProgressMilliseconds);
|
|
4576
|
+
try {
|
|
4577
|
+
await readFeatures();
|
|
4578
|
+
} finally {
|
|
4579
|
+
clearInterval(readingProgress);
|
|
4580
|
+
}
|
|
4581
|
+
if (options.deleteInputAfterRead) {
|
|
4582
|
+
rmSync(input, { force: true });
|
|
4583
|
+
for (const name2 of zip ? readdirSync2(temporary) : []) {
|
|
4584
|
+
if (extractedExtensions.has(extname3(name2).toLowerCase())) {
|
|
4585
|
+
rmSync(join4(temporary, name2), { force: true });
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
const summary = ingest.finish();
|
|
4590
|
+
const maxzoom = zoomRange.maxzoom ?? Math.max(chooseMaxzoom(ingest, summary), zoomRange.minzoom);
|
|
4591
|
+
const fieldMetadata = Object.fromEntries(summary.fields.map((item) => [item.name, item.type]));
|
|
4592
|
+
options.onProgress?.({
|
|
4593
|
+
features: summary.features,
|
|
4594
|
+
message: `Tiling ${summary.features} features through zoom ${maxzoom}.`,
|
|
4595
|
+
stage: "tiling"
|
|
4596
|
+
});
|
|
4597
|
+
archive = new TileArchiveWriter(join4(temporary, "tiles.bin"));
|
|
4598
|
+
const root = ingest.rankedSpool(spool);
|
|
4599
|
+
const zooms = await buildPyramid(root, {
|
|
4600
|
+
archive,
|
|
4601
|
+
directory,
|
|
4602
|
+
keys: ingest.keyNames(summary),
|
|
4603
|
+
layer,
|
|
4604
|
+
maxzoom,
|
|
4605
|
+
minzoom: zoomRange.minzoom,
|
|
4606
|
+
onTile: (count) => {
|
|
4607
|
+
if (count % 65536 < 256) {
|
|
4608
|
+
options.onProgress?.({ message: `Wrote ${count} tiles.`, stage: "tiling", tiles: count });
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
});
|
|
4612
|
+
const pending = join4(temporary, "archive.pmtiles");
|
|
4613
|
+
const bounds = summary.bounds;
|
|
4614
|
+
const name = options.name ?? layer;
|
|
4615
|
+
const recipe = recipeFromSettings({ ...options, maxzoom, minzoom: zoomRange.minzoom }, layer);
|
|
4616
|
+
options.onProgress?.({ message: "Writing the archive.", stage: "archiving" });
|
|
4617
|
+
const totals = await archive.finish(
|
|
4618
|
+
pending,
|
|
4619
|
+
{
|
|
4620
|
+
bounds,
|
|
4621
|
+
center: [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2, zoomRange.minzoom],
|
|
4622
|
+
maxzoom,
|
|
4623
|
+
minzoom: zoomRange.minzoom
|
|
4624
|
+
},
|
|
4625
|
+
serializeCanonicalJson({
|
|
4626
|
+
...options.attribution ? { attribution: options.attribution } : {},
|
|
4627
|
+
...options.description ? { description: options.description } : {},
|
|
4628
|
+
format: "pbf",
|
|
4629
|
+
generator: "tileflow tileset build",
|
|
4630
|
+
generator_version: getCliVersion(),
|
|
4631
|
+
name,
|
|
4632
|
+
tileflow_recipe_hash: recipeHash(recipe),
|
|
4633
|
+
tileflow_source_hash: `sha256:${sourceSha256}`,
|
|
4634
|
+
tilestats: tileStatistics(layer, summary),
|
|
4635
|
+
type: "overlay",
|
|
4636
|
+
vector_layers: [{ fields: fieldMetadata, id: layer, maxzoom, minzoom: zoomRange.minzoom }],
|
|
4637
|
+
version: "1"
|
|
4638
|
+
})
|
|
4639
|
+
);
|
|
4640
|
+
if (totals.byteCount > maximumArchiveBytes) {
|
|
4641
|
+
throw new TilesetBuildError(
|
|
4642
|
+
"TF_TILESET_BUILD_ARCHIVE_TOO_LARGE",
|
|
4643
|
+
"output",
|
|
4644
|
+
`The archive is ${formatBytes(totals.byteCount)}; publishable archives are limited to ${formatBytes(maximumArchiveBytes)}. Lower --maxzoom, or remove attributes with --include or --exclude.`
|
|
4645
|
+
);
|
|
4646
|
+
}
|
|
4647
|
+
options.onProgress?.({ message: "Validating the archive.", stage: "validating" });
|
|
4648
|
+
const inspection = await inspectTileflowPmtiles(pending, { sample: true });
|
|
4649
|
+
await rename(pending, output);
|
|
4650
|
+
return {
|
|
4651
|
+
archive: {
|
|
4652
|
+
addressedTiles: totals.addressedTiles,
|
|
4653
|
+
bounds,
|
|
4654
|
+
byteCount: totals.byteCount,
|
|
4655
|
+
maxzoom,
|
|
4656
|
+
maxzoomSource: zoomRange.maxzoom === void 0 ? "auto" : "option",
|
|
4657
|
+
minzoom: zoomRange.minzoom,
|
|
4658
|
+
path: outputOption,
|
|
4659
|
+
tileContents: totals.tileContents
|
|
4660
|
+
},
|
|
4661
|
+
command: "tileset build",
|
|
4662
|
+
crs: { authority: crs.authority, label: crs.label, reprojected: crs.reprojected },
|
|
4663
|
+
input: {
|
|
4664
|
+
features: summary.features,
|
|
4665
|
+
format,
|
|
4666
|
+
geometryTypes: summary.geometryTypes,
|
|
4667
|
+
skipped: summary.skipped
|
|
4668
|
+
},
|
|
4669
|
+
layer: { fields: summary.fields, id: layer },
|
|
4670
|
+
recipe,
|
|
4671
|
+
schemaVersion: 1,
|
|
4672
|
+
source: { sha256: sourceSha256 },
|
|
4673
|
+
warnings: [
|
|
4674
|
+
...summary.warnings,
|
|
4675
|
+
...placementWarnings(bounds, crs.label, options.sourceCrs !== void 0 || crs.reprojected),
|
|
4676
|
+
...inspection.warnings.map((warning) => warning.message)
|
|
4677
|
+
],
|
|
4678
|
+
zooms: zooms.filter((item) => item.zoom >= zoomRange.minzoom)
|
|
4679
|
+
};
|
|
4680
|
+
} catch (error) {
|
|
4681
|
+
if (isNoSpace(error)) {
|
|
4682
|
+
throw new TilesetBuildError(
|
|
4683
|
+
"TF_TILESET_BUILD_DISK_FULL",
|
|
4684
|
+
"output",
|
|
4685
|
+
"The disk ran out of space during the build. Free space beside the output, or write the output to another disk."
|
|
4686
|
+
);
|
|
4687
|
+
}
|
|
4688
|
+
throw error;
|
|
4689
|
+
} finally {
|
|
4690
|
+
archive?.close();
|
|
4691
|
+
zip?.close();
|
|
4692
|
+
zippedKml?.close();
|
|
4693
|
+
geopackage?.close();
|
|
4694
|
+
process.removeListener("SIGINT", interrupt);
|
|
4695
|
+
process.removeListener("SIGTERM", interrupt);
|
|
4696
|
+
removeTemporary();
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
function tileStatistics(layer, summary) {
|
|
4700
|
+
let budget = maximumTileStatisticsCharacters;
|
|
4701
|
+
const attributes = summary.fields.map((field2) => {
|
|
4702
|
+
const size = field2.values.reduce((total, value) => total + String(value).length, 0);
|
|
4703
|
+
const values = size <= budget ? field2.values : [];
|
|
4704
|
+
budget -= size <= budget ? size : 0;
|
|
4705
|
+
return {
|
|
4706
|
+
attribute: field2.name,
|
|
4707
|
+
count: values.length,
|
|
4708
|
+
...field2.max === void 0 ? {} : { max: field2.max, min: field2.min },
|
|
4709
|
+
type: field2.type.toLowerCase(),
|
|
4710
|
+
values
|
|
4711
|
+
};
|
|
4712
|
+
});
|
|
4713
|
+
const geometries = summary.geometryTypes;
|
|
4714
|
+
return {
|
|
4715
|
+
layerCount: 1,
|
|
4716
|
+
layers: [
|
|
4717
|
+
{
|
|
4718
|
+
attributeCount: attributes.length,
|
|
4719
|
+
attributes,
|
|
4720
|
+
count: summary.features,
|
|
4721
|
+
geometry: geometries.length === 1 ? geometries[0] : "Unknown",
|
|
4722
|
+
layer
|
|
4723
|
+
}
|
|
4724
|
+
]
|
|
4725
|
+
};
|
|
4726
|
+
}
|
|
4727
|
+
function checkContainerFormat(format, zipped, kmz) {
|
|
4728
|
+
if (zipped && format !== "shapefile") {
|
|
4729
|
+
throw new TilesetBuildError(
|
|
4730
|
+
"TF_TILESET_BUILD_FORMAT_UNSUPPORTED",
|
|
4731
|
+
"format",
|
|
4732
|
+
"Only Shapefiles can be read from a zip; extract other formats first."
|
|
4733
|
+
);
|
|
4734
|
+
}
|
|
4735
|
+
if (kmz && format !== "kml") {
|
|
4736
|
+
throw new TilesetBuildError(
|
|
4737
|
+
"TF_TILESET_BUILD_FORMAT_UNSUPPORTED",
|
|
4738
|
+
"format",
|
|
4739
|
+
"A .kmz file can only be read as KML."
|
|
4740
|
+
);
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
function detectFormat(path) {
|
|
4744
|
+
const extension = extname3(path).toLowerCase();
|
|
4745
|
+
switch (extension) {
|
|
4746
|
+
case ".geojson":
|
|
4747
|
+
case ".json":
|
|
4748
|
+
return "geojson";
|
|
4749
|
+
case ".ndjson":
|
|
4750
|
+
case ".geojsonl":
|
|
4751
|
+
case ".geojsons":
|
|
4752
|
+
case ".geojsonseq":
|
|
4753
|
+
case ".jsonl":
|
|
4754
|
+
return "geojsonseq";
|
|
4755
|
+
case ".csv":
|
|
4756
|
+
case ".tsv":
|
|
4757
|
+
return "csv";
|
|
4758
|
+
case ".shp":
|
|
4759
|
+
case ".zip":
|
|
4760
|
+
return "shapefile";
|
|
4761
|
+
case ".gpkg":
|
|
4762
|
+
return "geopackage";
|
|
4763
|
+
case ".kml":
|
|
4764
|
+
case ".kmz":
|
|
4765
|
+
return "kml";
|
|
4766
|
+
case ".gpx":
|
|
4767
|
+
return "gpx";
|
|
4768
|
+
default:
|
|
4769
|
+
throw new TilesetBuildError(
|
|
4770
|
+
"TF_TILESET_BUILD_FORMAT_UNSUPPORTED",
|
|
4771
|
+
"input",
|
|
4772
|
+
`Unsupported input extension "${extension}". Supported: .geojson, .json, .ndjson, .geojsonl, .csv, .tsv, .shp, a .zip holding a Shapefile, .gpkg, .kml, .kmz and .gpx. Use --format if the extension is misleading.`
|
|
4773
|
+
);
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
async function chooseCrsStatement(format, input, options, geopackage) {
|
|
4777
|
+
if (options.sourceCrs !== void 0) return parseSourceCrsOption(options.sourceCrs);
|
|
4778
|
+
switch (format) {
|
|
4779
|
+
case "shapefile":
|
|
4780
|
+
return shapefileCrsStatement(input);
|
|
4781
|
+
case "geopackage":
|
|
4782
|
+
return geopackage.crsStatement;
|
|
4783
|
+
case "gpx":
|
|
4784
|
+
return wgs84Statement("format", "GPX (WGS84 by definition)");
|
|
4785
|
+
case "kml":
|
|
4786
|
+
return wgs84Statement("format", "KML (WGS84 by definition)");
|
|
4787
|
+
case "geojson":
|
|
4788
|
+
case "geojsonseq":
|
|
4789
|
+
return wgs84Statement("format", "GeoJSON (WGS84 by RFC 7946)");
|
|
4790
|
+
case "csv":
|
|
4791
|
+
return await csvColumnsNameCoordinates(input, options) ? wgs84Statement("format", "CSV longitude and latitude columns") : { authority: "format", definition: null, label: "This CSV" };
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
function checkDocumentCrs(member, options) {
|
|
4795
|
+
if (options.sourceCrs !== void 0 || member === null) return;
|
|
4796
|
+
const resolved = resolveCrs(statementFromGeojsonCrs(member), true);
|
|
4797
|
+
if (resolved.transform === null) return;
|
|
4798
|
+
throw new TilesetBuildError(
|
|
4799
|
+
"TF_TILESET_BUILD_CRS_NOT_WGS84",
|
|
4800
|
+
"crs",
|
|
4801
|
+
`${resolved.label} is not WGS84. Pass --source-crs with the same CRS plus --reproject.`
|
|
4802
|
+
);
|
|
4803
|
+
}
|
|
4804
|
+
function isZip(path) {
|
|
4805
|
+
return extname3(path).toLowerCase() === ".zip";
|
|
4806
|
+
}
|
|
4807
|
+
function isKmz(path) {
|
|
4808
|
+
return extname3(path).toLowerCase() === ".kmz";
|
|
4809
|
+
}
|
|
4810
|
+
function inputStem(path) {
|
|
4811
|
+
const stem = basename3(path, extname3(path));
|
|
4812
|
+
return isZip(path) ? stem.replace(/\.shp$/iu, "") : stem;
|
|
4813
|
+
}
|
|
4814
|
+
function placementWarnings(bounds, label, stated) {
|
|
4815
|
+
const warnings = [];
|
|
4816
|
+
const [west, south, east, north] = bounds.map((value) => value.toFixed(4));
|
|
4817
|
+
if (stated) {
|
|
4818
|
+
warnings.push(
|
|
4819
|
+
`With ${label}, the features cover longitude ${west} to ${east} and latitude ${south} to ${north}. If that is not where the data belongs, the stated CRS is wrong.`
|
|
4820
|
+
);
|
|
4821
|
+
}
|
|
4822
|
+
if (bounds.every((value) => Math.abs(value) <= nullIslandDegrees)) {
|
|
4823
|
+
warnings.push(
|
|
4824
|
+
`Every feature is within ${nullIslandDegrees}\xB0 of longitude 0, latitude 0, in the Gulf of Guinea. Check for a wrong CRS or placeholder zero coordinates.`
|
|
4825
|
+
);
|
|
4826
|
+
}
|
|
4827
|
+
return warnings;
|
|
4828
|
+
}
|
|
4829
|
+
function chooseLayer2(requested, inputLayer, input) {
|
|
4830
|
+
const candidate = requested ?? (inputLayer ?? inputStem(input)).replace(/[^A-Za-z0-9._:-]+/gu, "_").replace(/^[^A-Za-z0-9]+/u, "");
|
|
4831
|
+
if (!layerNamePattern.test(candidate)) {
|
|
4832
|
+
throw new TilesetBuildError(
|
|
4833
|
+
"TF_TILESET_BUILD_LAYER_INVALID",
|
|
4834
|
+
"layer",
|
|
4835
|
+
`Layer name "${candidate}" is invalid. Use letters, digits, dots, underscores, colons and hyphens, starting with a letter or digit.`
|
|
4836
|
+
);
|
|
4837
|
+
}
|
|
4838
|
+
return candidate;
|
|
4839
|
+
}
|
|
4840
|
+
function checkZooms(options) {
|
|
4841
|
+
const minzoom = options.minzoom ?? 0;
|
|
4842
|
+
const { maxzoom } = options;
|
|
4843
|
+
if (!Number.isInteger(minzoom) || minzoom < 0 || minzoom > maximumZoom || maxzoom !== void 0 && (!Number.isInteger(maxzoom) || maxzoom > maximumZoom || maxzoom < minzoom)) {
|
|
4844
|
+
throw new TilesetBuildError(
|
|
4845
|
+
"TF_TILESET_BUILD_ZOOM_INVALID",
|
|
4846
|
+
"zoom",
|
|
4847
|
+
`Zooms must be integers with 0 <= --minzoom <= --maxzoom <= ${maximumZoom}.`
|
|
4848
|
+
);
|
|
4849
|
+
}
|
|
4850
|
+
return { ...maxzoom === void 0 ? {} : { maxzoom }, minzoom };
|
|
4851
|
+
}
|
|
4852
|
+
function checkExtractedSize(uncompressedBytes, options) {
|
|
4853
|
+
const limit2 = options.maximumExtractedBytes;
|
|
4854
|
+
if (uncompressedBytes === void 0 || limit2 === void 0 || uncompressedBytes <= limit2) return;
|
|
4855
|
+
throw new TilesetBuildError(
|
|
4856
|
+
"TF_TILESET_BUILD_INPUT_LIMIT",
|
|
4857
|
+
"input",
|
|
4858
|
+
`The archive expands to ${formatBytes(uncompressedBytes)}; the limit here is ${formatBytes(limit2)}.`
|
|
4859
|
+
);
|
|
4860
|
+
}
|
|
4861
|
+
function checkFreeSpace(directory, inputBytes, extractedBytes = 0) {
|
|
4862
|
+
if (!existsSync2(directory)) {
|
|
4863
|
+
throw new TilesetBuildError(
|
|
4864
|
+
"TF_TILESET_BUILD_OUTPUT_INVALID",
|
|
4865
|
+
"output",
|
|
4866
|
+
"The output directory does not exist."
|
|
4867
|
+
);
|
|
4868
|
+
}
|
|
4869
|
+
const volume = statfsSync(directory);
|
|
4870
|
+
const required = Math.max(minimumFreeBytes, inputBytes * 2) + extractedBytes;
|
|
4871
|
+
const available = Number(volume.bavail) * Number(volume.bsize);
|
|
4872
|
+
if (available < required) {
|
|
4873
|
+
throw new TilesetBuildError(
|
|
4874
|
+
"TF_TILESET_BUILD_DISK_FULL",
|
|
4875
|
+
"output",
|
|
4876
|
+
`The build needs about ${formatBytes(required)} of free space beside the output for temporary files; ${formatBytes(available)} is available.`
|
|
4877
|
+
);
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
function formatBytes(bytes) {
|
|
4881
|
+
return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
|
|
4882
|
+
}
|
|
4883
|
+
function isNoSpace(error) {
|
|
4884
|
+
return error?.code === "ENOSPC";
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
export {
|
|
4888
|
+
SpoolDirectory,
|
|
4889
|
+
FeatureSpool,
|
|
4890
|
+
FeatureIngest,
|
|
4891
|
+
readCsv,
|
|
4892
|
+
csvColumnsNameCoordinates,
|
|
4893
|
+
csvColumnNames,
|
|
4894
|
+
readGeojson,
|
|
4895
|
+
readGeojsonSequence,
|
|
4896
|
+
GeoPackageLayer,
|
|
4897
|
+
fileChunks,
|
|
4898
|
+
readGpx,
|
|
4899
|
+
readKml,
|
|
4900
|
+
readShapefile,
|
|
4901
|
+
ZippedShapefile,
|
|
4902
|
+
ZippedKml,
|
|
4903
|
+
checkInputSignature,
|
|
4904
|
+
buildTileset,
|
|
4905
|
+
checkContainerFormat,
|
|
4906
|
+
detectFormat,
|
|
4907
|
+
chooseCrsStatement,
|
|
4908
|
+
isZip,
|
|
4909
|
+
isKmz,
|
|
4910
|
+
chooseLayer2 as chooseLayer
|
|
4911
|
+
};
|