@unmap/geocoding 0.9.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,866 @@
1
1
  // src/index.ts
2
2
  import { DEFAULT_GATEWAY } from "@unmap/core";
3
+
4
+ // src/legal-land.ts
5
+ var SEP = String.raw`(?:\s*-\s*|\s+)`;
6
+ var SEP_OPT = String.raw`(?:\s*-\s*|\s+)?`;
7
+ var LSD_N = String.raw`(?<lsd>0?[1-9]|1[0-6])`;
8
+ var SEC_N = String.raw`(?<section>(?:0?[1-9]|[1-2][0-9]|3[0-6])a?)`;
9
+ var TWP_N = String.raw`(?<township>(?:(?:00|0)?[1-9]|0?[1-9][0-9]|1[0-2][0-9])a?)`;
10
+ var RGE_N = String.raw`(?<range>(?:0?[1-9]|[1-8][0-9]|9[0-3])a?)`;
11
+ var MER_N = String.raw`(?<meridian>[Ww][1-6]|[Ee][1-2])[Mm]?`;
12
+ var DLS_LSD = new RegExp(`^${LSD_N}${SEP}${SEC_N}${SEP}${TWP_N}${SEP}${RGE_N}${SEP_OPT}${MER_N}$`, "i");
13
+ var DLS_SEC = new RegExp(`^${SEC_N}${SEP}${TWP_N}${SEP}${RGE_N}${SEP_OPT}${MER_N}$`, "i");
14
+ var DLS_TWP = new RegExp(`^${TWP_N}${SEP}${RGE_N}${SEP_OPT}${MER_N}$`, "i");
15
+ var DLS_QTR_MATCH = new RegExp(`^([ns][ew])(1/4)?(?:\\s*-\\s*|\\s+).*$`, "i");
16
+ var DLS_QTR = new RegExp(
17
+ `^(?<quarter>[ns][ew])(?:[\\s-]*1/4)?${SEP_OPT}${SEC_N}${SEP}${TWP_N}${SEP}${RGE_N}${SEP_OPT}${MER_N}$`,
18
+ "i"
19
+ );
20
+ var DLS_HALF_MATCH = new RegExp(`^([nsew])(1/2)?(?:\\s*-\\s*|\\s+).*$`, "i");
21
+ var DLS_HALF = new RegExp(
22
+ `^(?<half>[nsew])(?:[\\s-]*1/2)?${SEP_OPT}${SEC_N}${SEP}${TWP_N}${SEP}${RGE_N}${SEP_OPT}${MER_N}$`,
23
+ "i"
24
+ );
25
+ var NTS = new RegExp(
26
+ `^(?<quarterUnit>[abcd])${SEP_OPT}(?<ntsUnit>(?:00|0)?[1-9]|0?[1-9][0-9]|100)${SEP_OPT}(?<block>[a-l])\\s*/?\\s*(?<series>0?8[2-9]|0?9[0-9]|10[0-9]|11[0-4])${SEP_OPT}(?<mapArea>[a-p])${SEP_OPT}(?<mapSheet>0?[1-9]|1[0-6])$`,
27
+ "i"
28
+ );
29
+ var NTS_SHEET = new RegExp(
30
+ `^(?<series>0?8[2-9]|0?9[0-9]|10[0-9]|11[0-4])(?<mapArea>[a-p])\\s*/?\\s*(?<mapSheet>0?[1-9]|1[0-6])$`,
31
+ "i"
32
+ );
33
+ var FPS_UNIT = /^([a-p])\s+(\d{1,2})\s+(\d{2})-(\d{2})\s+n\s+(\d{1,3})-(\d{1,3})\s+w$/i;
34
+ var FPS_SECTION = /^(\d{1,2})\s+(\d{2})-(\d{2})\s+n\s+(\d{1,3})-(\d{1,3})\s+w$/i;
35
+ var FPS_GRID = /^(\d{2})-(\d{2})\s+n\s+(\d{1,3})-(\d{1,3})\s+w$/i;
36
+ var ON_LOT = /^lot\s+(\d+)(?:\s+(.*))?$/i;
37
+ var ON_CON = /^(?:con|concession)\s+(\d+)\s+([\w ]+)$/i;
38
+ var ON_CON_IN_REST = /^(?:con|concession)\s+(\d+)\s*(.*)$/i;
39
+ var ON_LOT_IN_REST = /^lot\s+(\d+)\s*(.*)$/i;
40
+ var RIVER = /^(pl|rl|wl|lx|sl|ot)(?:[\s-]+[a-z0-9]+)+$/i;
41
+ function sanitize(q) {
42
+ return q.trim().replace(/['"]/g, "").replace(/[;\\]/g, "");
43
+ }
44
+ function insertDirectionSeparator(q) {
45
+ return q.replace(/^([NS][EW]|[NSEW])(?!1\/[24]\b)(\d)/i, "$1-$2");
46
+ }
47
+ function cleanPart(value) {
48
+ return value.replace(/^0+(?=\d)/, "").replace(/\/0+/, "/").toUpperCase();
49
+ }
50
+ function dlsProvince(meridian) {
51
+ const m = meridian.toUpperCase();
52
+ if (m === "W5") return "AB";
53
+ if (m === "W1" || m === "E1" || m === "E2") return "MB";
54
+ return void 0;
55
+ }
56
+ function titleCase(value) {
57
+ return value.replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
58
+ }
59
+ function groups(re, q) {
60
+ const match = re.exec(q);
61
+ if (!match?.groups) return null;
62
+ const out = {};
63
+ for (const [key, value] of Object.entries(match.groups)) {
64
+ if (value === void 0 || value === "") continue;
65
+ out[key] = cleanPart(value);
66
+ }
67
+ return out;
68
+ }
69
+ function dlsFrom(unit, g) {
70
+ const meridian = g.meridian;
71
+ const components = { meridian, range: g.range, township: g.township };
72
+ const pieces = [];
73
+ if (unit === "lsd") {
74
+ components.lsd = g.lsd;
75
+ components.section = g.section;
76
+ pieces.push(g.lsd, g.section);
77
+ } else if (unit === "qtr") {
78
+ components.quarter = g.quarter;
79
+ components.section = g.section;
80
+ pieces.push(g.quarter, g.section);
81
+ } else if (unit === "half") {
82
+ components.half = g.half;
83
+ components.section = g.section;
84
+ pieces.push(g.half, g.section);
85
+ } else if (unit === "sec") {
86
+ components.section = g.section;
87
+ pieces.push(g.section);
88
+ }
89
+ pieces.push(g.township, g.range, meridian);
90
+ const parsed = {
91
+ system: "DLS",
92
+ canonical: pieces.join("-"),
93
+ unit,
94
+ components
95
+ };
96
+ const province = dlsProvince(meridian);
97
+ if (province) parsed.province = province;
98
+ return parsed;
99
+ }
100
+ function parseDls(q) {
101
+ const candidate = insertDirectionSeparator(q);
102
+ const lsd = groups(DLS_LSD, candidate);
103
+ if (lsd) return dlsFrom("lsd", lsd);
104
+ if (DLS_QTR_MATCH.test(candidate)) {
105
+ const qtr = groups(DLS_QTR, candidate);
106
+ if (qtr) return dlsFrom("qtr", qtr);
107
+ }
108
+ if (DLS_HALF_MATCH.test(candidate)) {
109
+ const half = groups(DLS_HALF, candidate);
110
+ if (half) return dlsFrom("half", half);
111
+ }
112
+ const sec = groups(DLS_SEC, candidate);
113
+ if (sec) return dlsFrom("sec", sec);
114
+ const twp = groups(DLS_TWP, candidate);
115
+ if (twp) return dlsFrom("twp", twp);
116
+ return null;
117
+ }
118
+ function parseNts(q) {
119
+ const full = groups(NTS, q);
120
+ if (full) {
121
+ return {
122
+ system: "NTS",
123
+ canonical: `${full.quarterUnit}-${full.ntsUnit}-${full.block}/${full.series}-${full.mapArea}-${full.mapSheet}`,
124
+ unit: "nts",
125
+ components: {
126
+ quarterUnit: full.quarterUnit,
127
+ ntsUnit: full.ntsUnit,
128
+ block: full.block,
129
+ series: full.series,
130
+ mapArea: full.mapArea,
131
+ mapSheet: full.mapSheet
132
+ }
133
+ };
134
+ }
135
+ const sheet = groups(NTS_SHEET, q);
136
+ if (!sheet) return null;
137
+ return {
138
+ system: "NTS",
139
+ canonical: `${sheet.series}${sheet.mapArea}/${sheet.mapSheet}`,
140
+ unit: "nts_sheet",
141
+ components: {
142
+ series: sheet.series,
143
+ mapArea: sheet.mapArea,
144
+ mapSheet: sheet.mapSheet
145
+ }
146
+ };
147
+ }
148
+ function parseFps(q) {
149
+ const unit = FPS_UNIT.exec(q);
150
+ if (unit) {
151
+ const components2 = {
152
+ fpsUnit: unit[1].toUpperCase(),
153
+ fpsSection: unit[2],
154
+ latDeg: unit[3],
155
+ latMin: unit[4],
156
+ lonDeg: unit[5],
157
+ lonMin: unit[6]
158
+ };
159
+ return {
160
+ system: "FPS",
161
+ canonical: `${components2.fpsUnit} ${components2.fpsSection} ${components2.latDeg}-${components2.latMin} N ${components2.lonDeg}-${components2.lonMin} W`,
162
+ unit: "unit",
163
+ components: components2
164
+ };
165
+ }
166
+ const section = FPS_SECTION.exec(q);
167
+ if (section) {
168
+ const components2 = {
169
+ fpsSection: section[1],
170
+ latDeg: section[2],
171
+ latMin: section[3],
172
+ lonDeg: section[4],
173
+ lonMin: section[5]
174
+ };
175
+ return {
176
+ system: "FPS",
177
+ canonical: `${components2.fpsSection} ${components2.latDeg}-${components2.latMin} N ${components2.lonDeg}-${components2.lonMin} W`,
178
+ unit: "section",
179
+ components: components2
180
+ };
181
+ }
182
+ const grid = FPS_GRID.exec(q);
183
+ if (!grid) return null;
184
+ const components = {
185
+ latDeg: grid[1],
186
+ latMin: grid[2],
187
+ lonDeg: grid[3],
188
+ lonMin: grid[4]
189
+ };
190
+ return {
191
+ system: "FPS",
192
+ canonical: `${components.latDeg}-${components.latMin} N ${components.lonDeg}-${components.lonMin} W`,
193
+ unit: "grid",
194
+ components
195
+ };
196
+ }
197
+ function parseGts(q) {
198
+ const lot = ON_LOT.exec(q);
199
+ if (lot) {
200
+ const components2 = { lot: cleanPart(lot[1]) };
201
+ const rest2 = (lot[2] ?? "").trim();
202
+ const con2 = ON_CON_IN_REST.exec(rest2);
203
+ const nameBits = [];
204
+ if (con2) {
205
+ components2.concession = cleanPart(con2[1]);
206
+ const after = (con2[2] ?? "").trim();
207
+ if (after) components2.townshipName = titleCase(after);
208
+ nameBits.push(`Con ${components2.concession}`);
209
+ if (components2.townshipName) nameBits.push(components2.townshipName);
210
+ } else if (rest2) {
211
+ components2.townshipName = titleCase(rest2);
212
+ nameBits.push(components2.townshipName);
213
+ }
214
+ return {
215
+ system: "GTS",
216
+ canonical: [`Lot ${components2.lot}`, ...nameBits].join(" "),
217
+ unit: "lot",
218
+ components: components2,
219
+ province: "ON"
220
+ };
221
+ }
222
+ const con = ON_CON.exec(q);
223
+ if (!con) return null;
224
+ const components = { concession: cleanPart(con[1]) };
225
+ const rest = con[2].trim();
226
+ const nestedLot = ON_LOT_IN_REST.exec(rest);
227
+ if (nestedLot) {
228
+ components.lot = cleanPart(nestedLot[1]);
229
+ const after = (nestedLot[2] ?? "").trim();
230
+ if (after) components.townshipName = titleCase(after);
231
+ } else {
232
+ components.townshipName = titleCase(rest);
233
+ }
234
+ const tail = [
235
+ components.lot ? `Lot ${components.lot}` : "",
236
+ components.townshipName ?? ""
237
+ ].filter(Boolean).join(" ");
238
+ return {
239
+ system: "GTS",
240
+ canonical: [`Con ${components.concession}`, tail].filter(Boolean).join(" "),
241
+ unit: components.lot ? "lot" : "con",
242
+ components,
243
+ province: "ON"
244
+ };
245
+ }
246
+ function parseRiverLot(q) {
247
+ if (!RIVER.test(q)) return null;
248
+ const tokens = q.split(/[\s-]+/).filter(Boolean).map((t) => t.toUpperCase());
249
+ const kind = tokens[0];
250
+ const parts = tokens.slice(1);
251
+ return {
252
+ system: "RIVER_LOT",
253
+ canonical: tokens.join("-"),
254
+ unit: "river_lot",
255
+ components: { kind, parts },
256
+ province: "MB"
257
+ };
258
+ }
259
+ function parseLegalLand(q) {
260
+ if (typeof q !== "string") return null;
261
+ const raw = sanitize(q);
262
+ if (!raw) return null;
263
+ return parseFps(raw) ?? parseGts(raw) ?? parseRiverLot(raw) ?? parseDls(raw) ?? parseNts(raw);
264
+ }
265
+ var LAYER_SYSTEM = {
266
+ DLS: "dls",
267
+ NTS: "nts",
268
+ GTS: "on",
269
+ FPS: "fps",
270
+ RIVER_LOT: "river_lot"
271
+ };
272
+ function looksLikeLegalLand(q) {
273
+ return parseLegalLand(q) != null;
274
+ }
275
+ function toLegalLandDetails(parsed) {
276
+ return {
277
+ system: LAYER_SYSTEM[parsed.system],
278
+ ...parsed.province ? { province: parsed.province } : {},
279
+ canonical: parsed.canonical,
280
+ unit: parsed.unit,
281
+ components: parsed.components
282
+ };
283
+ }
284
+
285
+ // src/energy.ts
286
+ var ENERGY_SEARCH_LAYERS = ["well", "facility"];
287
+ var DLS_UWI = /^\s*(?:[A-Z0-9]{2}|\d{3})\/(1[0-6]|0?[1-9])-(3[0-6]|[12][0-9]|0?[1-9])-(1[0-2][0-9]|0?[1-9][0-9]|00[1-9])-(([1-8][0-9]|9[0-3]|0?[1-9])\s*(w[1-6]|e[1-2]|[1-6]))\s*m?\/?(\w{1,2})?\s*$/i;
288
+ var BC_UWI = /^\s*\d{3}\/(a|b|c|d)(\s*-\s*|\s+)?((00|0)?[1-9]|0?[1-9][0-9]|100)(\s*-\s*|\s+)?(a|b|c|d|e|f|g|h|i|j|k|l)\s*?\/\s*(0?8[2-9]|0?9[0-9]|10[0-9]|11[0-4])(\s*-\s*|\s+)?(a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p)(\s*-\s*|\s+)?(1[0-6]|0?[1-9])\/\d{1,2}\s*$/i;
289
+ var WELL_LICENCE = /^\s*(?:w|wl|lic(?:\.|ence|ense)?)\s*[-#]?\s*(\d{5,7})\s*$/i;
290
+ function parseEnergyQuery(q) {
291
+ if (typeof q !== "string") return null;
292
+ const raw = q.trim();
293
+ if (!raw) return null;
294
+ if (BC_UWI.test(raw) || DLS_UWI.test(raw)) {
295
+ return { kind: "uwi", raw, key: raw.replace(/\s+/g, "").toUpperCase() };
296
+ }
297
+ const licence = WELL_LICENCE.exec(raw);
298
+ if (licence?.[1]) {
299
+ return { kind: "licence", raw, key: licence[1] };
300
+ }
301
+ return null;
302
+ }
303
+ function looksLikeEnergyQuery(q) {
304
+ return parseEnergyQuery(q) != null;
305
+ }
306
+ function parseEnergySearchLayers(raw) {
307
+ if (!raw) return [];
308
+ const out = [];
309
+ const seen = /* @__PURE__ */ new Set();
310
+ for (const token of raw.split(",")) {
311
+ const id = token.trim();
312
+ if (id === "well" || id === "facility") {
313
+ if (!seen.has(id)) {
314
+ seen.add(id);
315
+ out.push(id);
316
+ }
317
+ }
318
+ }
319
+ return out;
320
+ }
321
+ function isEnergySearchSource(raw) {
322
+ return raw?.trim() === "energy";
323
+ }
324
+ function energySearchRequested(q, opts = {}) {
325
+ if (looksLikeEnergyQuery(q)) return true;
326
+ if (parseEnergySearchLayers(opts.layers).length > 0) return true;
327
+ return isEnergySearchSource(opts.profile) || isEnergySearchSource(opts.source);
328
+ }
329
+
330
+ // src/identify.ts
331
+ var LAND_IDENTIFY_LAYER_IDS = [
332
+ "municipalities",
333
+ "survey.dls",
334
+ "survey.nts",
335
+ "survey.parcels"
336
+ ];
337
+ var ENERGY_IDENTIFY_LAYER_IDS = [
338
+ "energy.wells",
339
+ "energy.pipelines",
340
+ "energy.facilities"
341
+ ];
342
+ var MINING_IDENTIFY_LAYER_IDS = [
343
+ "mining.occurrences",
344
+ "mining.prospectivity",
345
+ "mining.sk-potential"
346
+ ];
347
+ var IDENTIFY_LAYER_IDS = [
348
+ ...LAND_IDENTIFY_LAYER_IDS,
349
+ ...ENERGY_IDENTIFY_LAYER_IDS,
350
+ "energy.fields",
351
+ "energy.petroleum-tenure",
352
+ "energy.mineral-tenure",
353
+ ...MINING_IDENTIFY_LAYER_IDS
354
+ ];
355
+ var DEFAULT_IDENTIFY_LAYERS = ["municipalities"];
356
+ function isIdentifyLayer(id) {
357
+ return IDENTIFY_LAYER_IDS.includes(id);
358
+ }
359
+ function isLandIdentifyLayer(id) {
360
+ return LAND_IDENTIFY_LAYER_IDS.includes(id) || MINING_IDENTIFY_LAYER_IDS.includes(id);
361
+ }
362
+ function isEnergyIdentifyLayer(id) {
363
+ return id.startsWith("energy.");
364
+ }
365
+ function knownIdentifyLayers() {
366
+ return IDENTIFY_LAYER_IDS.join(", ");
367
+ }
368
+ function parseIdentifyLayers(raw) {
369
+ const ids = (raw ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
370
+ if (ids.length === 0) {
371
+ return { layers: [...DEFAULT_IDENTIFY_LAYERS] };
372
+ }
373
+ const layers = [];
374
+ const seen = /* @__PURE__ */ new Set();
375
+ for (const id of ids) {
376
+ if (!isIdentifyLayer(id)) {
377
+ return { error: `unknown identify layer "${id}". known: ${knownIdentifyLayers()}` };
378
+ }
379
+ if (!seen.has(id)) {
380
+ seen.add(id);
381
+ layers.push(id);
382
+ }
383
+ }
384
+ return { layers };
385
+ }
386
+ function parseIdentifyAt(at, lon, lat) {
387
+ const rawAt = at?.trim();
388
+ if (rawAt) {
389
+ const pieces = rawAt.split(",");
390
+ if (pieces.length !== 2 || pieces.some((p) => p.trim() === "")) {
391
+ return { error: "at must be lng,lat" };
392
+ }
393
+ const lng = Number(pieces[0]);
394
+ const latN = Number(pieces[1]);
395
+ if (!Number.isFinite(lng) || !Number.isFinite(latN)) {
396
+ return { error: "at must be lng,lat" };
397
+ }
398
+ return { at: [lng, latN] };
399
+ }
400
+ if (lon != null && lon !== "" && lat != null && lat !== "") {
401
+ const lng = Number(lon);
402
+ const latN = Number(lat);
403
+ if (!Number.isFinite(lng) || !Number.isFinite(latN)) {
404
+ return { error: "lon and lat must be numbers" };
405
+ }
406
+ return { at: [lng, latN] };
407
+ }
408
+ return { error: "at (lng,lat) or lon and lat required" };
409
+ }
410
+
411
+ // src/batch/csv.ts
412
+ var QUOTE = 34;
413
+ var COMMA = 44;
414
+ var CR = 13;
415
+ var LF = 10;
416
+ var CsvParser = class {
417
+ field = "";
418
+ row = [];
419
+ inQuotes = false;
420
+ /** A quote seen inside a quoted field: either an escape or the close. */
421
+ pendingQuote = false;
422
+ /** A CR seen outside quotes: end of record, and a following LF is part of the same break. */
423
+ pendingCr = false;
424
+ started = false;
425
+ rowHasContent = false;
426
+ /**
427
+ * Feed one chunk. Returns the records completed by it.
428
+ *
429
+ * A record that ends exactly at a chunk boundary is returned here; a partial one is held until
430
+ * the next chunk, or until `end()`.
431
+ */
432
+ write(chunk) {
433
+ const out = [];
434
+ if (!this.started) {
435
+ this.started = true;
436
+ if (chunk.charCodeAt(0) === 65279) chunk = chunk.slice(1);
437
+ }
438
+ for (let i = 0; i < chunk.length; i++) {
439
+ const ch = chunk.charCodeAt(i);
440
+ if (this.pendingCr) {
441
+ this.pendingCr = false;
442
+ if (ch === LF) continue;
443
+ }
444
+ if (this.inQuotes) {
445
+ if (this.pendingQuote) {
446
+ this.pendingQuote = false;
447
+ if (ch === QUOTE) {
448
+ this.field += '"';
449
+ continue;
450
+ }
451
+ this.inQuotes = false;
452
+ } else if (ch === QUOTE) {
453
+ this.pendingQuote = true;
454
+ continue;
455
+ } else {
456
+ this.field += chunk[i];
457
+ continue;
458
+ }
459
+ }
460
+ if (ch === QUOTE && this.field === "") {
461
+ this.inQuotes = true;
462
+ this.rowHasContent = true;
463
+ continue;
464
+ }
465
+ if (ch === COMMA) {
466
+ this.row.push(this.field);
467
+ this.field = "";
468
+ this.rowHasContent = true;
469
+ continue;
470
+ }
471
+ if (ch === CR) {
472
+ this.pendingCr = true;
473
+ out.push(this.finishRow());
474
+ continue;
475
+ }
476
+ if (ch === LF) {
477
+ out.push(this.finishRow());
478
+ continue;
479
+ }
480
+ this.field += chunk[i];
481
+ this.rowHasContent = true;
482
+ }
483
+ return out.filter((r) => r !== EMPTY);
484
+ }
485
+ /** Flush a final record with no trailing newline. */
486
+ end() {
487
+ if (this.inQuotes && this.pendingQuote) this.inQuotes = false;
488
+ if (this.field !== "" || this.row.length > 0 || this.rowHasContent) {
489
+ const row = this.finishRow();
490
+ return row === EMPTY ? [] : [row];
491
+ }
492
+ return [];
493
+ }
494
+ /** True when the stream ended inside an unterminated quoted field. */
495
+ get unterminated() {
496
+ return this.inQuotes && !this.pendingQuote;
497
+ }
498
+ finishRow() {
499
+ this.row.push(this.field);
500
+ const row = this.row;
501
+ this.field = "";
502
+ this.row = [];
503
+ const hadContent = this.rowHasContent;
504
+ this.rowHasContent = false;
505
+ if (!hadContent && row.length === 1 && row[0] === "") return EMPTY;
506
+ return row;
507
+ }
508
+ };
509
+ var EMPTY = [];
510
+ function alignRow(header, values, index) {
511
+ if (values.length === header.length) return { record: { index, values: [...values] } };
512
+ if (values.length < header.length) {
513
+ return {
514
+ record: { index, values: [...values, ...Array(header.length - values.length).fill("")] },
515
+ problem: {
516
+ index,
517
+ code: "too_few_fields",
518
+ message: `row ${index} has ${values.length} fields, header has ${header.length}`
519
+ }
520
+ };
521
+ }
522
+ return {
523
+ record: { index, values: [...values] },
524
+ problem: {
525
+ index,
526
+ code: "too_many_fields",
527
+ message: `row ${index} has ${values.length} fields, header has ${header.length}`
528
+ }
529
+ };
530
+ }
531
+ function encodeField(value) {
532
+ if (value === "") return "";
533
+ if (/[",\r\n\t]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
534
+ return value;
535
+ }
536
+ function looksLikeFormula(value) {
537
+ return /^[=+\-@\t\r]/.test(value);
538
+ }
539
+ function escapeForSpreadsheet(value) {
540
+ return looksLikeFormula(value) ? ` ${value}` : value;
541
+ }
542
+ function encodeRow(values, opts = {}) {
543
+ const prepared = opts.spreadsheetSafe ? values.map(escapeForSpreadsheet) : values;
544
+ return prepared.map(encodeField).join(",") + "\r\n";
545
+ }
546
+
547
+ // src/batch/output.ts
548
+ var OUTPUT_PREFIX = "unmap_";
549
+ var OUTPUT_COLUMNS = [
550
+ "unmap_status",
551
+ "unmap_id",
552
+ "unmap_name",
553
+ "unmap_lng",
554
+ "unmap_lat",
555
+ "unmap_layer",
556
+ "unmap_source",
557
+ "unmap_match_type",
558
+ "unmap_precision",
559
+ "unmap_review"
560
+ ];
561
+ function resolveOutputHeader(inputHeader) {
562
+ const taken = new Set(inputHeader.map((h) => h.trim().toLowerCase()));
563
+ const renamed = [];
564
+ const header = [...inputHeader];
565
+ for (const column of OUTPUT_COLUMNS) {
566
+ let name = column;
567
+ if (taken.has(name.toLowerCase())) {
568
+ let n = 2;
569
+ while (taken.has(`${column}_${n}`.toLowerCase())) n++;
570
+ name = `${column}_${n}`;
571
+ renamed.push({ column, to: name });
572
+ }
573
+ taken.add(name.toLowerCase());
574
+ header.push(name);
575
+ }
576
+ return { header, renamed };
577
+ }
578
+ var AMBIGUOUS_SCORE_RATIO = 0.95;
579
+ function classify(results) {
580
+ if (results.length === 0) return { status: "no_match", reasons: ["no_match"] };
581
+ const [top, second] = results;
582
+ const reasons = [];
583
+ if (top.match_type === "fallback") reasons.push("fallback_match");
584
+ for (const r of top.match_reasons ?? []) reasons.push(r);
585
+ if (second && typeof top.score === "number" && typeof second.score === "number" && top.score > 0 && second.score / top.score >= AMBIGUOUS_SCORE_RATIO) {
586
+ reasons.push("ambiguous");
587
+ }
588
+ return { status: reasons.length ? "review" : "matched", reasons };
589
+ }
590
+ function outputValues(status, reasons, top) {
591
+ return [
592
+ status,
593
+ top?.id ?? "",
594
+ top?.name ?? "",
595
+ // Fixed precision rather than the float's own repr: a CSV of coordinates is read by humans
596
+ // and by spreadsheets, and 51.04000000000001 is neither. 7 dp is ~1 cm, well beyond what any
597
+ // source in the corpus actually resolves.
598
+ top ? trimCoordinate(top.lng) : "",
599
+ top ? trimCoordinate(top.lat) : "",
600
+ top?.layer ?? "",
601
+ top?.source ?? "",
602
+ top?.match_type ?? "",
603
+ top?.precision ?? "",
604
+ reasons.join(" ")
605
+ ];
606
+ }
607
+ function trimCoordinate(n) {
608
+ return n.toFixed(7).replace(/\.?0+$/, "");
609
+ }
610
+
611
+ // src/search-layers.ts
612
+ var CIVIC_SEARCH_LAYERS = ["address", "street", "locality", "region", "poi"];
613
+ var CIVIC = new Set(CIVIC_SEARCH_LAYERS);
614
+ var ENERGY = new Set(ENERGY_SEARCH_LAYERS);
615
+ function knownSearchLayers() {
616
+ return [...CIVIC_SEARCH_LAYERS, ...ENERGY_SEARCH_LAYERS].join(", ");
617
+ }
618
+ function parseSearchLayers(raw) {
619
+ if (raw == null || raw.trim() === "") return { kind: "none" };
620
+ const civic = [];
621
+ const energy = [];
622
+ const seen = /* @__PURE__ */ new Set();
623
+ for (const token of raw.split(",")) {
624
+ const id = token.trim();
625
+ if (id === "") continue;
626
+ if (seen.has(id)) continue;
627
+ seen.add(id);
628
+ if (CIVIC.has(id)) civic.push(id);
629
+ else if (ENERGY.has(id)) energy.push(id);
630
+ else return { error: `unknown layer "${id}". Known layers: ${knownSearchLayers()}` };
631
+ }
632
+ if (civic.length && energy.length) {
633
+ return {
634
+ error: `layers cannot mix civic and energy values: ${civic.join(", ")} filter results within the civic corpus, while ${energy.join(", ")} select the energy corpus instead. Ask for one or the other.`
635
+ };
636
+ }
637
+ if (energy.length) return { kind: "energy", layers: energy };
638
+ if (civic.length) return { kind: "civic", layers: civic };
639
+ return { kind: "none" };
640
+ }
641
+ function parseFocus(raw) {
642
+ if (raw == null || raw.trim() === "") return void 0;
643
+ const pieces = raw.split(",").map((s) => s.trim());
644
+ const parts = pieces.map((s) => s === "" ? Number.NaN : Number(s));
645
+ if (parts.length !== 2 || !parts.every(Number.isFinite)) {
646
+ return { error: "focus must be lng,lat with two finite numbers" };
647
+ }
648
+ const [lng, lat] = parts;
649
+ if (lng < -180 || lng > 180) return { error: `focus longitude ${lng} is outside [-180, 180]` };
650
+ if (lat < -90 || lat > 90) return { error: `focus latitude ${lat} is outside [-90, 90]` };
651
+ return { focus: [lng, lat] };
652
+ }
653
+
654
+ // src/regions.ts
655
+ var BASE = {
656
+ ab: "AB",
657
+ alberta: "AB",
658
+ alta: "AB",
659
+ bc: "BC",
660
+ "british columbia": "BC",
661
+ "colombie-britannique": "BC",
662
+ mb: "MB",
663
+ manitoba: "MB",
664
+ nb: "NB",
665
+ "new brunswick": "NB",
666
+ "nouveau-brunswick": "NB",
667
+ nl: "NL",
668
+ "newfoundland and labrador": "NL",
669
+ newfoundland: "NL",
670
+ "terre-neuve-et-labrador": "NL",
671
+ nfld: "NL",
672
+ ns: "NS",
673
+ "nova scotia": "NS",
674
+ "nouvelle-\xE9cosse": "NS",
675
+ "nouvelle-ecosse": "NS",
676
+ nt: "NT",
677
+ "northwest territories": "NT",
678
+ "territoires du nord-ouest": "NT",
679
+ nwt: "NT",
680
+ nu: "NU",
681
+ nunavut: "NU",
682
+ on: "ON",
683
+ ontario: "ON",
684
+ ont: "ON",
685
+ pe: "PE",
686
+ pei: "PE",
687
+ "prince edward island": "PE",
688
+ "\xEEle-du-prince-\xE9douard": "PE",
689
+ qc: "QC",
690
+ pq: "QC",
691
+ quebec: "QC",
692
+ qu\u00E9bec: "QC",
693
+ "que.": "QC",
694
+ "qu\xE9.": "QC",
695
+ sk: "SK",
696
+ saskatchewan: "SK",
697
+ sask: "SK",
698
+ yt: "YT",
699
+ yukon: "YT"
700
+ };
701
+ for (const [k, v] of Object.entries(BASE)) {
702
+ if (/^[a-z]{2}$/.test(k)) BASE[k[0] + "." + k[1] + "."] = v;
703
+ else if (k === "nwt" || k === "pei") BASE[k.split("").join(".") + "."] = v;
704
+ else if (/^[a-z]{3,4}$/.test(k)) BASE[k + "."] = v;
705
+ }
706
+ var REGIONS = BASE;
707
+ var REGION_KEYS = Object.keys(BASE).sort((a, b) => b.length - a.length);
708
+ var REGION_CODES = [...new Set(Object.values(BASE))].sort();
709
+ function parseRegion(raw) {
710
+ if (!raw) return void 0;
711
+ const key = raw.trim().toLowerCase();
712
+ if (REGIONS[key]) return REGIONS[key];
713
+ const unaccented = key.normalize("NFD").replace(/\p{M}/gu, "");
714
+ for (const [k, v] of Object.entries(REGIONS)) {
715
+ if (k.normalize("NFD").replace(/\p{M}/gu, "") === unaccented) return v;
716
+ }
717
+ return void 0;
718
+ }
719
+ function regionForms(code) {
720
+ const forms = /* @__PURE__ */ new Set([code.toUpperCase()]);
721
+ for (const [k, v] of Object.entries(REGIONS)) if (v === code) forms.add(k.toUpperCase());
722
+ return [...forms];
723
+ }
724
+
725
+ // src/structured.ts
726
+ var STRUCTURED_FIELDS = ["address", "city", "region", "postalcode", "country"];
727
+ var ANCHOR_FIELDS = ["address", "city", "postalcode"];
728
+ var POSTCODE = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
729
+ function parseStructuredQuery(raw) {
730
+ const trim = (v) => {
731
+ const t = (v ?? "").trim();
732
+ return t === "" ? void 0 : t;
733
+ };
734
+ const country = trim(raw.country);
735
+ if (country && !/^(ca|can|canada)$/i.test(country)) {
736
+ return { error: `country must be CA: unmap covers Canada only (got "${country}")` };
737
+ }
738
+ let region;
739
+ const rawRegion = trim(raw.region);
740
+ if (rawRegion) {
741
+ region = parseRegion(rawRegion);
742
+ if (!region) {
743
+ return { error: `unknown region "${rawRegion}". Use a province or territory code: ${REGION_CODES.join(", ")}` };
744
+ }
745
+ }
746
+ let postalcode;
747
+ const rawPostal = trim(raw.postalcode);
748
+ if (rawPostal) {
749
+ postalcode = rawPostal.replace(/\s+/g, "").toUpperCase();
750
+ if (!POSTCODE.test(postalcode)) {
751
+ return { error: `postalcode must be a Canadian postal code such as M5H 2N2 (got "${rawPostal}")` };
752
+ }
753
+ }
754
+ const query = {
755
+ ...trim(raw.address) ? { address: trim(raw.address) } : {},
756
+ ...trim(raw.city) ? { city: trim(raw.city) } : {},
757
+ ...region ? { region } : {},
758
+ ...postalcode ? { postalcode } : {},
759
+ country: "CA"
760
+ };
761
+ if (!ANCHOR_FIELDS.some((f) => query[f] != null)) {
762
+ return {
763
+ error: `structured search needs at least one of ${ANCHOR_FIELDS.join(", ")}. region and country narrow a search; they cannot be one on their own.`
764
+ };
765
+ }
766
+ return { query };
767
+ }
768
+
769
+ // src/parcel.ts
770
+ var PID = /^\d{3}-?\d{3}-?\d{3}$/;
771
+ function parseParcel(q) {
772
+ if (typeof q !== "string") return null;
773
+ const raw = q.trim();
774
+ if (!PID.test(raw)) return null;
775
+ const digits = raw.replace(/-/g, "");
776
+ return {
777
+ raw,
778
+ pid: `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
779
+ };
780
+ }
781
+ function looksLikeParcel(q) {
782
+ return parseParcel(q) != null;
783
+ }
784
+
785
+ // src/query.ts
786
+ var QUERY_LAYER_IDS = IDENTIFY_LAYER_IDS;
787
+ var DEFAULT_QUERY_LIMIT = 50;
788
+ var MAX_QUERY_LIMIT = 50;
789
+ function knownQueryLayers() {
790
+ return knownIdentifyLayers();
791
+ }
792
+ function parseQueryLayer(raw) {
793
+ const id = (raw ?? "").trim();
794
+ if (!id) return { error: "layer required" };
795
+ if (isIdentifyLayer(id)) return { layer: id };
796
+ return {
797
+ unknown: id,
798
+ error: `unknown query layer "${id}". queryable: ${knownQueryLayers()}`
799
+ };
800
+ }
801
+ function parseQueryBbox(raw) {
802
+ const value = raw?.trim();
803
+ if (!value) return { error: "bbox (minlng,minlat,maxlng,maxlat) required" };
804
+ const pieces = value.split(",");
805
+ if (pieces.length !== 4 || pieces.some((piece) => piece.trim() === "")) {
806
+ return { error: "bbox must be minlng,minlat,maxlng,maxlat" };
807
+ }
808
+ const nums = pieces.map(Number);
809
+ if (nums.some((n) => !Number.isFinite(n))) {
810
+ return { error: "bbox must be minlng,minlat,maxlng,maxlat" };
811
+ }
812
+ const [minLng, minLat, maxLng, maxLat] = nums;
813
+ if (minLng >= maxLng || minLat >= maxLat) {
814
+ return { error: "bbox must be minlng,minlat,maxlng,maxlat" };
815
+ }
816
+ return { bbox: [minLng, minLat, maxLng, maxLat] };
817
+ }
818
+ function parseQueryLimit(raw) {
819
+ if (raw == null || raw === "") return DEFAULT_QUERY_LIMIT;
820
+ const n = Number(raw);
821
+ return Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), MAX_QUERY_LIMIT) : DEFAULT_QUERY_LIMIT;
822
+ }
823
+
824
+ // src/rural-route.ts
825
+ var KINDS = [
826
+ { pattern: /\b(?:R\.?\s?R\.?|RURAL\s+ROUTE)\s*#?\s*(\d{1,3})\b/i, kind: "rural-route", numbered: true },
827
+ { pattern: /\b(?:S\.?\s?S\.?|SUBURBAN\s+SERVICE)\s*#?\s*(\d{1,3})\b/i, kind: "suburban-service", numbered: true },
828
+ { pattern: /\b(?:M\.?\s?R\.?|MOBILE\s+ROUTE)\s*#?\s*(\d{1,3})\b/i, kind: "mobile-route", numbered: true },
829
+ { pattern: /\b(?:G\.?\s?D\.?|GENERAL\s+DELIVERY)\b/i, kind: "general-delivery", numbered: false }
830
+ ];
831
+ var PARTS = [
832
+ { pattern: /\bSITE\s*#?\s*([A-Z0-9-]{1,8})\b/i, field: "site" },
833
+ { pattern: /\bCOMP(?:ARTMENT)?\s*#?\s*([A-Z0-9-]{1,8})\b/i, field: "compartment" },
834
+ { pattern: /\b(?:PO\s*)?BOX\s*#?\s*([A-Z0-9-]{1,8})\b/i, field: "box" },
835
+ // One word only. Canada Post does publish two-word station names ("STN ST LAURENT"), but
836
+ // "STN MAIN BRANDON" is ambiguous between a two-word station and a station plus a community,
837
+ // and guessing wrong swallows the only part that can be geocoded. Missing the second word of
838
+ // a rare station name costs a label; stealing the town costs the answer.
839
+ { pattern: /\b(?:STN|STATION|RPO|LCD)\s+([A-Z][A-Z0-9'-]*)\b/i, field: "station" }
840
+ ];
841
+ function parseRuralRoute(q) {
842
+ if (typeof q !== "string") return null;
843
+ const raw = q.trim();
844
+ if (raw === "") return null;
845
+ const matched = KINDS.map((k) => ({ ...k, match: k.pattern.exec(raw) })).find((k) => k.match !== null);
846
+ if (!matched?.match) return null;
847
+ let rest = raw.replace(matched.pattern, " ");
848
+ const parsed = { raw, kind: matched.kind, remainder: "" };
849
+ if (matched.numbered) parsed.number = String(Number(matched.match[1]));
850
+ for (const part of PARTS) {
851
+ const hit = part.pattern.exec(rest);
852
+ if (!hit) continue;
853
+ parsed[part.field] = hit[1].trim().toUpperCase();
854
+ rest = rest.replace(part.pattern, " ");
855
+ }
856
+ parsed.remainder = rest.replace(/[,;]+/g, " ").replace(/\s+/g, " ").replace(/^[-\s]+|[-\s]+$/g, "");
857
+ return parsed;
858
+ }
859
+ function looksLikeRuralRoute(q) {
860
+ return parseRuralRoute(q) != null;
861
+ }
862
+
863
+ // src/index.ts
3
864
  var GeocoderError = class extends Error {
4
865
  constructor(status, message, code) {
5
866
  super(message);
@@ -39,6 +900,32 @@ var Geocoder = class {
39
900
  autocomplete(q, opts = {}) {
40
901
  return this.query("/geocode/autocomplete", { q, ...toParams(opts) }, opts.signal);
41
902
  }
903
+ /**
904
+ * Forward geocoding from address components rather than one string.
905
+ *
906
+ * `address` and `city` decide which rows are candidates and which wins. `region` and
907
+ * `postalcode` narrow: they drop rows that contradict them and keep rows that are silent
908
+ * about them, which come back with `region_not_verified` / `postalcode_not_verified` in
909
+ * `match_reasons`. Needs at least one of `address`, `city` or `postalcode`.
910
+ */
911
+ structured(opts) {
912
+ return this.query(
913
+ "/geocode/structured",
914
+ {
915
+ address: opts.address,
916
+ city: opts.city,
917
+ region: opts.region,
918
+ postalcode: opts.postalcode,
919
+ country: opts.country,
920
+ limit: opts.limit != null ? String(opts.limit) : void 0,
921
+ lang: opts.lang,
922
+ bbox: opts.bbox ? opts.bbox.join(",") : void 0,
923
+ focus: opts.focus ? opts.focus.join(",") : void 0,
924
+ layers: opts.layers
925
+ },
926
+ opts.signal
927
+ );
928
+ }
42
929
  /**
43
930
  * Category search: POIs of a kind near a point, sorted by distance (metres, in each
44
931
  * result's `distance`). `category` takes canonical taxonomy ids ("health.pharmacy") or
@@ -67,7 +954,43 @@ var Geocoder = class {
67
954
  opts.signal
68
955
  );
69
956
  }
70
- async query(path, params, signal) {
957
+ /**
958
+ * Which catalog features contain a point. Default layer is `municipalities`
959
+ * (core). `survey.dls` / `survey.nts` need the `legal-land` add-on.
960
+ * `energy.*` need the `energy` add-on. `mining.*` need the `mining` add-on
961
+ * and identify via Land extra tables. Empty until the matching PostGIS table
962
+ * is loaded. No mining search grammar — claim numbers stay `energy.mineral-tenure`.
963
+ */
964
+ identify(opts) {
965
+ return this.requestJson(
966
+ "/identify",
967
+ {
968
+ at: opts.at.join(","),
969
+ layers: opts.layers?.length ? opts.layers.join(",") : void 0,
970
+ geometry: opts.geometry ? "true" : void 0
971
+ },
972
+ opts.signal
973
+ );
974
+ }
975
+ /**
976
+ * Features of one catalog layer that intersect a bbox. Same PostGIS tables as
977
+ * `identify`. Tile-only catalog ids are a 501. Survey layers need `legal-land`;
978
+ * `energy.*` need `energy`; `survey.parcels` needs `agriculture`; mining
979
+ * layers need `mining`. Empty until the matching table is loaded.
980
+ */
981
+ queryLayer(opts) {
982
+ return this.requestJson(
983
+ "/data/query",
984
+ {
985
+ layer: opts.layer,
986
+ bbox: opts.bbox.join(","),
987
+ limit: opts.limit != null ? String(opts.limit) : void 0,
988
+ geometry: opts.geometry ? "true" : void 0
989
+ },
990
+ opts.signal
991
+ );
992
+ }
993
+ async requestJson(path, params, signal) {
71
994
  const q = new URLSearchParams({ key: this.key });
72
995
  for (const [k, v] of Object.entries(params)) if (v != null && v !== "") q.set(k, v);
73
996
  const res = await this.fetchImpl(`${this.base}${path}?${q}`, {
@@ -75,7 +998,10 @@ var Geocoder = class {
75
998
  headers: { accept: "application/json" }
76
999
  });
77
1000
  if (!res.ok) throw await errorFrom(res, path);
78
- const body = await res.json();
1001
+ return res.json();
1002
+ }
1003
+ async query(path, params, signal) {
1004
+ const body = await this.requestJson(path, params, signal);
79
1005
  return Array.isArray(body) ? body : body.results ?? [];
80
1006
  }
81
1007
  };
@@ -83,11 +1009,69 @@ function toParams(o) {
83
1009
  return {
84
1010
  limit: o.limit != null ? String(o.limit) : void 0,
85
1011
  lang: o.lang,
86
- bbox: o.bbox ? o.bbox.join(",") : void 0
1012
+ bbox: o.bbox ? o.bbox.join(",") : void 0,
1013
+ focus: o.focus ? o.focus.join(",") : void 0,
1014
+ region: o.region,
1015
+ geometry: o.geometry ? "true" : void 0,
1016
+ layers: o.layers
87
1017
  };
88
1018
  }
89
1019
  export {
1020
+ AMBIGUOUS_SCORE_RATIO,
1021
+ CIVIC_SEARCH_LAYERS,
1022
+ CsvParser,
1023
+ DEFAULT_IDENTIFY_LAYERS,
1024
+ DEFAULT_QUERY_LIMIT,
1025
+ ENERGY_IDENTIFY_LAYER_IDS,
1026
+ ENERGY_SEARCH_LAYERS,
90
1027
  Geocoder,
91
- GeocoderError
1028
+ GeocoderError,
1029
+ IDENTIFY_LAYER_IDS,
1030
+ LAND_IDENTIFY_LAYER_IDS,
1031
+ MAX_QUERY_LIMIT,
1032
+ MINING_IDENTIFY_LAYER_IDS,
1033
+ OUTPUT_COLUMNS,
1034
+ OUTPUT_PREFIX,
1035
+ QUERY_LAYER_IDS,
1036
+ REGIONS,
1037
+ REGION_CODES,
1038
+ REGION_KEYS,
1039
+ STRUCTURED_FIELDS,
1040
+ alignRow,
1041
+ classify,
1042
+ encodeField,
1043
+ encodeRow,
1044
+ energySearchRequested,
1045
+ escapeForSpreadsheet,
1046
+ isEnergyIdentifyLayer,
1047
+ isEnergySearchSource,
1048
+ isIdentifyLayer,
1049
+ isLandIdentifyLayer,
1050
+ knownIdentifyLayers,
1051
+ knownQueryLayers,
1052
+ knownSearchLayers,
1053
+ looksLikeEnergyQuery,
1054
+ looksLikeFormula,
1055
+ looksLikeLegalLand,
1056
+ looksLikeParcel,
1057
+ looksLikeRuralRoute,
1058
+ outputValues,
1059
+ parseEnergyQuery,
1060
+ parseEnergySearchLayers,
1061
+ parseFocus,
1062
+ parseIdentifyAt,
1063
+ parseIdentifyLayers,
1064
+ parseLegalLand,
1065
+ parseParcel,
1066
+ parseQueryBbox,
1067
+ parseQueryLayer,
1068
+ parseQueryLimit,
1069
+ parseRegion,
1070
+ parseRuralRoute,
1071
+ parseSearchLayers,
1072
+ parseStructuredQuery,
1073
+ regionForms,
1074
+ resolveOutputHeader,
1075
+ toLegalLandDetails
92
1076
  };
93
1077
  //# sourceMappingURL=index.js.map