oglap-ggp-node 1.0.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/LICENSE +661 -0
- package/oglap-ggp-node-1.0.0.tgz +0 -0
- package/oglap.js +1800 -0
- package/package.json +37 -0
- package/test.js +349 -0
package/oglap.js
ADDED
|
@@ -0,0 +1,1800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file oglap.js
|
|
3
|
+
* @description OGLAP Protocol Core Logic.
|
|
4
|
+
* @version 0.1.0
|
|
5
|
+
* This module strictly adheres to the Global Grid Protocol (GGP) specifications,
|
|
6
|
+
* converting geographic coordinates into human-readable, deterministic alphanumeric
|
|
7
|
+
* OGLAP codes (e.g., GN-CKY-QKPC-B4A4-2798 / GN-CKY-QFEAA4-2798), and vice versa.
|
|
8
|
+
*
|
|
9
|
+
* Security & Performance notes:
|
|
10
|
+
* - O(N) spatial lookups optimized via internal object geometry caching (`_computedBbox`, `_computedArea`).
|
|
11
|
+
* - Regex operations safely scoped to bounded string inputs to prevent ReDoS.
|
|
12
|
+
* - Entire dataset runs statically in memory, suitable for clientside or serverless environments.
|
|
13
|
+
*
|
|
14
|
+
* @module OGLAP
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
|
18
|
+
import area from '@turf/area';
|
|
19
|
+
|
|
20
|
+
// --- PACKAGE IDENTITY ---
|
|
21
|
+
const PACKAGE_VERSION = '0.1.0';
|
|
22
|
+
|
|
23
|
+
// --- INITIALIZATION STATE ---
|
|
24
|
+
let _initialized = false;
|
|
25
|
+
let _initReport = null;
|
|
26
|
+
|
|
27
|
+
// --- INITIAL STATE ---
|
|
28
|
+
let COUNTRY_PROFILE = {};
|
|
29
|
+
let COUNTRY_CODE = 'GN';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a flat key-value map from a complex profile table extracting 'oglap_code'.
|
|
33
|
+
* @param {Object} table - The profile table containing deeply nested code entries.
|
|
34
|
+
* @returns {Object<string, string>} A map of ISO codes to OGLAP codes.
|
|
35
|
+
*/
|
|
36
|
+
function mapFromCodeTable(table = {}) {
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.entries(table)
|
|
39
|
+
.map(([iso, entry]) => [iso, entry?.oglap_code])
|
|
40
|
+
.filter(([, code]) => typeof code === 'string' && code.trim())
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let OGLAP_COUNTRY_REGIONS = {};
|
|
45
|
+
let OGLAP_COUNTRY_REGIONS_REVERSE = {};
|
|
46
|
+
let OGLAP_COUNTRY_PREFECTURES = {};
|
|
47
|
+
|
|
48
|
+
/** @type {Map<string|number, string>} Cache mapping place_id to specific zone codes */
|
|
49
|
+
let OGLAP_ZONE_CODES_BY_ID = new Map();
|
|
50
|
+
|
|
51
|
+
let ZONE_TYPE_PREFIX_DEFAULT = 'Z';
|
|
52
|
+
let ZONE_TYPE_PREFIX = {};
|
|
53
|
+
let GGP_STOPWORDS = new Set();
|
|
54
|
+
let GGP_PAD_CHAR = 'X';
|
|
55
|
+
let COUNTRY_SW = [0, 0];
|
|
56
|
+
let COUNTRY_BOUNDS = { sw: [7.19, -15.37], ne: [12.68, -7.64] };
|
|
57
|
+
/** @type {Object|null} Cached country border polygon (admin_level 2) for boundary checks */
|
|
58
|
+
let COUNTRY_BORDER_GEOJSON = null;
|
|
59
|
+
let METERS_PER_DEGREE_LAT = 111320;
|
|
60
|
+
|
|
61
|
+
/** @returns {string} Current OGLAP package version */
|
|
62
|
+
export function getPackageVersion() { return PACKAGE_VERSION; }
|
|
63
|
+
/** @returns {Object} Current active country profile */
|
|
64
|
+
export function getCountryProfile() { return COUNTRY_PROFILE; }
|
|
65
|
+
/** @returns {string} 2-letter Country OGLAP code (e.g. "GN") */
|
|
66
|
+
export function getCountryCode() { return COUNTRY_CODE; }
|
|
67
|
+
/** @returns {number[]} The SW boundary constraint [Lat, Lon] for the country */
|
|
68
|
+
export function getCountrySW() { return COUNTRY_SW; }
|
|
69
|
+
/** @returns {Object<string, string>} A map of Prefectures codes */
|
|
70
|
+
export function getOglapPrefectures() { return OGLAP_COUNTRY_PREFECTURES; }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Quick status check. Returns the last init report, or a not-initialized stub if never called.
|
|
74
|
+
* @returns {{ ok: boolean, checks: Array, error: string|null, countryCode: string|null, countryName: string|null, bounds: number[][]|null }}
|
|
75
|
+
*/
|
|
76
|
+
export function checkOglap() {
|
|
77
|
+
if (_initReport) return _initReport;
|
|
78
|
+
return { ok: false, countryCode: null, countryName: null, bounds: null, checks: [], error: 'initOglap has not been called yet.' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- SEMVER HELPERS ---
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parses a semver string "MAJOR.MINOR.PATCH" into [major, minor, patch].
|
|
85
|
+
* Returns null if the string is not a valid semver.
|
|
86
|
+
*/
|
|
87
|
+
function parseSemver(str) {
|
|
88
|
+
if (typeof str !== 'string') return null;
|
|
89
|
+
const m = str.trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
90
|
+
if (!m) return null;
|
|
91
|
+
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Checks if `version` satisfies the caret range `^range`.
|
|
96
|
+
* ^MAJOR.MINOR.PATCH means >=MAJOR.MINOR.PATCH and <(MAJOR+1).0.0 (when MAJOR>0).
|
|
97
|
+
* ^0.MINOR.PATCH means >=0.MINOR.PATCH and <0.(MINOR+1).0 (when MAJOR=0).
|
|
98
|
+
*/
|
|
99
|
+
function satisfiesCaret(version, range) {
|
|
100
|
+
const v = parseSemver(version);
|
|
101
|
+
const r = parseSemver(range);
|
|
102
|
+
if (!v || !r) return false;
|
|
103
|
+
// version must be >= range
|
|
104
|
+
for (let i = 0; i < 3; i++) {
|
|
105
|
+
if (v[i] > r[i]) break;
|
|
106
|
+
if (v[i] < r[i]) return false;
|
|
107
|
+
}
|
|
108
|
+
// version must be < next breaking change
|
|
109
|
+
if (r[0] > 0) return v[0] === r[0];
|
|
110
|
+
if (r[1] > 0) return v[0] === 0 && v[1] === r[1];
|
|
111
|
+
return v[0] === 0 && v[1] === 0 && v[2] === r[2];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- REMOTE DATA ---
|
|
115
|
+
const OGLAP_S3_BASE = 'https://s3.guinee.io/oglap/ggp';
|
|
116
|
+
const OGLAP_REMOTE_FILES = [
|
|
117
|
+
{ key: 'profile', name: 'gn_oglap_country_profile.json', label: 'Country profile', timeoutMs: 30000 },
|
|
118
|
+
{ key: 'localities', name: 'gn_localities_naming.json', label: 'Localities naming', timeoutMs: 60000 },
|
|
119
|
+
{ key: 'data', name: 'gn_full.json', label: 'Places database', timeoutMs: 300000 },
|
|
120
|
+
];
|
|
121
|
+
const _SLOW_BPS = 50 * 1024; // 50 KB/s
|
|
122
|
+
const _SLOW_WINDOW_MS = 5000;
|
|
123
|
+
|
|
124
|
+
// --- LOCAL DATA STORAGE ---
|
|
125
|
+
const OGLAP_DATA_DIR_DEFAULT = 'oglap-data';
|
|
126
|
+
|
|
127
|
+
/** @private Lazily loaded Node.js modules for file I/O. */
|
|
128
|
+
let _fsmod = null;
|
|
129
|
+
let _pathmod = null;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Dynamically imports Node.js fs and path modules.
|
|
133
|
+
* Only called in download mode — has no effect on browser-only (direct mode) usage.
|
|
134
|
+
* @private
|
|
135
|
+
* @returns {Promise<{ fs: object, path: object }>}
|
|
136
|
+
*/
|
|
137
|
+
async function _getNodeModules() {
|
|
138
|
+
if (_fsmod && _pathmod) return { fs: _fsmod, path: _pathmod };
|
|
139
|
+
try {
|
|
140
|
+
_fsmod = await import('node:fs/promises');
|
|
141
|
+
_pathmod = await import('node:path');
|
|
142
|
+
// Handle default exports when behind an ESM wrapper
|
|
143
|
+
if (_fsmod.default && typeof _fsmod.default.mkdir === 'function') _fsmod = _fsmod.default;
|
|
144
|
+
if (_pathmod.default && typeof _pathmod.default.resolve === 'function') _pathmod = _pathmod.default;
|
|
145
|
+
return { fs: _fsmod, path: _pathmod };
|
|
146
|
+
} catch {
|
|
147
|
+
throw new Error('File system access requires a Node.js-compatible runtime (Node, Bun, Deno).');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Creates a directory recursively if it doesn't exist.
|
|
153
|
+
* @private
|
|
154
|
+
*/
|
|
155
|
+
async function _ensureDir(dirPath) {
|
|
156
|
+
const { fs } = await _getNodeModules();
|
|
157
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Checks whether a file exists at the given path.
|
|
162
|
+
* @private
|
|
163
|
+
*/
|
|
164
|
+
async function _fileExists(filePath) {
|
|
165
|
+
const { fs } = await _getNodeModules();
|
|
166
|
+
try { await fs.access(filePath); return true; } catch { return false; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reads and JSON-parses a file from disk.
|
|
171
|
+
* @private
|
|
172
|
+
*/
|
|
173
|
+
async function _readJsonFile(filePath) {
|
|
174
|
+
const { fs } = await _getNodeModules();
|
|
175
|
+
const text = await fs.readFile(filePath, 'utf-8');
|
|
176
|
+
return JSON.parse(text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Writes text content to a file on disk.
|
|
181
|
+
* @private
|
|
182
|
+
*/
|
|
183
|
+
async function _writeFile(filePath, content) {
|
|
184
|
+
const { fs } = await _getNodeModules();
|
|
185
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Fetch URL with streaming progress, slow-network detection, and timeout.
|
|
190
|
+
* @private
|
|
191
|
+
* @param {string} url
|
|
192
|
+
* @param {{ onChunk?: Function, timeoutMs?: number }} opts
|
|
193
|
+
* @returns {Promise<string>} Response body as text.
|
|
194
|
+
*/
|
|
195
|
+
async function _fetchWithProgress(url, { onChunk, timeoutMs = 120000 } = {}) {
|
|
196
|
+
const ctrl = new AbortController();
|
|
197
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
198
|
+
let res;
|
|
199
|
+
try {
|
|
200
|
+
res = await fetch(url, { signal: ctrl.signal });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
throw new Error(err.name === 'AbortError'
|
|
204
|
+
? `Timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
205
|
+
: `Network error: ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
throw new Error(`HTTP ${res.status}${res.statusText ? ' ' + res.statusText : ''}`);
|
|
210
|
+
}
|
|
211
|
+
const total = parseInt(res.headers.get('content-length') || '0', 10);
|
|
212
|
+
// Fallback for environments without streaming
|
|
213
|
+
if (!res.body?.getReader) {
|
|
214
|
+
const text = await res.text();
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
return text;
|
|
217
|
+
}
|
|
218
|
+
const reader = res.body.getReader();
|
|
219
|
+
const chunks = [];
|
|
220
|
+
let loaded = 0, winStart = Date.now(), winBytes = 0;
|
|
221
|
+
for (; ;) {
|
|
222
|
+
let rd;
|
|
223
|
+
try { rd = await reader.read(); } catch (e) { clearTimeout(timer); throw new Error(`Download interrupted: ${e.message}`); }
|
|
224
|
+
if (rd.done) break;
|
|
225
|
+
chunks.push(rd.value);
|
|
226
|
+
loaded += rd.value.length;
|
|
227
|
+
winBytes += rd.value.length;
|
|
228
|
+
const now = Date.now(), elapsed = now - winStart;
|
|
229
|
+
let slow = false;
|
|
230
|
+
if (elapsed >= _SLOW_WINDOW_MS) {
|
|
231
|
+
slow = (winBytes / elapsed) * 1000 < _SLOW_BPS;
|
|
232
|
+
winStart = now; winBytes = 0;
|
|
233
|
+
}
|
|
234
|
+
if (onChunk) onChunk({ loaded, total, percent: total ? Math.round((loaded / total) * 1000) / 10 : 0, slow });
|
|
235
|
+
}
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
const buf = new Uint8Array(loaded);
|
|
238
|
+
let off = 0;
|
|
239
|
+
for (const c of chunks) { buf.set(c, off); off += c.length; }
|
|
240
|
+
return new TextDecoder().decode(buf);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- INIT VALIDATION ---
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Validates profile and localities naming, applies engine state if valid.
|
|
247
|
+
* @private
|
|
248
|
+
*/
|
|
249
|
+
function _validateAndApply(profile, localitiesNaming, priorChecks = []) {
|
|
250
|
+
const checks = [...priorChecks];
|
|
251
|
+
let fatal = false;
|
|
252
|
+
|
|
253
|
+
function pass(id, msg) { checks.push({ id, status: 'pass', message: msg }); }
|
|
254
|
+
function warn(id, msg) { checks.push({ id, status: 'warn', message: msg }); }
|
|
255
|
+
function fail(id, msg) { checks.push({ id, status: 'fail', message: msg }); fatal = true; }
|
|
256
|
+
|
|
257
|
+
// ── 1. Profile presence & schema ──────────────────────────────────
|
|
258
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
259
|
+
fail('profile.present', 'Country profile is missing or not a valid object.');
|
|
260
|
+
return { ok: false, countryCode: null, countryName: null, bounds: null, checks, error: 'Country profile is missing.' };
|
|
261
|
+
}
|
|
262
|
+
pass('profile.present', 'Country profile loaded.');
|
|
263
|
+
|
|
264
|
+
const profileSchema = profile.schema_id;
|
|
265
|
+
if (profileSchema !== 'oglap.country_profile.v2') {
|
|
266
|
+
fail('profile.schema', `Expected schema "oglap.country_profile.v2", got "${profileSchema || '(none)'}".`);
|
|
267
|
+
} else {
|
|
268
|
+
pass('profile.schema', `Profile schema: ${profileSchema}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── 2. Profile required fields ────────────────────────────────────
|
|
272
|
+
const meta = profile.meta;
|
|
273
|
+
if (!meta?.country_oglap_code && !meta?.iso_alpha_2) {
|
|
274
|
+
fail('profile.meta.country_code', 'Profile meta missing both country_oglap_code and iso_alpha_2.');
|
|
275
|
+
} else {
|
|
276
|
+
pass('profile.meta.country_code', `Country code: ${meta.country_oglap_code || meta.iso_alpha_2}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!profile.country_extent?.country_sw || !profile.country_extent?.country_bounds) {
|
|
280
|
+
fail('profile.country_extent', 'Profile missing country_extent (country_sw or country_bounds).');
|
|
281
|
+
} else {
|
|
282
|
+
pass('profile.country_extent', 'Country extent defined.');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!profile.grid_settings) {
|
|
286
|
+
fail('profile.grid_settings', 'Profile missing grid_settings section.');
|
|
287
|
+
} else {
|
|
288
|
+
pass('profile.grid_settings', 'Grid settings present.');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!profile.zone_naming?.type_prefix_map) {
|
|
292
|
+
fail('profile.zone_naming', 'Profile missing zone_naming.type_prefix_map.');
|
|
293
|
+
} else {
|
|
294
|
+
pass('profile.zone_naming', `Zone naming rules loaded (${Object.keys(profile.zone_naming.type_prefix_map).length} type prefixes).`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── 3. Package version compatibility ──────────────────────────────
|
|
298
|
+
const compat = profile.compatibility;
|
|
299
|
+
if (!compat) {
|
|
300
|
+
warn('profile.compatibility', 'Profile has no compatibility section — skipping version checks.');
|
|
301
|
+
} else {
|
|
302
|
+
const range = compat.oglap_package_range;
|
|
303
|
+
if (!range || typeof range !== 'string') {
|
|
304
|
+
warn('compat.package_range', 'No oglap_package_range specified in profile — skipping package version check.');
|
|
305
|
+
} else {
|
|
306
|
+
const rangeBase = range.replace(/^\^/, '');
|
|
307
|
+
if (satisfiesCaret(PACKAGE_VERSION, rangeBase)) {
|
|
308
|
+
pass('compat.package_range', `Package v${PACKAGE_VERSION} satisfies required range "${range}".`);
|
|
309
|
+
} else {
|
|
310
|
+
fail('compat.package_range', `Package v${PACKAGE_VERSION} does NOT satisfy required range "${range}". Update the OGLAP package or use a compatible profile.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── 4. Localities naming presence & schema ────────────────────────
|
|
316
|
+
if (!localitiesNaming || typeof localitiesNaming !== 'object' || Array.isArray(localitiesNaming)) {
|
|
317
|
+
fail('localities.present', 'Localities naming data is missing or not a valid object.');
|
|
318
|
+
return { ok: false, countryCode: null, countryName: null, bounds: null, checks, error: 'Localities naming data is missing.' };
|
|
319
|
+
}
|
|
320
|
+
pass('localities.present', 'Localities naming data loaded.');
|
|
321
|
+
|
|
322
|
+
const locSchema = localitiesNaming.schema_id;
|
|
323
|
+
if (locSchema !== 'oglap.localities_naming.v1') {
|
|
324
|
+
fail('localities.schema', `Expected localities schema "oglap.localities_naming.v1", got "${locSchema || '(none)'}".`);
|
|
325
|
+
} else {
|
|
326
|
+
pass('localities.schema', `Localities schema: ${locSchema}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── 5. Country code alignment ─────────────────────────────────────
|
|
330
|
+
const profileCountry = meta?.country_oglap_code || meta?.iso_alpha_2;
|
|
331
|
+
const locCountry = localitiesNaming.country;
|
|
332
|
+
if (profileCountry && locCountry && profileCountry !== locCountry) {
|
|
333
|
+
fail('compat.country_match', `Country mismatch: profile="${profileCountry}", localities="${locCountry}".`);
|
|
334
|
+
} else if (profileCountry && locCountry) {
|
|
335
|
+
pass('compat.country_match', `Country codes match: "${profileCountry}".`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── 6. Dataset version compatibility ──────────────────────────────
|
|
339
|
+
const datasetVersions = compat?.dataset_versions;
|
|
340
|
+
const locGeneratedAt = localitiesNaming.generated_at;
|
|
341
|
+
if (!datasetVersions || !Array.isArray(datasetVersions) || datasetVersions.length === 0) {
|
|
342
|
+
warn('compat.dataset_version', 'Profile has no dataset_versions list — skipping dataset compatibility check.');
|
|
343
|
+
} else if (!locGeneratedAt) {
|
|
344
|
+
fail('compat.dataset_version', 'Localities naming has no generated_at timestamp — cannot verify dataset compatibility.');
|
|
345
|
+
} else if (!datasetVersions.includes(locGeneratedAt)) {
|
|
346
|
+
fail('compat.dataset_version', `Localities naming timestamp "${locGeneratedAt}" is not in profile's compatible dataset_versions [${datasetVersions.join(', ')}].`);
|
|
347
|
+
} else {
|
|
348
|
+
pass('compat.dataset_version', `Localities naming dataset version "${locGeneratedAt}" is compatible with profile.`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── 7. Localities naming references expected source db ────────────
|
|
352
|
+
const locSource = localitiesNaming.source;
|
|
353
|
+
if (!locSource) {
|
|
354
|
+
warn('localities.source', 'Localities naming has no source field — cannot verify which gn_full database was used.');
|
|
355
|
+
} else {
|
|
356
|
+
pass('localities.source', `Localities naming was generated from source: "${locSource}".`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── 8. Localities naming structural check (admin levels) ──────────
|
|
360
|
+
const hasL4 = localitiesNaming.level_4_regions && Object.keys(localitiesNaming.level_4_regions).length > 0;
|
|
361
|
+
const hasL6 = localitiesNaming.level_6_prefectures && Object.keys(localitiesNaming.level_6_prefectures).length > 0;
|
|
362
|
+
const hasZones = (
|
|
363
|
+
(localitiesNaming.level_8_sous_prefectures && Object.keys(localitiesNaming.level_8_sous_prefectures).length > 0) ||
|
|
364
|
+
(localitiesNaming.level_9_villages && Object.keys(localitiesNaming.level_9_villages).length > 0) ||
|
|
365
|
+
(localitiesNaming.level_10_quartiers && Object.keys(localitiesNaming.level_10_quartiers).length > 0)
|
|
366
|
+
);
|
|
367
|
+
if (!hasL4) {
|
|
368
|
+
fail('localities.level_4', 'Localities naming has no level_4_regions entries — regions cannot be resolved.');
|
|
369
|
+
} else {
|
|
370
|
+
pass('localities.level_4', `Level 4 regions: ${Object.keys(localitiesNaming.level_4_regions).length} entries.`);
|
|
371
|
+
}
|
|
372
|
+
if (!hasL6) {
|
|
373
|
+
warn('localities.level_6', 'Localities naming has no level_6_prefectures — prefecture resolution will use fallbacks.');
|
|
374
|
+
} else {
|
|
375
|
+
pass('localities.level_6', `Level 6 prefectures: ${Object.keys(localitiesNaming.level_6_prefectures).length} entries.`);
|
|
376
|
+
}
|
|
377
|
+
if (!hasZones) {
|
|
378
|
+
warn('localities.zones', 'Localities naming has no zone entries (levels 8/9/10) — local grid addressing will be unavailable.');
|
|
379
|
+
} else {
|
|
380
|
+
const count =
|
|
381
|
+
Object.keys(localitiesNaming.level_8_sous_prefectures || {}).length +
|
|
382
|
+
Object.keys(localitiesNaming.level_9_villages || {}).length +
|
|
383
|
+
Object.keys(localitiesNaming.level_10_quartiers || {}).length;
|
|
384
|
+
pass('localities.zones', `Zone entries (levels 8/9/10): ${count} total.`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── If any fatal check failed, abort before applying state ────────
|
|
388
|
+
if (fatal) {
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
countryCode: profileCountry || null,
|
|
392
|
+
countryName: meta?.country_name || null,
|
|
393
|
+
bounds: null,
|
|
394
|
+
checks,
|
|
395
|
+
error: checks.filter(c => c.status === 'fail').map(c => c.message).join(' ')
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── All checks passed — apply state ───────────────────────────────
|
|
400
|
+
COUNTRY_PROFILE = profile;
|
|
401
|
+
COUNTRY_CODE = profileCountry || 'GN';
|
|
402
|
+
|
|
403
|
+
OGLAP_COUNTRY_REGIONS = mapFromCodeTable(localitiesNaming.level_4_regions);
|
|
404
|
+
|
|
405
|
+
OGLAP_COUNTRY_REGIONS_REVERSE = Object.fromEntries(
|
|
406
|
+
Object.entries(OGLAP_COUNTRY_REGIONS).map(([iso, code]) => [code, iso])
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
OGLAP_COUNTRY_PREFECTURES = mapFromCodeTable(localitiesNaming.level_6_prefectures);
|
|
410
|
+
|
|
411
|
+
// Cache zone codes by ID (levels 8, 9, 10)
|
|
412
|
+
OGLAP_ZONE_CODES_BY_ID.clear();
|
|
413
|
+
const zones = [
|
|
414
|
+
...(Object.values(localitiesNaming.level_8_sous_prefectures || {})),
|
|
415
|
+
...(Object.values(localitiesNaming.level_9_villages || {})),
|
|
416
|
+
...(Object.values(localitiesNaming.level_10_quartiers || {}))
|
|
417
|
+
];
|
|
418
|
+
for (const z of zones) {
|
|
419
|
+
if (z.place_id && z.oglap_code) {
|
|
420
|
+
OGLAP_ZONE_CODES_BY_ID.set(z.place_id.toString(), z.oglap_code);
|
|
421
|
+
OGLAP_ZONE_CODES_BY_ID.set(Number(z.place_id), z.oglap_code);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { default: defaultZone = 'Z', ...prefixMap } = profile.zone_naming?.type_prefix_map || {};
|
|
426
|
+
ZONE_TYPE_PREFIX_DEFAULT = defaultZone;
|
|
427
|
+
ZONE_TYPE_PREFIX = prefixMap;
|
|
428
|
+
|
|
429
|
+
GGP_STOPWORDS = new Set(
|
|
430
|
+
(profile.zone_naming?.stopwords || []).map((s) => String(s).toUpperCase())
|
|
431
|
+
);
|
|
432
|
+
GGP_PAD_CHAR = profile.zone_naming?.padding_char || 'X';
|
|
433
|
+
|
|
434
|
+
COUNTRY_SW = profile.country_extent?.country_sw || [7.19, -15.37];
|
|
435
|
+
COUNTRY_BOUNDS = {
|
|
436
|
+
sw: profile.country_extent?.country_bounds?.sw || [7.19, -15.37],
|
|
437
|
+
ne: profile.country_extent?.country_bounds?.ne || [12.68, -7.64],
|
|
438
|
+
};
|
|
439
|
+
METERS_PER_DEGREE_LAT = profile.grid_settings?.distance_conversion?.meters_per_degree_lat || 111320;
|
|
440
|
+
|
|
441
|
+
const boundsArr = [COUNTRY_BOUNDS.sw, COUNTRY_BOUNDS.ne];
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
countryCode: COUNTRY_CODE,
|
|
446
|
+
countryName: meta?.country_name || 'Country',
|
|
447
|
+
bounds: boundsArr,
|
|
448
|
+
checks,
|
|
449
|
+
error: null
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Initialize the OGLAP engine.
|
|
455
|
+
*
|
|
456
|
+
* **Download mode** (recommended for standalone use):
|
|
457
|
+
* Downloads required files from S3, saves them to a local `oglap-data/{version}/`
|
|
458
|
+
* folder, and loads them into the engine. On subsequent calls, files are loaded
|
|
459
|
+
* directly from the local cache (skipping the download) unless `forceDownload` is set.
|
|
460
|
+
* ```js
|
|
461
|
+
* // Simplest — downloads latest version:
|
|
462
|
+
* const report = await initOglap();
|
|
463
|
+
*
|
|
464
|
+
* // With options:
|
|
465
|
+
* const report = await initOglap({
|
|
466
|
+
* version: 'v1.0.0', // pin a specific version (default: 'latest')
|
|
467
|
+
* dataDir: './oglap-data', // local cache folder (default)
|
|
468
|
+
* forceDownload: false, // set true to re-download even if cached
|
|
469
|
+
* onProgress: ({ label, step, totalSteps, percent, status }) => { ... }
|
|
470
|
+
* });
|
|
471
|
+
* ```
|
|
472
|
+
*
|
|
473
|
+
* **Direct mode** (when data is already loaded in memory):
|
|
474
|
+
* ```js
|
|
475
|
+
* const report = await initOglap(profileObj, localitiesNamingObj);
|
|
476
|
+
* // then load places separately:
|
|
477
|
+
* loadOglap(placesArray);
|
|
478
|
+
* ```
|
|
479
|
+
*
|
|
480
|
+
* @param {Object} [profileOrOptions] - Country profile object (direct mode), options hash (download mode), or omit for latest.
|
|
481
|
+
* @param {string} [profileOrOptions.version='latest'] - Dataset version to fetch (e.g. 'latest', 'v1.0.0').
|
|
482
|
+
* @param {string} [profileOrOptions.dataDir='oglap-data'] - Local folder for cached data files.
|
|
483
|
+
* @param {boolean} [profileOrOptions.forceDownload=false] - Re-download all files even if they exist locally.
|
|
484
|
+
* @param {string} [profileOrOptions.baseUrl] - Override the S3 base URL.
|
|
485
|
+
* @param {Function} [profileOrOptions.onProgress] - Progress callback.
|
|
486
|
+
* @param {Object} [localitiesNaming] - Localities naming object (direct mode only).
|
|
487
|
+
* @returns {Promise<{ ok: boolean, countryCode: string|null, countryName: string|null, bounds: number[][]|null, checks: Array, error: string|null, dataDir?: string, dataLoaded?: Object }>}
|
|
488
|
+
*/
|
|
489
|
+
export async function initOglap(profileOrOptions, localitiesNaming) {
|
|
490
|
+
_initialized = false;
|
|
491
|
+
_initReport = null;
|
|
492
|
+
|
|
493
|
+
// ── Direct mode: initOglap(profileObj, localitiesObj) ──
|
|
494
|
+
const isDirect = localitiesNaming !== undefined ||
|
|
495
|
+
(profileOrOptions != null && typeof profileOrOptions === 'object' && 'schema_id' in profileOrOptions);
|
|
496
|
+
|
|
497
|
+
if (isDirect) {
|
|
498
|
+
const report = _validateAndApply(profileOrOptions, localitiesNaming);
|
|
499
|
+
_initialized = report.ok;
|
|
500
|
+
_initReport = report;
|
|
501
|
+
return report;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Download mode: initOglap({ version, onProgress, baseUrl, dataDir, forceDownload }) ──
|
|
505
|
+
const opts = profileOrOptions || {};
|
|
506
|
+
const version = opts.version || 'latest';
|
|
507
|
+
const baseUrl = opts.baseUrl || OGLAP_S3_BASE;
|
|
508
|
+
const dataDir = opts.dataDir || OGLAP_DATA_DIR_DEFAULT;
|
|
509
|
+
const forceDownload = !!opts.forceDownload;
|
|
510
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => { };
|
|
511
|
+
const vUrl = `${baseUrl}/${version}`;
|
|
512
|
+
const checks = [];
|
|
513
|
+
const loaded = {};
|
|
514
|
+
|
|
515
|
+
// ── Resolve & create local data directory: oglap-data/{version}/ ──
|
|
516
|
+
let versionDir;
|
|
517
|
+
try {
|
|
518
|
+
const { path } = await _getNodeModules();
|
|
519
|
+
versionDir = path.resolve(dataDir, version);
|
|
520
|
+
await _ensureDir(versionDir);
|
|
521
|
+
checks.push({ id: 'storage.dir', status: 'pass', message: `Data directory ready: ${versionDir}` });
|
|
522
|
+
} catch (err) {
|
|
523
|
+
checks.push({ id: 'storage.dir', status: 'fail', message: `Cannot create data directory: ${err.message}` });
|
|
524
|
+
_initReport = { ok: false, countryCode: null, countryName: null, bounds: null, checks, error: checks.at(-1).message };
|
|
525
|
+
return _initReport;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Helper: get a file — from local cache or download + save ──
|
|
529
|
+
async function getFile(fileSpec, step, totalSteps) {
|
|
530
|
+
const { path } = await _getNodeModules();
|
|
531
|
+
const filePath = path.join(versionDir, fileSpec.name);
|
|
532
|
+
|
|
533
|
+
// Try local cache first (unless forced)
|
|
534
|
+
if (!forceDownload && await _fileExists(filePath)) {
|
|
535
|
+
onProgress({ file: fileSpec.name, label: fileSpec.label, step, totalSteps, status: 'cached', loaded: 0, total: 0, percent: 100 });
|
|
536
|
+
try {
|
|
537
|
+
const parsed = await _readJsonFile(filePath);
|
|
538
|
+
checks.push({ id: `local.${fileSpec.key}`, status: 'pass', message: `${fileSpec.label}: loaded from local cache.` });
|
|
539
|
+
return parsed;
|
|
540
|
+
} catch (readErr) {
|
|
541
|
+
// Local file corrupted — fall through to re-download
|
|
542
|
+
checks.push({ id: `local.${fileSpec.key}`, status: 'warn', message: `Local ${fileSpec.label} is invalid (${readErr.message}), re-downloading.` });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Download from S3
|
|
547
|
+
let slowNotified = false;
|
|
548
|
+
onProgress({ file: fileSpec.name, label: fileSpec.label, step, totalSteps, status: 'downloading', loaded: 0, total: 0, percent: 0 });
|
|
549
|
+
const text = await _fetchWithProgress(`${vUrl}/${fileSpec.name}`, {
|
|
550
|
+
timeoutMs: fileSpec.timeoutMs,
|
|
551
|
+
onChunk({ loaded: ld, total: tot, percent: pct, slow }) {
|
|
552
|
+
if (slow && !slowNotified) {
|
|
553
|
+
slowNotified = true;
|
|
554
|
+
onProgress({ file: fileSpec.name, label: fileSpec.label, step, totalSteps, status: 'slow', loaded: ld, total: tot, percent: pct });
|
|
555
|
+
}
|
|
556
|
+
onProgress({ file: fileSpec.name, label: fileSpec.label, step, totalSteps, status: 'downloading', loaded: ld, total: tot, percent: pct });
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Parse JSON
|
|
561
|
+
let parsed;
|
|
562
|
+
try { parsed = JSON.parse(text); } catch (e) { throw new Error(`Invalid JSON in ${fileSpec.name}: ${e.message}`); }
|
|
563
|
+
|
|
564
|
+
// Save to local cache
|
|
565
|
+
try {
|
|
566
|
+
await _writeFile(filePath, text);
|
|
567
|
+
checks.push({ id: `save.${fileSpec.key}`, status: 'pass', message: `${fileSpec.label}: downloaded and saved to ${filePath}` });
|
|
568
|
+
} catch (saveErr) {
|
|
569
|
+
checks.push({ id: `save.${fileSpec.key}`, status: 'warn', message: `${fileSpec.label}: downloaded but failed to save locally (${saveErr.message}).` });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
onProgress({ file: fileSpec.name, label: fileSpec.label, step, totalSteps, status: 'done', loaded: 0, total: 0, percent: 100 });
|
|
573
|
+
return parsed;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── Helper: report file retrieval failure ──
|
|
577
|
+
function fileFail(fileSpec, step, err) {
|
|
578
|
+
onProgress({ file: fileSpec.name, label: fileSpec.label, step, totalSteps: 3, status: 'error', loaded: 0, total: 0, percent: 0, error: err.message });
|
|
579
|
+
checks.push({ id: `fetch.${fileSpec.key}`, status: 'fail', message: `Failed to get ${fileSpec.label}: ${err.message}` });
|
|
580
|
+
_initReport = { ok: false, countryCode: null, countryName: null, bounds: null, checks, error: checks.at(-1).message, dataDir: versionDir };
|
|
581
|
+
return _initReport;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Step 1/3: Country profile
|
|
585
|
+
try {
|
|
586
|
+
loaded.profile = await getFile(OGLAP_REMOTE_FILES[0], 1, 3);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
return fileFail(OGLAP_REMOTE_FILES[0], 1, err);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Step 2/3: Localities naming
|
|
592
|
+
try {
|
|
593
|
+
loaded.localities = await getFile(OGLAP_REMOTE_FILES[1], 2, 3);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
return fileFail(OGLAP_REMOTE_FILES[1], 2, err);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Validate profile + localities before fetching the large data file
|
|
599
|
+
onProgress({ file: '', label: 'Validating configuration', step: 0, totalSteps: 0, status: 'validating', loaded: 0, total: 0, percent: 0 });
|
|
600
|
+
const report = _validateAndApply(loaded.profile, loaded.localities, checks);
|
|
601
|
+
if (!report.ok) {
|
|
602
|
+
_initReport = report;
|
|
603
|
+
report.dataDir = versionDir;
|
|
604
|
+
return report;
|
|
605
|
+
}
|
|
606
|
+
_initialized = true;
|
|
607
|
+
|
|
608
|
+
// Step 3/3: Places database (large file)
|
|
609
|
+
try {
|
|
610
|
+
loaded.data = await getFile(OGLAP_REMOTE_FILES[2], 3, 3);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
const f = OGLAP_REMOTE_FILES[2];
|
|
613
|
+
onProgress({ file: f.name, label: f.label, step: 3, totalSteps: 3, status: 'error', loaded: 0, total: 0, percent: 0, error: err.message });
|
|
614
|
+
report.checks.push({ id: `fetch.${f.key}`, status: 'fail', message: `Failed to get ${f.label}: ${err.message}` });
|
|
615
|
+
report.ok = false;
|
|
616
|
+
report.error = `Failed to get ${f.label}: ${err.message}`;
|
|
617
|
+
report.dataDir = versionDir;
|
|
618
|
+
_initReport = report;
|
|
619
|
+
return report;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Load places into engine
|
|
623
|
+
const loadResult = loadOglap(loaded.data);
|
|
624
|
+
report.checks.push({ id: 'data.load', status: loadResult.ok ? 'pass' : 'fail', message: loadResult.message });
|
|
625
|
+
if (!loadResult.ok) {
|
|
626
|
+
report.ok = false;
|
|
627
|
+
report.error = loadResult.message;
|
|
628
|
+
}
|
|
629
|
+
report.dataLoaded = loadResult;
|
|
630
|
+
report.dataDir = versionDir;
|
|
631
|
+
_initReport = report;
|
|
632
|
+
return report;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --- DATA & CACHE ---
|
|
636
|
+
let places = [];
|
|
637
|
+
let lapSearchIndex = null;
|
|
638
|
+
let upperAdminLetterCache = new Map();
|
|
639
|
+
let adminLevel6PlacesCache = null;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Loads geojson places into the in-memory engine. Clears existing search and geometry caches.
|
|
643
|
+
* Validates that initOglap was called first, and that data is a non-empty array
|
|
644
|
+
* of objects with the expected shape (place_id, geojson or address).
|
|
645
|
+
*
|
|
646
|
+
* @param {Array<Object>} data - Array of place objects from OGLAP source (e.g. gn_full.json).
|
|
647
|
+
* @returns {{ ok: boolean, count: number, message: string }}
|
|
648
|
+
*/
|
|
649
|
+
export function loadOglap(data) {
|
|
650
|
+
lapSearchIndex = null;
|
|
651
|
+
upperAdminLetterCache.clear();
|
|
652
|
+
adminLevel6PlacesCache = null;
|
|
653
|
+
|
|
654
|
+
if (!_initialized) {
|
|
655
|
+
places = [];
|
|
656
|
+
return { ok: false, count: 0, message: 'Cannot load data: initOglap must be called first with a valid profile and localities naming.' };
|
|
657
|
+
}
|
|
658
|
+
if (!Array.isArray(data)) {
|
|
659
|
+
places = [];
|
|
660
|
+
return { ok: false, count: 0, message: 'Data must be an array of place objects.' };
|
|
661
|
+
}
|
|
662
|
+
if (data.length === 0) {
|
|
663
|
+
places = [];
|
|
664
|
+
return { ok: false, count: 0, message: 'Data array is empty — no places to load.' };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Spot-check: first entry should look like a place object
|
|
668
|
+
const sample = data[0];
|
|
669
|
+
if (typeof sample !== 'object' || sample === null) {
|
|
670
|
+
places = [];
|
|
671
|
+
return { ok: false, count: 0, message: 'Data entries are not valid objects.' };
|
|
672
|
+
}
|
|
673
|
+
const hasGeometry = !!sample.geojson;
|
|
674
|
+
const hasAddress = !!sample.address;
|
|
675
|
+
const hasPlaceId = sample.place_id != null;
|
|
676
|
+
if (!hasPlaceId && !hasGeometry && !hasAddress) {
|
|
677
|
+
places = [];
|
|
678
|
+
return { ok: false, count: 0, message: 'Data entries do not appear to be OGLAP place objects (missing place_id, geojson, and address).' };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
places = data;
|
|
682
|
+
|
|
683
|
+
// Cache the country border polygon (admin_level 2) for boundary checks
|
|
684
|
+
COUNTRY_BORDER_GEOJSON = null;
|
|
685
|
+
const countryPlace = data.find(p =>
|
|
686
|
+
(p.extratags?.admin_level === '2' || p.extratags?.admin_level === 2) &&
|
|
687
|
+
(p.geojson?.type === 'Polygon' || p.geojson?.type === 'MultiPolygon')
|
|
688
|
+
);
|
|
689
|
+
if (countryPlace) {
|
|
690
|
+
COUNTRY_BORDER_GEOJSON = countryPlace.geojson;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const withGeometry = data.filter(p => p.geojson).length;
|
|
694
|
+
return {
|
|
695
|
+
ok: true,
|
|
696
|
+
count: data.length,
|
|
697
|
+
message: `Loaded ${data.length} places (${withGeometry} with geometry).`
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Retrieves the currently loaded geography places.
|
|
703
|
+
* @returns {Array<Object>}
|
|
704
|
+
*/
|
|
705
|
+
export function getOglapPlaces() { return places; }
|
|
706
|
+
|
|
707
|
+
// ——— Grid & reference stability (basemap- and language-independent) ———
|
|
708
|
+
// - Origin: zone bbox SW from our reference data (gn_full.json), never from map tiles or labels.
|
|
709
|
+
// - Meters per degree: fixed WGS84 approximation (111320, 111320*cos(lat)); same everywhere.
|
|
710
|
+
// - ADMIN_LEVEL_2/ADMIN_LEVEL_3 codes: from OGLAP-GN tables + GGP naming rules applied to our data only.
|
|
711
|
+
// - LAP codes are therefore stable regardless of which basemap is shown or its language.
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
function metersPerDegreeLat() {
|
|
715
|
+
return METERS_PER_DEGREE_LAT;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function metersPerDegreeLon(latDeg) {
|
|
719
|
+
const latRad = (latDeg * Math.PI) / 180;
|
|
720
|
+
return METERS_PER_DEGREE_LAT * Math.cos(latRad);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* GGP Section 6: Name normalization — uppercase, remove accents, hyphens/underscores to space, remove punctuation.
|
|
725
|
+
*/
|
|
726
|
+
function normalizeNameForGGP(name) {
|
|
727
|
+
if (!name || typeof name !== 'string') return '';
|
|
728
|
+
return name
|
|
729
|
+
.normalize('NFD')
|
|
730
|
+
.replace(/\p{M}/gu, '')
|
|
731
|
+
.toUpperCase()
|
|
732
|
+
.replace(/[-_]/g, ' ')
|
|
733
|
+
.replace(/['.,\/()]/g, '')
|
|
734
|
+
.replace(/\s+/g, ' ')
|
|
735
|
+
.trim();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* GGP Section 7: Remove stopwords from token list (French + locality fillers).
|
|
740
|
+
*/
|
|
741
|
+
function removeStopwords(tokens) {
|
|
742
|
+
return tokens.filter((t) => t.length > 0 && !GGP_STOPWORDS.has(t));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Consonants only (A,E,I,O,U,Y excluded) for abbreviation. */
|
|
746
|
+
const CONSONANTS = new Set('BCDFGHJKLMNPQRSTVWXZ');
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Two-letter consonant abbreviation from significant tokens (reduces collision e.g. Boulbinet vs Boussoura).
|
|
750
|
+
* Chars 2–3 of zone code = first 2 consonants in order from the name.
|
|
751
|
+
*/
|
|
752
|
+
function consonantAbbrev2(significantTokens) {
|
|
753
|
+
const str = significantTokens.join('');
|
|
754
|
+
const cons = [];
|
|
755
|
+
for (const c of str.toUpperCase()) {
|
|
756
|
+
if (CONSONANTS.has(c)) cons.push(c);
|
|
757
|
+
if (cons.length >= 2) break;
|
|
758
|
+
}
|
|
759
|
+
if (cons.length >= 2) return cons.join('');
|
|
760
|
+
if (cons.length === 1) return cons[0] + GGP_PAD_CHAR;
|
|
761
|
+
return GGP_PAD_CHAR + GGP_PAD_CHAR;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* First letter of direct upper admin subdivision (prefecture/county or region).
|
|
766
|
+
* Used as 4th char of zone code to tie zone to parent admin.
|
|
767
|
+
*/
|
|
768
|
+
function getAdminLevel6IsoFromAddress(address) {
|
|
769
|
+
return address?.['ISO3166-2-Lvl6'] || address?.['ISO3166-2-lvl6'] || null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function normalizedFirstLetter(name) {
|
|
773
|
+
const normalized = normalizeNameForGGP(name || '');
|
|
774
|
+
const match = normalized.match(/[A-Z]/);
|
|
775
|
+
return match ? match[0] : null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function stripPrefecturePrefix(name) {
|
|
779
|
+
return String(name || '').replace(/^(Préfecture|Prefecture)\s+(de\s+)?/i, '').trim();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Infer prefecture-level admin name by point containment (admin_level=6). */
|
|
783
|
+
function getAdminLevel6NameFromContainment(lon, lat) {
|
|
784
|
+
if (!Array.isArray(places) || places.length === 0) return null;
|
|
785
|
+
if (!adminLevel6PlacesCache) {
|
|
786
|
+
adminLevel6PlacesCache = places.filter((p) => {
|
|
787
|
+
const level = p.extratags?.admin_level != null ? parseInt(p.extratags.admin_level, 10) : 0;
|
|
788
|
+
return level === 6;
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
for (const place of adminLevel6PlacesCache) {
|
|
792
|
+
if (!place?.geojson) continue;
|
|
793
|
+
if (!pointInGeometry(lon, lat, place.geojson)) continue;
|
|
794
|
+
const pAddress = place.address || {};
|
|
795
|
+
const iso6 = getAdminLevel6IsoFromAddress(pAddress);
|
|
796
|
+
const profileName = iso6 ? COUNTRY_PROFILE?.admin_codes?.level_6_prefectures?.[iso6]?.name : null;
|
|
797
|
+
if (profileName) return profileName;
|
|
798
|
+
return stripPrefecturePrefix(pAddress.county || pAddress.state || place.display_name?.split(',')[0] || '');
|
|
799
|
+
}
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function upperAdminFirstLetter(address, place = null) {
|
|
804
|
+
const cacheKey = place?.place_id;
|
|
805
|
+
if (cacheKey != null && upperAdminLetterCache.has(cacheKey)) {
|
|
806
|
+
return upperAdminLetterCache.get(cacheKey);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const countyName = stripPrefecturePrefix(address?.county);
|
|
810
|
+
let resolved = normalizedFirstLetter(countyName);
|
|
811
|
+
if (!resolved) resolved = normalizedFirstLetter(address?.state);
|
|
812
|
+
|
|
813
|
+
if (!resolved) {
|
|
814
|
+
const iso6 = getAdminLevel6IsoFromAddress(address || {});
|
|
815
|
+
const profilePrefName = iso6 ? COUNTRY_PROFILE?.admin_codes?.level_6_prefectures?.[iso6]?.name : null;
|
|
816
|
+
resolved = normalizedFirstLetter(stripPrefecturePrefix(profilePrefName || ''));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!resolved && place) {
|
|
820
|
+
const centroid = centroidFromPlace(place);
|
|
821
|
+
if (centroid) {
|
|
822
|
+
const [lat, lon] = centroid;
|
|
823
|
+
const prefByContainment = getAdminLevel6NameFromContainment(lon, lat);
|
|
824
|
+
resolved = normalizedFirstLetter(stripPrefecturePrefix(prefByContainment || ''));
|
|
825
|
+
if (!resolved) {
|
|
826
|
+
const regionIso = getAdminLevel2IsoWithFallback(lat, lon, place, { skipSampling: true });
|
|
827
|
+
const regionName = COUNTRY_PROFILE?.admin_codes?.level_4_regions?.[regionIso]?.name;
|
|
828
|
+
resolved = normalizedFirstLetter(regionName);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (!resolved) resolved = GGP_PAD_CHAR;
|
|
834
|
+
if (cacheKey != null) upperAdminLetterCache.set(cacheKey, resolved);
|
|
835
|
+
return resolved;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Zone key = 2 consonants from name + 1 letter from direct upper admin (4th char).
|
|
840
|
+
* Format: [Type][Consonant][Consonant][UpperAdmin].
|
|
841
|
+
*/
|
|
842
|
+
function nameKeyFromTokens(significantTokens, address, place = null) {
|
|
843
|
+
if (!significantTokens.length) return 'XXX';
|
|
844
|
+
const two = consonantAbbrev2(significantTokens);
|
|
845
|
+
const upper = address ? upperAdminFirstLetter(address, place) : GGP_PAD_CHAR;
|
|
846
|
+
return (two + upper).slice(0, 3);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* GGP Fallback A: when base key collides, use 2 consonants + first letter of state (if different from county).
|
|
851
|
+
*/
|
|
852
|
+
function nameKeyFallbackA(significantTokens, address) {
|
|
853
|
+
if (!significantTokens.length || !address?.state) return null;
|
|
854
|
+
const two = consonantAbbrev2(significantTokens);
|
|
855
|
+
const stateFirst = (address.state || '').slice(0, 1).toUpperCase();
|
|
856
|
+
if (!/[A-Z]/.test(stateFirst)) return null;
|
|
857
|
+
return (two + stateFirst).slice(0, 3);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Get significant tokens for a zone name (normalize + stopwords). Returns array in document order.
|
|
862
|
+
*/
|
|
863
|
+
function getSignificantTokens(name) {
|
|
864
|
+
const normalized = normalizeNameForGGP(name);
|
|
865
|
+
if (!normalized) return [];
|
|
866
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
867
|
+
return removeStopwords(tokens);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* GGP Zone code = [PREFIX] + [KEY3]. KEY3 = 2 consonants + 1 upper-admin letter.
|
|
872
|
+
*/
|
|
873
|
+
function zoneCodeFromNameAndType(name, typePrefix, address) {
|
|
874
|
+
const prefix = typePrefix || 'Z';
|
|
875
|
+
const significant = getSignificantTokens(name);
|
|
876
|
+
if (!significant.length) return prefix + 'XXX';
|
|
877
|
+
const key = nameKeyFromTokens(significant, address, null);
|
|
878
|
+
return prefix + key;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Computes and caches the bounding box of a place.
|
|
883
|
+
* @param {Object} place
|
|
884
|
+
* @returns {number[]|null} [minLat, maxLat, minLon, maxLon]
|
|
885
|
+
*/
|
|
886
|
+
function getCachedBbox(place) {
|
|
887
|
+
if (!place || !place.geojson) return null;
|
|
888
|
+
if (place._computedBbox) return place._computedBbox;
|
|
889
|
+
place._computedBbox = bboxFromGeometry(place.geojson);
|
|
890
|
+
return place._computedBbox;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Computes and caches the centroid [lat, lon] of a place from its geometry bbox.
|
|
895
|
+
* @param {Object} place
|
|
896
|
+
* @returns {number[]|null}
|
|
897
|
+
*/
|
|
898
|
+
function centroidFromPlace(place) {
|
|
899
|
+
const bbox = getCachedBbox(place);
|
|
900
|
+
return bbox ? centroidFromBbox(bbox) : null;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Get base code and optional fallback A code for a place (for collision resolution).
|
|
905
|
+
* Zone key = 2 consonants from name + 1 letter from direct upper admin (prefecture/county).
|
|
906
|
+
*/
|
|
907
|
+
function getPlaceZoneCandidates(place) {
|
|
908
|
+
const address = place.address || {};
|
|
909
|
+
const name =
|
|
910
|
+
address.quarter || address.neighbourhood || address.suburb ||
|
|
911
|
+
address.village || address.hamlet || address.town || address.city ||
|
|
912
|
+
(place.display_name && place.display_name.split(',')[0]?.trim()) || '';
|
|
913
|
+
const placeType = place.type || place.addresstype || '';
|
|
914
|
+
const adminLevel = place.extratags?.admin_level != null
|
|
915
|
+
? parseInt(place.extratags.admin_level, 10) : undefined;
|
|
916
|
+
const prefix = getTypePrefixForZone(placeType, adminLevel);
|
|
917
|
+
const significant = getSignificantTokens(name);
|
|
918
|
+
if (!significant.length) return { prefix, baseCode: prefix + 'XXX', fallbackCode: null };
|
|
919
|
+
const baseKey = nameKeyFromTokens(significant, address, place);
|
|
920
|
+
const baseCode = prefix + baseKey;
|
|
921
|
+
const fallbackKey = nameKeyFallbackA(significant, address);
|
|
922
|
+
const fallbackCode = fallbackKey ? prefix + fallbackKey : null;
|
|
923
|
+
return { prefix, baseCode, fallbackCode };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Resolve type prefix from OSM place type and admin_level (GGP Section 5). */
|
|
927
|
+
function getTypePrefixForZone(placeType, adminLevel) {
|
|
928
|
+
const key = (placeType || '').toLowerCase();
|
|
929
|
+
if (adminLevel === 8) return 'S'; // Commune / Sous-préfecture
|
|
930
|
+
if (adminLevel === 10) return 'Q'; // Quartier boundary
|
|
931
|
+
if (ZONE_TYPE_PREFIX[key]) return ZONE_TYPE_PREFIX[key];
|
|
932
|
+
return ZONE_TYPE_PREFIX_DEFAULT;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* GGP Section 10 — Collision avoidance: build deterministic assignment of zone codes per ADMIN_LEVEL_2.
|
|
937
|
+
* For each place in the same ADMIN_LEVEL_2: try base code, then fallback A, then base[0:3]+digit 0–9.
|
|
938
|
+
* Uses effective ADMIN_LEVEL_2 (from address, then region containment, then sampling) so all places get a bucket.
|
|
939
|
+
*/
|
|
940
|
+
function buildAdminLevel2ZoneAssignments(admin_level_2_Iso) {
|
|
941
|
+
const isoKey = admin_level_2_Iso || '';
|
|
942
|
+
const inAdminLevel2 = places.filter((p) => effectiveAdminLevel2IsoForPlace(p, { skipSampling: true }) === isoKey);
|
|
943
|
+
const sorted = [...inAdminLevel2].sort((a, b) => (a.place_id || 0) - (b.place_id || 0));
|
|
944
|
+
const used = new Set();
|
|
945
|
+
const digitCountByBase = new Map(); // base prefix (3 chars) -> next digit to use
|
|
946
|
+
const assignment = new Map(); // place_id -> finalCode
|
|
947
|
+
|
|
948
|
+
for (const place of sorted) {
|
|
949
|
+
const { baseCode, fallbackCode } = getPlaceZoneCandidates(place);
|
|
950
|
+
const prefix3 = baseCode.slice(0, 3);
|
|
951
|
+
let finalCode = null;
|
|
952
|
+
if (!used.has(baseCode)) {
|
|
953
|
+
finalCode = baseCode;
|
|
954
|
+
} else if (fallbackCode && !used.has(fallbackCode)) {
|
|
955
|
+
finalCode = fallbackCode;
|
|
956
|
+
} else {
|
|
957
|
+
const next = digitCountByBase.get(prefix3) ?? 0;
|
|
958
|
+
const digit = Math.min(9, next);
|
|
959
|
+
digitCountByBase.set(prefix3, next + 1);
|
|
960
|
+
finalCode = prefix3 + String(digit);
|
|
961
|
+
}
|
|
962
|
+
used.add(finalCode);
|
|
963
|
+
assignment.set(place.place_id, finalCode);
|
|
964
|
+
}
|
|
965
|
+
return assignment;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Get effective ADMIN_LEVEL_2 ISO for a place (for grouping). skipSampling=true when building index (faster). */
|
|
969
|
+
function effectiveAdminLevel2IsoForPlace(place, opts = {}) {
|
|
970
|
+
const cen = centroidFromPlace(place);
|
|
971
|
+
if (cen) return getAdminLevel2IsoWithFallback(cen[0], cen[1], place, opts);
|
|
972
|
+
return getAdminLevel2IsoFromAddress(place.address || {}) || null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Get ADMIN_LEVEL_3 zone code for a place, respecting manual localities naming overrides first, with fallback to mathematical collision resolution within its ADMIN_LEVEL_2. */
|
|
976
|
+
function getAdminLevel3CodeWithCollision(place) {
|
|
977
|
+
if (!place) return null;
|
|
978
|
+
// Use explicit zone code from localities naming data if present!
|
|
979
|
+
if (place.place_id && OGLAP_ZONE_CODES_BY_ID.has(place.place_id)) {
|
|
980
|
+
return OGLAP_ZONE_CODES_BY_ID.get(place.place_id);
|
|
981
|
+
}
|
|
982
|
+
const admin_level_2_Iso = effectiveAdminLevel2IsoForPlace(place);
|
|
983
|
+
const assignments = buildAdminLevel2ZoneAssignments(admin_level_2_Iso);
|
|
984
|
+
return assignments.get(place.place_id) ?? getPlaceZoneCandidates(place).baseCode;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Get ADMIN_LEVEL_3 zone code from address/type/name/adminLevel (no collision; used when no place context). */
|
|
988
|
+
function getAdminLevel3Code(address, placeType, displayName, adminLevel) {
|
|
989
|
+
const name =
|
|
990
|
+
address?.quarter ||
|
|
991
|
+
address?.neighbourhood ||
|
|
992
|
+
address?.suburb ||
|
|
993
|
+
address?.village ||
|
|
994
|
+
address?.hamlet ||
|
|
995
|
+
address?.town ||
|
|
996
|
+
address?.city ||
|
|
997
|
+
(displayName && displayName.split(',')[0]?.trim()) ||
|
|
998
|
+
'';
|
|
999
|
+
const prefix = getTypePrefixForZone(placeType, adminLevel);
|
|
1000
|
+
return zoneCodeFromNameAndType(name, prefix, address);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ——— Macroblock dual strategy ———
|
|
1004
|
+
// Local (zone, admin_level ≥ 9): 4-char LetterDigitLetterDigit, 100 m cells, 1 m microspot.
|
|
1005
|
+
// National (fallback): 6-char XXXYYY (A-Z per axis, 26³ = 17 576 per axis),
|
|
1006
|
+
// 100 m cells, 1 m microspot.
|
|
1007
|
+
// Grid capacity: 17 576 × 100 m = 1 757.6 km per axis — enough for any country.
|
|
1008
|
+
const ALPHA3_MAX = 26 ** 3; // 17 576
|
|
1009
|
+
const NATIONAL_CELL_SIZE_M = 100;
|
|
1010
|
+
const NATIONAL_MICRO_SCALE = 1;
|
|
1011
|
+
|
|
1012
|
+
/** Encode integer 0..17 575 as 3 A-Z letters (AAA..ZZZ). */
|
|
1013
|
+
function encodeAlpha3(n) {
|
|
1014
|
+
const val = Math.max(0, Math.min(Math.floor(n), ALPHA3_MAX - 1));
|
|
1015
|
+
const c2 = val % 26;
|
|
1016
|
+
const c1 = Math.floor(val / 26) % 26;
|
|
1017
|
+
const c0 = Math.floor(val / 676);
|
|
1018
|
+
return String.fromCharCode(65 + c0, 65 + c1, 65 + c2);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Decode 3 A-Z letters to integer; returns -1 if invalid. */
|
|
1022
|
+
function decodeAlpha3(str) {
|
|
1023
|
+
if (!str || str.length !== 3) return -1;
|
|
1024
|
+
const u = str.toUpperCase();
|
|
1025
|
+
let n = 0;
|
|
1026
|
+
for (let i = 0; i < 3; i++) {
|
|
1027
|
+
const c = u.charCodeAt(i) - 65;
|
|
1028
|
+
if (c < 0 || c > 25) return -1;
|
|
1029
|
+
n = n * 26 + c;
|
|
1030
|
+
}
|
|
1031
|
+
return n;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Letter encoding for local macroblock: 0→A, 1→B, … 9→J. */
|
|
1035
|
+
function macroLetter(n) {
|
|
1036
|
+
return String.fromCharCode(65 + Math.min(9, Math.max(0, Math.floor(n))));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Encode local macroblock (zone): eastBlocks, northBlocks (100 m) → 4-char e.g. C2E6. */
|
|
1040
|
+
function encodeLocalMacroblock(eastBlocks, northBlocks) {
|
|
1041
|
+
const eTens = Math.floor(eastBlocks / 10);
|
|
1042
|
+
const eUnits = Math.floor(eastBlocks % 10);
|
|
1043
|
+
const nTens = Math.floor(northBlocks / 10);
|
|
1044
|
+
const nUnits = Math.floor(northBlocks % 10);
|
|
1045
|
+
return (
|
|
1046
|
+
macroLetter(eTens) + eUnits +
|
|
1047
|
+
macroLetter(nTens) + nUnits
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Encode national macroblock: eastKm, northKm → 6-char XXXYYY. */
|
|
1052
|
+
function encodeNationalMacroblock(eastKm, northKm) {
|
|
1053
|
+
return encodeAlpha3(eastKm) + encodeAlpha3(northKm);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Encode microspot: 0–99 east, 0–99 north → 4 digits e.g. 5020. */
|
|
1057
|
+
function encodeMicrospot(eastM, northM) {
|
|
1058
|
+
const e = Math.min(99, Math.max(0, Math.round(eastM)));
|
|
1059
|
+
const n = Math.min(99, Math.max(0, Math.round(northM)));
|
|
1060
|
+
return String(e).padStart(2, '0') + String(n).padStart(2, '0');
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Decode local macroblock letter (A=0 … J=9). */
|
|
1064
|
+
function decodeMacroLetter(c) {
|
|
1065
|
+
if (!/^[A-J]$/i.test(c)) return 0;
|
|
1066
|
+
return c.toUpperCase().charCodeAt(0) - 65;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Decode macroblock string.
|
|
1071
|
+
* 6-char (XXXYYY, all A-Z): national → { blockEast: eastKm, blockNorth: northKm }.
|
|
1072
|
+
* 4-char (LetterDigitLetterDigit): local → { blockEast, blockNorth } in 100 m blocks.
|
|
1073
|
+
*/
|
|
1074
|
+
function decodeMacroblock(str) {
|
|
1075
|
+
if (!str || str.length < 4) return null;
|
|
1076
|
+
const u = str.toUpperCase();
|
|
1077
|
+
if (u.length === 6 && /^[A-Z]{6}$/.test(u)) {
|
|
1078
|
+
const eastKm = decodeAlpha3(u.slice(0, 3));
|
|
1079
|
+
const northKm = decodeAlpha3(u.slice(3, 6));
|
|
1080
|
+
if (eastKm < 0 || northKm < 0) return null;
|
|
1081
|
+
return { blockEast: eastKm, blockNorth: northKm };
|
|
1082
|
+
}
|
|
1083
|
+
if (u.length >= 4 && /^[A-J]\d[A-J]\d$/.test(u.slice(0, 4))) {
|
|
1084
|
+
const blockEast = decodeMacroLetter(u[0]) * 10 + parseInt(u[1], 10);
|
|
1085
|
+
const blockNorth = decodeMacroLetter(u[2]) * 10 + parseInt(u[3], 10);
|
|
1086
|
+
if (Number.isNaN(blockNorth)) return null;
|
|
1087
|
+
return { blockEast, blockNorth };
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Decode microspot string "9921" → { eastM, northM } (meters within the 100 m block). */
|
|
1093
|
+
function decodeMicrospot(str) {
|
|
1094
|
+
if (!str || str.length !== 4) return null;
|
|
1095
|
+
const eastM = parseInt(str.slice(0, 2), 10);
|
|
1096
|
+
const northM = parseInt(str.slice(2, 4), 10);
|
|
1097
|
+
if (Number.isNaN(eastM) || Number.isNaN(northM)) return null;
|
|
1098
|
+
return { eastM, northM };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Decodes a LAP code string into WGS84 [lat, lon] coordinates.
|
|
1103
|
+
* The country code prefix is optional since the country is implicit from the current profile.
|
|
1104
|
+
*
|
|
1105
|
+
* Accepted formats:
|
|
1106
|
+
* - National with CC: "GN-FAR-HMDEUP-3241"
|
|
1107
|
+
* - National without CC: "FAR-HMDEUP-3241"
|
|
1108
|
+
* - Local with CC: "GN-CON-QCL0-A2A3-6041"
|
|
1109
|
+
* - Local without CC: "CON-QCL0-A2A3-6041"
|
|
1110
|
+
*
|
|
1111
|
+
* @param {string} lapCode - The LAP code string to decode.
|
|
1112
|
+
* @returns {{lat: number, lon: number}|null} Decoded WGS84 coordinates, or null if invalid/unresolvable.
|
|
1113
|
+
*/
|
|
1114
|
+
function lapToCoordinates(lapCode) {
|
|
1115
|
+
if (!_initialized) throw new Error('OGLAP not initialized. Call initOglap() with a valid profile and localities naming first.');
|
|
1116
|
+
|
|
1117
|
+
const parsed = parseLapCode(lapCode);
|
|
1118
|
+
if (!parsed || !parsed.macroblock || !parsed.microspot) return null;
|
|
1119
|
+
|
|
1120
|
+
const macro = decodeMacroblock(parsed.macroblock);
|
|
1121
|
+
const micro = decodeMicrospot(parsed.microspot);
|
|
1122
|
+
if (!macro || !micro) return null;
|
|
1123
|
+
|
|
1124
|
+
let originLat, originLon;
|
|
1125
|
+
if (parsed.isNationalGrid) {
|
|
1126
|
+
originLat = COUNTRY_SW[0];
|
|
1127
|
+
originLon = COUNTRY_SW[1];
|
|
1128
|
+
} else {
|
|
1129
|
+
// Local grid: resolve zone origin from the place's bbox
|
|
1130
|
+
const match = getPlaceByLapCode(lapCode);
|
|
1131
|
+
if (!match?.place) return null;
|
|
1132
|
+
const bbox = getCachedBbox(match.place);
|
|
1133
|
+
if (!bbox) return null;
|
|
1134
|
+
originLat = bbox[0]; // minLat
|
|
1135
|
+
originLon = bbox[2]; // minLon
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const mPerLat = metersPerDegreeLat();
|
|
1139
|
+
const isNational = parsed.macroblock.length === 6;
|
|
1140
|
+
const cellSize = isNational ? NATIONAL_CELL_SIZE_M : 100;
|
|
1141
|
+
const microScale = isNational ? NATIONAL_MICRO_SCALE : 1;
|
|
1142
|
+
const eastM = macro.blockEast * cellSize + micro.eastM * microScale;
|
|
1143
|
+
const northM = macro.blockNorth * cellSize + micro.northM * microScale;
|
|
1144
|
+
const lat = originLat + northM / mPerLat;
|
|
1145
|
+
const lon = originLon + eastM / metersPerDegreeLon(originLat);
|
|
1146
|
+
return { lat, lon };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Compute LAP code segments.
|
|
1151
|
+
* Local: COUNTRY-ADMIN2-ADMIN3-MACROBLOCK(4)-MICROSPOT (100 m cells, 1 m micro)
|
|
1152
|
+
* National: COUNTRY-ADMIN2-MACROBLOCK(6)-MICROSPOT (100 m cells, 1 m micro)
|
|
1153
|
+
*/
|
|
1154
|
+
function computeLAP(lat, lon, originLat, originLon, admin_level_2_Code, admin_level_3_code, useNationalGrid = false) {
|
|
1155
|
+
const mPerLat = metersPerDegreeLat();
|
|
1156
|
+
const mPerLon = metersPerDegreeLon(originLat);
|
|
1157
|
+
const northM = (lat - originLat) * mPerLat;
|
|
1158
|
+
const eastM = (lon - originLon) * mPerLon;
|
|
1159
|
+
|
|
1160
|
+
// JS Float64 precision loss compensation when parsing exact grid boundaries
|
|
1161
|
+
const EPSILON = 1e-4;
|
|
1162
|
+
const northMEps = northM + EPSILON;
|
|
1163
|
+
const eastMEps = eastM + EPSILON;
|
|
1164
|
+
|
|
1165
|
+
const admin2 = admin_level_2_Code;
|
|
1166
|
+
|
|
1167
|
+
if (useNationalGrid) {
|
|
1168
|
+
const blockEastN = Math.max(0, Math.floor(eastMEps / NATIONAL_CELL_SIZE_M));
|
|
1169
|
+
const blockNorthN = Math.max(0, Math.floor(northMEps / NATIONAL_CELL_SIZE_M));
|
|
1170
|
+
const inCellEast = Math.floor((eastMEps - blockEastN * NATIONAL_CELL_SIZE_M) / NATIONAL_MICRO_SCALE);
|
|
1171
|
+
const inCellNorth = Math.floor((northMEps - blockNorthN * NATIONAL_CELL_SIZE_M) / NATIONAL_MICRO_SCALE);
|
|
1172
|
+
const macroblock = encodeNationalMacroblock(blockEastN, blockNorthN);
|
|
1173
|
+
const microspot = encodeMicrospot(inCellEast, inCellNorth);
|
|
1174
|
+
return {
|
|
1175
|
+
country: COUNTRY_CODE,
|
|
1176
|
+
admin_level_2: admin2,
|
|
1177
|
+
admin_level_3: null,
|
|
1178
|
+
macroblock,
|
|
1179
|
+
microspot,
|
|
1180
|
+
isNationalGrid: true,
|
|
1181
|
+
lapCode: `${COUNTRY_CODE}-${admin2}-${macroblock}-${microspot}`,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const blockEast = Math.floor(eastMEps / 100);
|
|
1186
|
+
const blockNorth = Math.floor(northMEps / 100);
|
|
1187
|
+
const inBlockEast = eastMEps - blockEast * 100;
|
|
1188
|
+
const inBlockNorth = northMEps - blockNorth * 100;
|
|
1189
|
+
const macroblock = encodeLocalMacroblock(blockEast, blockNorth);
|
|
1190
|
+
const microspot = encodeMicrospot(inBlockEast, inBlockNorth);
|
|
1191
|
+
|
|
1192
|
+
return {
|
|
1193
|
+
country: COUNTRY_CODE,
|
|
1194
|
+
admin_level_2: admin2,
|
|
1195
|
+
admin_level_3: admin_level_3_code || null,
|
|
1196
|
+
macroblock,
|
|
1197
|
+
microspot,
|
|
1198
|
+
isNationalGrid: false,
|
|
1199
|
+
lapCode: `${COUNTRY_CODE}-${admin2}-${admin_level_3_code}-${macroblock}-${microspot}`,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Extract the most meaningful name from a place object. */
|
|
1204
|
+
function getPlaceName(place) {
|
|
1205
|
+
if (!place) return 'Unknown';
|
|
1206
|
+
if (place.extratags?.name) return place.extratags.name;
|
|
1207
|
+
if (place.address?.neighbourhood) return place.address.neighbourhood;
|
|
1208
|
+
if (place.address?.suburb) return place.address.suburb;
|
|
1209
|
+
if (place.address?.village) return place.address.village;
|
|
1210
|
+
if (place.address?.city) return place.address.city;
|
|
1211
|
+
if (place.address?.town) return place.address.town;
|
|
1212
|
+
if (place.address?.county) return place.address.county;
|
|
1213
|
+
if (place.address?.state) return place.address.state;
|
|
1214
|
+
return 'Unknown';
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Bbox [minLat, maxLat, minLon, maxLon] from GeoJSON geometry. */
|
|
1218
|
+
function bboxFromGeometry(geometry) {
|
|
1219
|
+
if (!geometry?.coordinates) return null;
|
|
1220
|
+
const c = geometry.coordinates;
|
|
1221
|
+
const type = geometry.type;
|
|
1222
|
+
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
|
|
1223
|
+
function add(lon, lat) {
|
|
1224
|
+
if (lat < minLat) minLat = lat;
|
|
1225
|
+
if (lat > maxLat) maxLat = lat;
|
|
1226
|
+
if (lon < minLon) minLon = lon;
|
|
1227
|
+
if (lon > maxLon) maxLon = lon;
|
|
1228
|
+
}
|
|
1229
|
+
if (type === 'Point') {
|
|
1230
|
+
add(c[0], c[1]);
|
|
1231
|
+
} else if (type === 'Polygon') {
|
|
1232
|
+
c.forEach(ring => ring.forEach(p => add(p[0], p[1])));
|
|
1233
|
+
} else if (type === 'MultiPolygon') {
|
|
1234
|
+
c.forEach(poly => poly.forEach(ring => ring.forEach(p => add(p[0], p[1]))));
|
|
1235
|
+
}
|
|
1236
|
+
if (minLat === Infinity) return null;
|
|
1237
|
+
return [minLat, maxLat, minLon, maxLon];
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Centroid [lat, lon] from bbox [minLat, maxLat, minLon, maxLon]. */
|
|
1241
|
+
function centroidFromBbox(bbox) {
|
|
1242
|
+
if (!bbox || bbox.length < 4) return null;
|
|
1243
|
+
return [(bbox[0] + bbox[1]) / 2, (bbox[2] + bbox[3]) / 2];
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Build search index: key "${admin_level_2_Iso}_${admin_level_3_code}" -> first place with that zone code. Uses effective ADMIN_LEVEL_2. */
|
|
1247
|
+
function buildLapSearchIndex() {
|
|
1248
|
+
if (lapSearchIndex) return lapSearchIndex;
|
|
1249
|
+
lapSearchIndex = new Map();
|
|
1250
|
+
const isoToAssignment = new Map();
|
|
1251
|
+
for (const place of places) {
|
|
1252
|
+
const iso = effectiveAdminLevel2IsoForPlace(place, { skipSampling: true });
|
|
1253
|
+
|
|
1254
|
+
let code = OGLAP_ZONE_CODES_BY_ID.has(place.place_id)
|
|
1255
|
+
? OGLAP_ZONE_CODES_BY_ID.get(place.place_id)
|
|
1256
|
+
: null;
|
|
1257
|
+
|
|
1258
|
+
if (!code) {
|
|
1259
|
+
if (!isoToAssignment.has(iso)) {
|
|
1260
|
+
isoToAssignment.set(iso, buildAdminLevel2ZoneAssignments(iso));
|
|
1261
|
+
}
|
|
1262
|
+
code = isoToAssignment.get(iso).get(place.place_id);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (!code) continue;
|
|
1266
|
+
const key = `${iso}_${code}`;
|
|
1267
|
+
if (!lapSearchIndex.has(key)) lapSearchIndex.set(key, place);
|
|
1268
|
+
}
|
|
1269
|
+
return lapSearchIndex;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Parses a raw search query string into structured LAP components.
|
|
1274
|
+
* Supports multiple valid formats (country code prefix is optional):
|
|
1275
|
+
* - National LAP: "GN-CKY-ABCABC-2798" or "CKY-ABCABC-2798"
|
|
1276
|
+
* - Local LAP: "GN-CKY-QKPC-B4A4-2798" or "CKY-QKPC-B4A4-2798"
|
|
1277
|
+
* - Zone search: "GN-CKY-QKAR", "CKY QKAR", "QKAR"
|
|
1278
|
+
*
|
|
1279
|
+
* @param {string} query - The raw user input string.
|
|
1280
|
+
* @returns {{ admin_level_2_Iso?: string, admin_level_3_code?: string|null, macroblock?: string, microspot?: string, isNationalGrid?: boolean }|null} Structured components or null if parsing fails.
|
|
1281
|
+
*/
|
|
1282
|
+
function parseLapCode(query) {
|
|
1283
|
+
const q = (query || '').trim();
|
|
1284
|
+
if (!q) return null;
|
|
1285
|
+
const parts = q.split(/[\s-]+/).filter(Boolean).map((p) => p.toUpperCase());
|
|
1286
|
+
|
|
1287
|
+
// National LAP with CC: GN-ADMIN2-XXXYYY-MICRO (4 parts, macroblock = 6 chars all A-Z)
|
|
1288
|
+
if (parts.length >= 4 && parts[0] === COUNTRY_CODE) {
|
|
1289
|
+
const admin2Code = parts[1];
|
|
1290
|
+
const maybeMacro = parts[2];
|
|
1291
|
+
const maybeMicro = parts[3];
|
|
1292
|
+
const admin2Iso = OGLAP_COUNTRY_REGIONS_REVERSE[admin2Code];
|
|
1293
|
+
if (admin2Iso && maybeMacro.length === 6 && /^[A-Z]{6}$/.test(maybeMacro) && maybeMicro.length === 4 && /^\d{4}$/.test(maybeMicro)) {
|
|
1294
|
+
return { admin_level_2_Iso: admin2Iso, admin_level_3_code: null, macroblock: maybeMacro, microspot: maybeMicro, isNationalGrid: true };
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Local LAP with CC: GN-ADMIN2-ADMIN3-MACRO-MICRO (5 parts, macroblock = 4 chars)
|
|
1299
|
+
if (parts.length >= 5 && parts[0] === COUNTRY_CODE) {
|
|
1300
|
+
const admin2Code = parts[1];
|
|
1301
|
+
const admin3Code = parts[2];
|
|
1302
|
+
const macroblock = parts[3];
|
|
1303
|
+
const microspot = parts[4];
|
|
1304
|
+
const admin2Iso = OGLAP_COUNTRY_REGIONS_REVERSE[admin2Code];
|
|
1305
|
+
if (admin2Iso && admin3Code.length >= 1 && macroblock.length === 4 && microspot.length === 4) {
|
|
1306
|
+
return { admin_level_2_Iso: admin2Iso, admin_level_3_code: admin3Code, macroblock, microspot, isNationalGrid: false };
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// National LAP without CC: ADMIN2-XXXYYY-MICRO (3 parts, macroblock = 6 chars all A-Z)
|
|
1311
|
+
if (parts.length === 3 && parts[0] !== COUNTRY_CODE) {
|
|
1312
|
+
const admin2Code = parts[0];
|
|
1313
|
+
const maybeMacro = parts[1];
|
|
1314
|
+
const maybeMicro = parts[2];
|
|
1315
|
+
const admin2Iso = OGLAP_COUNTRY_REGIONS_REVERSE[admin2Code];
|
|
1316
|
+
if (admin2Iso && maybeMacro.length === 6 && /^[A-Z]{6}$/.test(maybeMacro) && maybeMicro.length === 4 && /^\d{4}$/.test(maybeMicro)) {
|
|
1317
|
+
return { admin_level_2_Iso: admin2Iso, admin_level_3_code: null, macroblock: maybeMacro, microspot: maybeMicro, isNationalGrid: true };
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Local LAP without CC: ADMIN2-ADMIN3-MACRO-MICRO (4 parts, macroblock = 4 chars)
|
|
1322
|
+
if (parts.length === 4 && parts[0] !== COUNTRY_CODE) {
|
|
1323
|
+
const admin2Code = parts[0];
|
|
1324
|
+
const admin3Code = parts[1];
|
|
1325
|
+
const macroblock = parts[2];
|
|
1326
|
+
const microspot = parts[3];
|
|
1327
|
+
const admin2Iso = OGLAP_COUNTRY_REGIONS_REVERSE[admin2Code];
|
|
1328
|
+
if (admin2Iso && admin3Code.length >= 1 && macroblock.length === 4 && microspot.length === 4) {
|
|
1329
|
+
return { admin_level_2_Iso: admin2Iso, admin_level_3_code: admin3Code, macroblock, microspot, isNationalGrid: false };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Zone search with country prefix: GN-ADMIN2-ADMIN3
|
|
1334
|
+
if (parts.length >= 3 && parts[0] === COUNTRY_CODE) {
|
|
1335
|
+
const admin2Code = parts[1];
|
|
1336
|
+
const admin3Code = parts[2];
|
|
1337
|
+
const admin2Iso = OGLAP_COUNTRY_REGIONS_REVERSE[admin2Code];
|
|
1338
|
+
if (admin2Iso && admin3Code.length >= 1) return { admin_level_2_Iso: admin2Iso, admin_level_3_code: admin3Code };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Zone search shorthand: ADMIN2 ADMIN3
|
|
1342
|
+
if (parts.length === 2 && parts[0].length <= 4 && parts[1].length <= 4) {
|
|
1343
|
+
const admin2Code = parts[0];
|
|
1344
|
+
const admin3Code = parts[1];
|
|
1345
|
+
const admin2Iso = OGLAP_COUNTRY_REGIONS_REVERSE[admin2Code];
|
|
1346
|
+
if (admin2Iso) return { admin_level_2_Iso: admin2Iso, admin_level_3_code: admin3Code };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Zone code only
|
|
1350
|
+
if (parts.length === 1 && parts[0].length <= 4) {
|
|
1351
|
+
return { admin_level_3_code: parts[0] };
|
|
1352
|
+
}
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Validates a LAP or zone search input format before costly parsing and spatial lookups.
|
|
1358
|
+
* Accepts: full LAP with or without country prefix (national or local), zone search.
|
|
1359
|
+
*
|
|
1360
|
+
* @param {string} query - The raw user input string.
|
|
1361
|
+
* @returns {string|null} Returns `null` if the format is perfectly valid, or a descriptive error message string if invalid.
|
|
1362
|
+
*/
|
|
1363
|
+
function validateLapCode(query) {
|
|
1364
|
+
const q = (query || '').trim();
|
|
1365
|
+
if (!q) return 'Enter a LAP code or zone code to search.';
|
|
1366
|
+
|
|
1367
|
+
const parts = q.split(/[\s-]+/).filter(Boolean).map((p) => p.toUpperCase());
|
|
1368
|
+
if (parts.length > 5) return `Invalid format: too many segments. Use e.g. GN-CKY-QKPC-B4A4-2798 (local) or GN-CKY-XXXYYY-2798 (national) or zone code QKAR.`;
|
|
1369
|
+
|
|
1370
|
+
// 5 parts: CC-ADMIN2-ADMIN3-MACRO-MICRO (local with CC)
|
|
1371
|
+
if (parts.length === 5) {
|
|
1372
|
+
if (parts[0] !== COUNTRY_CODE) return `LAP code must start with country code "${COUNTRY_CODE}" when using 5-segment format.`;
|
|
1373
|
+
const admin2 = OGLAP_COUNTRY_REGIONS_REVERSE[parts[1]];
|
|
1374
|
+
if (!admin2) return `Unknown region code "${parts[1]}". Use a valid ADMIN_LEVEL_2 code (e.g. CKY).`;
|
|
1375
|
+
if (!parts[2] || parts[2].length < 1) return 'Zone (ADMIN_LEVEL_3) code is required.';
|
|
1376
|
+
if (parts[3].length !== 4) return 'Local macroblock must be 4 characters (e.g. B4A4).';
|
|
1377
|
+
if (!/^[A-J]\d[A-J]\d$/i.test(parts[3])) return 'Local macroblock format: letter-digit-letter-digit (e.g. B4A4).';
|
|
1378
|
+
if (parts[4].length !== 4 || !/^\d{4}$/.test(parts[4])) return 'Microspot must be 4 digits (e.g. 2798).';
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// 4 parts: CC-ADMIN2-MACRO6-MICRO (national with CC) OR ADMIN2-ADMIN3-MACRO4-MICRO (local without CC)
|
|
1383
|
+
if (parts.length === 4) {
|
|
1384
|
+
if (parts[0] === COUNTRY_CODE) {
|
|
1385
|
+
// National with CC: GN-ADMIN2-XXXYYY-MICRO
|
|
1386
|
+
const admin2 = OGLAP_COUNTRY_REGIONS_REVERSE[parts[1]];
|
|
1387
|
+
if (!admin2) return `Unknown region code "${parts[1]}". Use a valid ADMIN_LEVEL_2 code (e.g. CKY).`;
|
|
1388
|
+
if (parts[2].length !== 6 || !/^[A-Z]{6}$/.test(parts[2])) return 'National macroblock must be 6 letters (e.g. ABCDEF).';
|
|
1389
|
+
if (parts[3].length !== 4 || !/^\d{4}$/.test(parts[3])) return 'Microspot must be 4 digits (e.g. 2798).';
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
// Local without CC: ADMIN2-ADMIN3-MACRO4-MICRO
|
|
1393
|
+
const admin2 = OGLAP_COUNTRY_REGIONS_REVERSE[parts[0]];
|
|
1394
|
+
if (!admin2) return `Unknown region code "${parts[0]}". Use a valid ADMIN_LEVEL_2 code (e.g. CKY).`;
|
|
1395
|
+
if (!parts[1] || parts[1].length < 1) return 'Zone (ADMIN_LEVEL_3) code is required.';
|
|
1396
|
+
if (parts[2].length !== 4) return 'Local macroblock must be 4 characters (e.g. B4A4).';
|
|
1397
|
+
if (!/^[A-J]\d[A-J]\d$/i.test(parts[2])) return 'Local macroblock format: letter-digit-letter-digit (e.g. B4A4).';
|
|
1398
|
+
if (parts[3].length !== 4 || !/^\d{4}$/.test(parts[3])) return 'Microspot must be 4 digits (e.g. 2798).';
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// 3 parts: ADMIN2-MACRO6-MICRO (national without CC) OR CC-ADMIN2-ADMIN3 (zone search)
|
|
1403
|
+
if (parts.length === 3) {
|
|
1404
|
+
if (parts[0] === COUNTRY_CODE) {
|
|
1405
|
+
// Zone search: GN-ADMIN2-ADMIN3
|
|
1406
|
+
const admin2 = OGLAP_COUNTRY_REGIONS_REVERSE[parts[1]];
|
|
1407
|
+
if (!admin2) return `Unknown region code "${parts[1]}". Use a valid ADMIN_LEVEL_2 code (e.g. CKY).`;
|
|
1408
|
+
if (!parts[2] || parts[2].length < 1) return 'Zone code is required.';
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
// National without CC: ADMIN2-XXXYYY-MICRO
|
|
1412
|
+
const admin2 = OGLAP_COUNTRY_REGIONS_REVERSE[parts[0]];
|
|
1413
|
+
if (admin2 && parts[1].length === 6 && /^[A-Z]{6}$/.test(parts[1]) && parts[2].length === 4 && /^\d{4}$/.test(parts[2])) {
|
|
1414
|
+
return null;
|
|
1415
|
+
}
|
|
1416
|
+
// Could be zone search without CC — fall through
|
|
1417
|
+
if (admin2) return null; // loose zone search
|
|
1418
|
+
return `Unknown region code "${parts[0]}". Use a valid ADMIN_LEVEL_2 code (e.g. CKY).`;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (parts.length === 2) {
|
|
1422
|
+
const admin2 = OGLAP_COUNTRY_REGIONS_REVERSE[parts[0]];
|
|
1423
|
+
if (!admin2) return `Unknown region code "${parts[0]}". Use e.g. CKY QKAR.`;
|
|
1424
|
+
if (!parts[1] || parts[1].length > 4) return 'Zone code must be 1–4 characters.';
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (parts.length === 1) {
|
|
1429
|
+
if (parts[0].length > 4) return 'Zone code only must be 1–4 characters (e.g. QKAR).';
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
return 'Invalid LAP or zone format. Use full LAP (GN-CKY-...), or zone code (e.g. QKAR).';
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Find first place matching the LAP search query. Returns { place, parsed, originLat?, originLon? } or null. National grid LAPs resolve directly without a place. */
|
|
1437
|
+
function getPlaceByLapCode(query) {
|
|
1438
|
+
if (!_initialized) throw new Error('OGLAP not initialized. Call initOglap() with a valid profile and localities naming first.');
|
|
1439
|
+
const parsed = parseLapCode(query);
|
|
1440
|
+
if (!parsed) return null;
|
|
1441
|
+
if (parsed.isNationalGrid && parsed.admin_level_2_Iso) {
|
|
1442
|
+
return { place: null, parsed, originLat: COUNTRY_SW[0], originLon: COUNTRY_SW[1] };
|
|
1443
|
+
}
|
|
1444
|
+
if (places.length === 0) return null;
|
|
1445
|
+
buildLapSearchIndex();
|
|
1446
|
+
let place = null;
|
|
1447
|
+
if (parsed.admin_level_2_Iso && parsed.admin_level_3_code) {
|
|
1448
|
+
const key = `${parsed.admin_level_2_Iso}_${parsed.admin_level_3_code}`;
|
|
1449
|
+
place = lapSearchIndex.get(key) || null;
|
|
1450
|
+
} else if (parsed.admin_level_3_code) {
|
|
1451
|
+
for (const [key, p] of lapSearchIndex) {
|
|
1452
|
+
if (key.endsWith('_' + parsed.admin_level_3_code)) {
|
|
1453
|
+
place = p;
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (!place) return null;
|
|
1459
|
+
return { place, parsed };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** Ensure each ring's first and last coordinate are the same (Turf requirement). */
|
|
1463
|
+
function closeRings(geometry) {
|
|
1464
|
+
if (!geometry?.coordinates) return geometry;
|
|
1465
|
+
const closeRing = (ring) => {
|
|
1466
|
+
if (!ring || ring.length < 3) return ring;
|
|
1467
|
+
const first = ring[0];
|
|
1468
|
+
const last = ring[ring.length - 1];
|
|
1469
|
+
if (first[0] !== last[0] || first[1] !== last[1]) {
|
|
1470
|
+
return [...ring, [first[0], first[1]]];
|
|
1471
|
+
}
|
|
1472
|
+
return ring;
|
|
1473
|
+
};
|
|
1474
|
+
if (geometry.type === 'Polygon') {
|
|
1475
|
+
return {
|
|
1476
|
+
type: 'Polygon',
|
|
1477
|
+
coordinates: geometry.coordinates.map(closeRing),
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
if (geometry.type === 'MultiPolygon') {
|
|
1481
|
+
return {
|
|
1482
|
+
type: 'MultiPolygon',
|
|
1483
|
+
coordinates: geometry.coordinates.map((poly) => poly.map(closeRing)),
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
return geometry;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** Check if point [lon, lat] is inside GeoJSON geometry. */
|
|
1490
|
+
function pointInGeometry(lon, lat, geometry) {
|
|
1491
|
+
if (!geometry) return false;
|
|
1492
|
+
const pt = { type: 'Point', coordinates: [lon, lat] };
|
|
1493
|
+
let poly;
|
|
1494
|
+
if (geometry.type === 'Polygon') {
|
|
1495
|
+
poly = closeRings({ type: 'Polygon', coordinates: geometry.coordinates });
|
|
1496
|
+
} else if (geometry.type === 'MultiPolygon') {
|
|
1497
|
+
poly = closeRings({ type: 'MultiPolygon', coordinates: geometry.coordinates });
|
|
1498
|
+
} else {
|
|
1499
|
+
return false;
|
|
1500
|
+
}
|
|
1501
|
+
try {
|
|
1502
|
+
return booleanPointInPolygon(pt, poly);
|
|
1503
|
+
} catch (_) {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/** Find smallest containing feature for (lon, lat) and return it + bbox for origin. */
|
|
1509
|
+
function reverseGeocode(lon, lat) {
|
|
1510
|
+
const containing = [];
|
|
1511
|
+
for (const place of places) {
|
|
1512
|
+
const geo = place.geojson;
|
|
1513
|
+
if (!geo) continue;
|
|
1514
|
+
if (geo.type === 'Point' || geo.type === 'MultiPoint') continue;
|
|
1515
|
+
if (pointInGeometry(lon, lat, geo)) {
|
|
1516
|
+
const poly = geo.type === 'Polygon'
|
|
1517
|
+
? closeRings({ type: 'Polygon', coordinates: geo.coordinates })
|
|
1518
|
+
: closeRings({ type: 'MultiPolygon', coordinates: geo.coordinates });
|
|
1519
|
+
try {
|
|
1520
|
+
if (place._computedArea === undefined) {
|
|
1521
|
+
place._computedArea = area(poly);
|
|
1522
|
+
}
|
|
1523
|
+
containing.push({
|
|
1524
|
+
place,
|
|
1525
|
+
area: place._computedArea,
|
|
1526
|
+
});
|
|
1527
|
+
} catch (_) {
|
|
1528
|
+
// skip if area() fails (e.g. invalid geometry)
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
if (containing.length === 0) return null;
|
|
1533
|
+
containing.sort((a, b) => a.area - b.area);
|
|
1534
|
+
|
|
1535
|
+
// Best (smallest) feature
|
|
1536
|
+
const best = containing[0].place;
|
|
1537
|
+
|
|
1538
|
+
// Ensure the best place has its address object initialized
|
|
1539
|
+
if (!best.address) best.address = {};
|
|
1540
|
+
|
|
1541
|
+
// Bubble up missing hierarchical addressing properties from enclosing areas
|
|
1542
|
+
for (let i = 1; i < containing.length; i++) {
|
|
1543
|
+
const parent = containing[i].place;
|
|
1544
|
+
const parentAddr = parent.address || {};
|
|
1545
|
+
const parentLevel = parent.extratags?.admin_level ? parseInt(parent.extratags.admin_level, 10) : null;
|
|
1546
|
+
const parentName = getPlaceName(parent);
|
|
1547
|
+
|
|
1548
|
+
if (!best.address.country && parentAddr.country) best.address.country = parentAddr.country;
|
|
1549
|
+
|
|
1550
|
+
// Admin Level 4 -> State/Region
|
|
1551
|
+
if (!best.address.state) {
|
|
1552
|
+
if (parentAddr.state) best.address.state = parentAddr.state;
|
|
1553
|
+
else if (parentLevel === 4) best.address.state = parentName;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Admin Level 6 -> County/Prefecture
|
|
1557
|
+
if (!best.address.county) {
|
|
1558
|
+
if (parentAddr.county) best.address.county = parentAddr.county;
|
|
1559
|
+
else if (parentLevel === 6) best.address.county = parentName;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// Admin Level 8 -> City/Town/Sub-prefecture
|
|
1563
|
+
if (!best.address.city && !best.address.town && !best.address.village) {
|
|
1564
|
+
if (parentAddr.city) best.address.city = parentAddr.city;
|
|
1565
|
+
else if (parentAddr.town) best.address.town = parentAddr.town;
|
|
1566
|
+
else if (parentLevel === 8) best.address.city = parentName;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Fallback country if still missing
|
|
1571
|
+
if (!best.address.country) best.address.country = COUNTRY_PROFILE?.meta?.country_name || 'Guinée';
|
|
1572
|
+
|
|
1573
|
+
const bbox = getCachedBbox(best);
|
|
1574
|
+
const originLat = bbox ? bbox[0] : COUNTRY_SW[0];
|
|
1575
|
+
const originLon = bbox ? bbox[2] : COUNTRY_SW[1];
|
|
1576
|
+
return {
|
|
1577
|
+
place: best,
|
|
1578
|
+
originLat,
|
|
1579
|
+
originLon,
|
|
1580
|
+
bbox,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** Get ADMIN_LEVEL_2 OGLAP code from address (ISO3166-2-Lvl4). */
|
|
1585
|
+
function getAdminLevel2Code(address) {
|
|
1586
|
+
const iso4 = address?.['ISO3166-2-Lvl4'] || address?.['ISO3166-2-lvl4'];
|
|
1587
|
+
return (iso4 && OGLAP_COUNTRY_REGIONS[iso4]) || null;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** Get ADMIN_LEVEL_2 ISO from address. */
|
|
1591
|
+
function getAdminLevel2IsoFromAddress(address) {
|
|
1592
|
+
return address?.['ISO3166-2-Lvl4'] || address?.['ISO3166-2-lvl4'] || null;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Find region (admin_level 4) that contains the point; return its ISO or null. */
|
|
1596
|
+
function getAdminLevel2FromRegionContainment(lon, lat) {
|
|
1597
|
+
for (const place of places) {
|
|
1598
|
+
const level = place.extratags?.admin_level != null ? parseInt(place.extratags.admin_level, 10) : 0;
|
|
1599
|
+
if (level !== 4) continue;
|
|
1600
|
+
const iso = getAdminLevel2IsoFromAddress(place.address || {});
|
|
1601
|
+
if (!iso) continue;
|
|
1602
|
+
if (pointInGeometry(lon, lat, place.geojson)) return iso;
|
|
1603
|
+
}
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Sample 3–7 points in 500m–1km radius; return majority ADMIN_LEVEL_2 ISO or null. */
|
|
1608
|
+
function getAdminLevel2BySampling(lon, lat, numSamples = 5, radiusM = 750) {
|
|
1609
|
+
const mPerLat = metersPerDegreeLat();
|
|
1610
|
+
const mPerLon = metersPerDegreeLon(lat);
|
|
1611
|
+
const counts = new Map();
|
|
1612
|
+
const angles = [];
|
|
1613
|
+
for (let i = 0; i < numSamples; i++) {
|
|
1614
|
+
const angle = (i / numSamples) * 2 * Math.PI;
|
|
1615
|
+
const eastM = radiusM * Math.cos(angle);
|
|
1616
|
+
const northM = radiusM * Math.sin(angle);
|
|
1617
|
+
const dLat = northM / mPerLat;
|
|
1618
|
+
const dLon = eastM / mPerLon;
|
|
1619
|
+
const lat2 = lat + dLat;
|
|
1620
|
+
const lon2 = lon + dLon;
|
|
1621
|
+
let iso = getAdminLevel2FromRegionContainment(lon2, lat2);
|
|
1622
|
+
if (!iso) {
|
|
1623
|
+
const rev = reverseGeocode(lon2, lat2);
|
|
1624
|
+
if (rev) iso = getAdminLevel2IsoFromAddress(rev.place.address || {});
|
|
1625
|
+
}
|
|
1626
|
+
if (iso) counts.set(iso, (counts.get(iso) || 0) + 1);
|
|
1627
|
+
}
|
|
1628
|
+
let best = null;
|
|
1629
|
+
let bestCount = 0;
|
|
1630
|
+
for (const [iso, c] of counts) {
|
|
1631
|
+
if (c > bestCount) {
|
|
1632
|
+
bestCount = c;
|
|
1633
|
+
best = iso;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
return best;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** ADMIN_LEVEL_2 OGLAP code with fallbacks: address → region containment → sampling (optional) → null. */
|
|
1640
|
+
function getAdminLevel2WithFallback(lat, lon, place, opts = {}) {
|
|
1641
|
+
const { skipSampling = false } = opts;
|
|
1642
|
+
const address = place?.address || {};
|
|
1643
|
+
let iso = getAdminLevel2IsoFromAddress(address);
|
|
1644
|
+
if (iso) return OGLAP_COUNTRY_REGIONS[iso];
|
|
1645
|
+
iso = getAdminLevel2FromRegionContainment(lon, lat);
|
|
1646
|
+
if (iso) return OGLAP_COUNTRY_REGIONS[iso];
|
|
1647
|
+
if (!skipSampling) {
|
|
1648
|
+
iso = getAdminLevel2BySampling(lon, lat, 5, 750);
|
|
1649
|
+
if (iso) return OGLAP_COUNTRY_REGIONS[iso];
|
|
1650
|
+
}
|
|
1651
|
+
return null;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** ADMIN_LEVEL_2 ISO with same fallbacks (for grouping / collision). skipSampling=true for fast index build. */
|
|
1655
|
+
function getAdminLevel2IsoWithFallback(lat, lon, place, opts = {}) {
|
|
1656
|
+
const { skipSampling = false } = opts;
|
|
1657
|
+
const address = place?.address || {};
|
|
1658
|
+
let iso = getAdminLevel2IsoFromAddress(address);
|
|
1659
|
+
if (iso) return iso;
|
|
1660
|
+
iso = getAdminLevel2FromRegionContainment(lon, lat);
|
|
1661
|
+
if (iso) return iso;
|
|
1662
|
+
if (!skipSampling) {
|
|
1663
|
+
iso = getAdminLevel2BySampling(lon, lat, 5, 750);
|
|
1664
|
+
if (iso) return iso;
|
|
1665
|
+
}
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/** True if place has admin_level ≥ 9 AND meaningful name tokens (use zone grid); else use national grid. */
|
|
1670
|
+
function useZoneGridForPlace(place) {
|
|
1671
|
+
if (!place?.extratags?.admin_level) return false;
|
|
1672
|
+
const level = parseInt(place.extratags.admin_level, 10);
|
|
1673
|
+
if (level < 9) return false;
|
|
1674
|
+
// If place has a pre-assigned zone code in localities naming, use local grid
|
|
1675
|
+
const pid = place.place_id;
|
|
1676
|
+
if (pid != null && (OGLAP_ZONE_CODES_BY_ID.has(pid) || OGLAP_ZONE_CODES_BY_ID.has(String(pid)))) return true;
|
|
1677
|
+
// Check if place has meaningful name tokens for zone code generation
|
|
1678
|
+
const address = place.address || {};
|
|
1679
|
+
const name = address.quarter || address.neighbourhood || address.suburb ||
|
|
1680
|
+
address.village || address.hamlet || address.town || address.city ||
|
|
1681
|
+
(place.display_name && place.display_name.split(',')[0]?.trim()) || '';
|
|
1682
|
+
const significant = getSignificantTokens(name);
|
|
1683
|
+
return significant.length > 0;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Build OGLAP address and LAP code from reverse result and click.
|
|
1688
|
+
* Local grid when place has admin_level ≥ 9 (zone origin, ADMIN3 segment).
|
|
1689
|
+
* National grid otherwise (country origin, XXXYYY macroblock, no ADMIN3).
|
|
1690
|
+
*/
|
|
1691
|
+
function buildOGLAPResult(lat, lon, rev) {
|
|
1692
|
+
const useZone = rev?.place && useZoneGridForPlace(rev.place);
|
|
1693
|
+
const useNational = !useZone;
|
|
1694
|
+
let originLat, originLon, admin_level_2, admin_level_3, displayName, address, pcode;
|
|
1695
|
+
|
|
1696
|
+
if (useZone) {
|
|
1697
|
+
originLat = rev.originLat;
|
|
1698
|
+
originLon = rev.originLon;
|
|
1699
|
+
admin_level_2 = getAdminLevel2WithFallback(lat, lon, rev.place);
|
|
1700
|
+
if (!admin_level_2) return null;
|
|
1701
|
+
admin_level_3 = getAdminLevel3CodeWithCollision(rev.place);
|
|
1702
|
+
address = rev.place.address || {};
|
|
1703
|
+
displayName = getPlaceName(rev.place);
|
|
1704
|
+
const pcodeRaw = rev.place.extratags?.['unocha:pcode'];
|
|
1705
|
+
pcode = typeof pcodeRaw === 'string' && pcodeRaw.trim()
|
|
1706
|
+
? pcodeRaw.split(';').map((s) => s.trim()).filter(Boolean)
|
|
1707
|
+
: [];
|
|
1708
|
+
} else {
|
|
1709
|
+
originLat = COUNTRY_SW[0];
|
|
1710
|
+
originLon = COUNTRY_SW[1];
|
|
1711
|
+
admin_level_2 = getAdminLevel2WithFallback(lat, lon, rev?.place ?? null);
|
|
1712
|
+
if (!admin_level_2) return null;
|
|
1713
|
+
admin_level_3 = null;
|
|
1714
|
+
address = rev?.place?.address || {};
|
|
1715
|
+
displayName = getPlaceName(rev?.place);
|
|
1716
|
+
pcode = [];
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const lap = computeLAP(lat, lon, originLat, originLon, admin_level_2, admin_level_3, useNational);
|
|
1720
|
+
|
|
1721
|
+
const addressParts = (useNational
|
|
1722
|
+
? [
|
|
1723
|
+
`${lap.macroblock}-${lap.microspot}`,
|
|
1724
|
+
address?.county || address?.state || address?.city || address?.town || address?.village,
|
|
1725
|
+
address?.country,
|
|
1726
|
+
]
|
|
1727
|
+
: [
|
|
1728
|
+
displayName !== 'Unknown'
|
|
1729
|
+
? `${lap.macroblock}-${lap.microspot} ${displayName}`
|
|
1730
|
+
: `${lap.macroblock}-${lap.microspot}`,
|
|
1731
|
+
address?.county || address?.state || address?.city || address?.town || address?.village, // Admin2 fallback
|
|
1732
|
+
address?.country,
|
|
1733
|
+
]).filter(Boolean);
|
|
1734
|
+
|
|
1735
|
+
const humanAddress = [...new Set(addressParts)].join(', ');
|
|
1736
|
+
|
|
1737
|
+
return {
|
|
1738
|
+
lapCode: lap.lapCode,
|
|
1739
|
+
country: lap.country,
|
|
1740
|
+
admin_level_2: lap.admin_level_2,
|
|
1741
|
+
admin_level_3: lap.admin_level_3,
|
|
1742
|
+
macroblock: lap.macroblock,
|
|
1743
|
+
microspot: lap.microspot,
|
|
1744
|
+
isNationalGrid: lap.isNationalGrid,
|
|
1745
|
+
displayName,
|
|
1746
|
+
address: address || {},
|
|
1747
|
+
humanAddress,
|
|
1748
|
+
originLat,
|
|
1749
|
+
originLon,
|
|
1750
|
+
pcode,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Draw a grid of lines at a given cell size (in meters).
|
|
1756
|
+
* Uses a single mPerLon (at originLat) for all conversions so that lines stay
|
|
1757
|
+
* at fixed positions regardless of zoom or pan — no per-row variation.
|
|
1758
|
+
* Returns array of line coordinate pairs, or [] if too many lines.
|
|
1759
|
+
*/
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Convert raw WGS84 Latitude and Longitude to a structured, fully-qualified OGLAP Code object.
|
|
1763
|
+
* This combines reverseGeocoding containment checks and grid projection into a single SDK function.
|
|
1764
|
+
*
|
|
1765
|
+
* @param {number} lat - Latitude in decimal degrees
|
|
1766
|
+
* @param {number} lon - Longitude in decimal degrees
|
|
1767
|
+
* @returns {{
|
|
1768
|
+
* lapCode: string, country: string, admin_level_2: string, admin_level_3: string|null,
|
|
1769
|
+
* macroblock: string, microspot: string, isNationalGrid: boolean,
|
|
1770
|
+
* displayName: string, address: Object, humanAddress: string,
|
|
1771
|
+
* originLat: number, originLon: number, pcode: string[]
|
|
1772
|
+
* }} The resulting structured OGLAP place block.
|
|
1773
|
+
*/
|
|
1774
|
+
function coordinatesToLap(lat, lon) {
|
|
1775
|
+
if (!_initialized) throw new Error('OGLAP not initialized. Call initOglap() with a valid profile and localities naming first.');
|
|
1776
|
+
|
|
1777
|
+
// Fast reject: coordinates outside the country bounding box
|
|
1778
|
+
const { sw, ne } = COUNTRY_BOUNDS;
|
|
1779
|
+
if (lat < sw[0] || lat > ne[0] || lon < sw[1] || lon > ne[1]) {
|
|
1780
|
+
return null;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Precise reject: coordinates must fall inside the country border polygon (admin_level 2)
|
|
1784
|
+
if (COUNTRY_BORDER_GEOJSON && !pointInGeometry(lon, lat, COUNTRY_BORDER_GEOJSON)) {
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const rev = reverseGeocode(lon, lat);
|
|
1789
|
+
return buildOGLAPResult(lat, lon, rev);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
export {
|
|
1793
|
+
parseLapCode,
|
|
1794
|
+
validateLapCode,
|
|
1795
|
+
getPlaceByLapCode,
|
|
1796
|
+
lapToCoordinates,
|
|
1797
|
+
coordinatesToLap,
|
|
1798
|
+
bboxFromGeometry,
|
|
1799
|
+
centroidFromBbox,
|
|
1800
|
+
};
|