oglap-ggp-node 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,401 @@
1
+ # oglap-ggp-node-js
2
+
3
+ Implémentation Node.js du protocole **OGLAP** (Offline Grid Location Addressing Protocol) — un système d'adressage déterministe basé sur une grille, conçu pour les régions où les adresses postales formelles sont inexistantes ou peu fiables.
4
+
5
+ OGLAP génère des **codes LAP** compacts et lisibles (ex. `GN-CKY-QKAR-B4A4-2798`) qui identifient de façon unique n'importe quelle coordonnée à l'intérieur d'un pays configuré, hors ligne et sans API externe.
6
+
7
+ ---
8
+
9
+ ## Fonctionnalités
10
+
11
+ - **Coordonnées → code LAP** — encoder toute position GPS en adresse LAP structurée
12
+ - **Code LAP → coordonnées** — décoder un code LAP vers son centre géographique
13
+ - **Geocodage inversé** — trouver la région administrative, zone et lieu contenant une coordonnée
14
+ - **Parsing & validation de code LAP** — analyser des codes partiels ou complets, valider le format et le contenu
15
+ - **Boîte englobante et centroïde** — calculer bbox et centre à partir de géométries GeoJSON
16
+ - **Entièrement hors ligne** — aucune connexion requise une fois les données chargées
17
+
18
+ ---
19
+
20
+ ## Format du code LAP
21
+
22
+ Un code LAP encode une localisation à quatre niveaux hiérarchiques :
23
+
24
+ ### Grille locale (5 segments)
25
+ ```
26
+ GN - CKY - QKAR - B4A4 - 2798
27
+ │ │ │ │ └─ Microspot — 4 chiffres, offset métrique (XX = est, YY = nord)
28
+ │ │ │ └─────── Macrobloc — 4 chars [A-J][0-9][A-J][0-9], blocs ~100 m dans la zone
29
+ │ │ └────────────── Zone — 4 chars, dérivé du nom de lieu local
30
+ │ └───────────────────── Région — 3 chars, code de région administrative
31
+ └──────────────────────────── Pays — code ISO alpha-2 du pays
32
+ ```
33
+
34
+ ### Grille nationale (4 segments)
35
+ Utilisée quand une coordonnée se situe en dehors des limites administratives de niveau 8 et au-dessus :
36
+ ```
37
+ GN - CKY - AABCDE - 4250
38
+ │ │ │ └─ Microspot — 4 chiffres
39
+ │ │ └────────── Macrobloc — 6 lettres, grille kilométrique nationale
40
+ │ └──────────────── Région
41
+ └─────────────────────── Pays
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Démarrage
47
+
48
+ ### 1. Ajouter la dépendance
49
+
50
+ ```bash
51
+ npm install oglap-ggp-node-js
52
+ ```
53
+
54
+ ### 2. Préparer les données
55
+
56
+ Le package nécessite trois fichiers JSON :
57
+
58
+ | Fichier | Description |
59
+ |---|---|
60
+ | `{country_code}_oglap_country_profile.json` | Paramètres de grille, codes admin, règles de nommage |
61
+ | `{country_code}_localities_naming.json` | Géométries GeoJSON des lieux (régions, zones, localités) |
62
+ | `{country_code}_full.json` | Correspondances place-ID → code OGLAP |
63
+
64
+ ### 3. Initialiser avant toute utilisation
65
+
66
+ Appeler `initOglap` une seule fois au démarrage de l'application, avant toute autre fonction :
67
+
68
+ **Mode téléchargement (recommandé)** — les données sont téléchargées et mises en cache localement :
69
+
70
+ ```js
71
+ import { initOglap } from 'oglap-ggp-node-js';
72
+
73
+ const report = await initOglap({
74
+ version: 'latest',
75
+ dataDir: 'oglap-data', // dossier de cache local
76
+ forceDownload: false,
77
+ onProgress({ label, status, percent, step, totalSteps }) {
78
+ if (status === 'downloading') process.stdout.write(`\r↓ [${step}/${totalSteps}] ${label}: ${percent}%`);
79
+ if (status === 'cached') console.log(`⚡ [${step}/${totalSteps}] ${label}: depuis le cache`);
80
+ if (status === 'done') console.log(`\r✓ [${step}/${totalSteps}] ${label}: terminé`);
81
+ if (status === 'error') console.log(`✗ [${step}/${totalSteps}] ${label}: erreur`);
82
+ }
83
+ });
84
+
85
+ if (!report.ok) throw new Error(report.error);
86
+ ```
87
+
88
+ **Mode direct** — pour les environnements sans accès disque (serverless, edge) :
89
+
90
+ ```js
91
+ import { initOglap, loadOglap } from 'oglap-ggp-node-js';
92
+
93
+ const profile = await fetch('/data/country_profile.json').then(r => r.json());
94
+ const localities = await fetch('/data/localities_naming.json').then(r => r.json());
95
+ const places = await fetch('/data/oglap_data.json').then(r => r.json());
96
+
97
+ const report = await initOglap(profile, localities);
98
+ if (!report.ok) throw new Error(report.error);
99
+
100
+ const loaded = loadOglap(places);
101
+ console.log(`${loaded.count} lieux chargés`);
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Utilisation
107
+
108
+ ### Encoder des coordonnées en code LAP
109
+
110
+ ```js
111
+ import { coordinatesToLap } from 'oglap-ggp-node-js';
112
+
113
+ const result = coordinatesToLap(9.5370, -13.6773); // lat, lon — Conakry, Guinée
114
+
115
+ console.log(result.lapCode); // GN-CKY-QKAR-B4A4-2798
116
+ console.log(result.humanAddress); // Quartier Almamya, Conakry, Kindia, Guinée
117
+ console.log(result.admin_level_2); // CKY (code de région)
118
+ console.log(result.admin_level_3); // QKAR (code de zone)
119
+ console.log(result.macroblock); // B4A4
120
+ console.log(result.microspot); // 2798
121
+ console.log(result.isNationalGrid); // false
122
+ console.log(result.originLat); // latitude d'origine de la bbox
123
+ console.log(result.originLon); // longitude d'origine de la bbox
124
+ ```
125
+
126
+ Retourne `null` si les coordonnées sont hors du territoire.
127
+
128
+ ### Décoder un code LAP en coordonnées
129
+
130
+ ```js
131
+ import { lapToCoordinates } from 'oglap-ggp-node-js';
132
+
133
+ const coords = lapToCoordinates('GN-CKY-QKAR-B4A4-2798');
134
+
135
+ if (coords) {
136
+ console.log(`lat: ${coords.lat}, lon: ${coords.lon}`);
137
+ // lat: 9.5370..., lon: -13.6773...
138
+ }
139
+
140
+ // Le préfixe pays est optionnel
141
+ const coords2 = lapToCoordinates('CKY-QKAR-B4A4-2798');
142
+ ```
143
+
144
+ ### Parser et valider un code LAP
145
+
146
+ ```js
147
+ import { validateLapCode, parseLapCode } from 'oglap-ggp-node-js';
148
+
149
+ // Valider — retourne un message d'erreur, ou null si valide
150
+ const error = validateLapCode('GN-CKY-QKAR-B4A4-2798');
151
+ if (error) {
152
+ console.log('Invalide :', error);
153
+ } else {
154
+ console.log('Code valide');
155
+ }
156
+
157
+ // Parser en composants
158
+ const parsed = parseLapCode('GN-CKY-QKAR-B4A4-2798');
159
+ if (parsed) {
160
+ console.log(parsed.admin_level_2_Iso); // code ISO de la région
161
+ console.log(parsed.admin_level_3_code); // code de zone : QKAR
162
+ console.log(parsed.macroblock); // B4A4
163
+ console.log(parsed.microspot); // 2798
164
+ console.log(parsed.isNationalGrid); // false
165
+ }
166
+
167
+ // Les codes partiels sont aussi supportés
168
+ parseLapCode('GN-CKY-QKAR'); // région + zone seulement
169
+ parseLapCode('QKAR'); // zone seulement
170
+ ```
171
+
172
+ ### Résoudre un code LAP vers un lieu
173
+
174
+ ```js
175
+ import { getPlaceByLapCode } from 'oglap-ggp-node-js';
176
+
177
+ const resolved = getPlaceByLapCode('GN-CKY-QKAR-B4A4-2798');
178
+
179
+ if (resolved) {
180
+ console.log(resolved.originLat); // latitude d'origine de la bbox
181
+ console.log(resolved.originLon); // longitude d'origine de la bbox
182
+
183
+ if (resolved.place) {
184
+ const addr = resolved.place.address;
185
+ const name = addr?.village ?? addr?.town ?? addr?.city ?? resolved.place.display_name;
186
+ console.log(name); // nom du lieu lisible
187
+ }
188
+
189
+ // Accéder aux composants parsés
190
+ console.log(resolved.parsed.macroblock); // B4A4
191
+ console.log(resolved.parsed.microspot); // 2798
192
+ }
193
+ ```
194
+
195
+ ### Boîte englobante et centroïde
196
+
197
+ ```js
198
+ import { bboxFromGeometry, centroidFromBbox } from 'oglap-ggp-node-js';
199
+
200
+ const geometry = {
201
+ type: 'Polygon',
202
+ coordinates: [[
203
+ [-13.70, 9.50],
204
+ [-13.65, 9.50],
205
+ [-13.65, 9.55],
206
+ [-13.70, 9.55],
207
+ [-13.70, 9.50],
208
+ ]]
209
+ };
210
+
211
+ const bbox = bboxFromGeometry(geometry);
212
+ // [minLat, maxLat, minLon, maxLon]
213
+
214
+ if (bbox) {
215
+ const center = centroidFromBbox(bbox);
216
+ // [lat, lon] du point central de la bbox
217
+ console.log(`Centre : ${center[0]}, ${center[1]}`);
218
+ }
219
+ ```
220
+
221
+ ### Accéder aux métadonnées du pays
222
+
223
+ ```js
224
+ import { getCountryCode, getCountrySW, getOglapPrefectures } from 'oglap-ggp-node-js';
225
+
226
+ console.log(getCountryCode()); // "GN"
227
+ console.log(getCountrySW()); // [lat, lon] du coin sud-ouest
228
+
229
+ const prefectures = getOglapPrefectures();
230
+ // { [isoCode]: prefectureOglapCode, ... }
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Modèles de données
236
+
237
+ ### Résultat de `coordinatesToLap`
238
+
239
+ | Champ | Type | Description |
240
+ |---|---|---|
241
+ | `lapCode` | `string` | Code LAP complet, ex. `GN-CKY-QKAR-B4A4-2798` |
242
+ | `country` | `string` | Code pays, ex. `GN` |
243
+ | `admin_level_2` | `string` | Code de région, ex. `CKY` |
244
+ | `admin_level_3` | `string\|null` | Code de zone, ex. `QKAR` |
245
+ | `macroblock` | `string` | Composant macrobloc |
246
+ | `microspot` | `string` | Composant microspot |
247
+ | `isNationalGrid` | `boolean` | `true` si grille nationale utilisée |
248
+ | `displayName` | `string` | Nom du lieu issu du geocodage inversé |
249
+ | `humanAddress` | `string` | Adresse complète lisible |
250
+ | `address` | `object` | Composants d'adresse structurés |
251
+ | `originLat` | `number` | Latitude d'origine de la bbox |
252
+ | `originLon` | `number` | Longitude d'origine de la bbox |
253
+ | `pcode` | `string[]` | P-codes UNOCHA pour la localisation |
254
+
255
+ ### Résultat de `parseLapCode`
256
+
257
+ | Champ | Type | Description |
258
+ |---|---|---|
259
+ | `admin_level_2_Iso` | `string\|undefined` | Code ISO de la région |
260
+ | `admin_level_3_code` | `string\|undefined` | Code de zone |
261
+ | `macroblock` | `string\|undefined` | Composant macrobloc |
262
+ | `microspot` | `string\|undefined` | Composant microspot |
263
+ | `isNationalGrid` | `boolean` | `true` si grille nationale |
264
+
265
+ ### Résultat de `getPlaceByLapCode`
266
+
267
+ | Champ | Type | Description |
268
+ |---|---|---|
269
+ | `place` | `object\|null` | Données OSM du lieu |
270
+ | `parsed` | `object` | Composants LAP parsés |
271
+ | `originLat` | `number\|undefined` | Latitude d'origine de la bbox |
272
+ | `originLon` | `number\|undefined` | Longitude d'origine de la bbox |
273
+
274
+ ### Rapport de `initOglap`
275
+
276
+ | Champ | Type | Description |
277
+ |---|---|---|
278
+ | `ok` | `boolean` | Initialisation réussie |
279
+ | `countryCode` | `string\|null` | Ex. `GN` |
280
+ | `countryName` | `string\|null` | Ex. `Guinée` |
281
+ | `bounds` | `number[][]\|null` | `[[swLat, swLon], [neLat, neLon]]` |
282
+ | `checks` | `Array` | Résultats de validation (`pass`, `warn`, `fail`) |
283
+ | `error` | `string\|null` | Message d'erreur si `!ok` |
284
+ | `dataDir` | `string\|undefined` | Dossier de cache local |
285
+ | `dataLoaded` | `object\|undefined` | Résultat du chargement des lieux |
286
+
287
+ ---
288
+
289
+ ## Exemple complet de bout en bout
290
+
291
+ ```js
292
+ import {
293
+ initOglap,
294
+ checkOglap,
295
+ coordinatesToLap,
296
+ lapToCoordinates,
297
+ getPlaceByLapCode,
298
+ validateLapCode,
299
+ parseLapCode,
300
+ } from 'oglap-ggp-node-js';
301
+
302
+ class LocationService {
303
+ static #initialized = false;
304
+
305
+ static async init() {
306
+ if (this.#initialized) return;
307
+
308
+ const report = await initOglap({
309
+ onProgress({ label, status, percent, step, totalSteps }) {
310
+ if (status === 'downloading') process.stdout.write(`\r↓ [${step}/${totalSteps}] ${label}: ${percent}%`);
311
+ if (status === 'cached') console.log(`⚡ [${step}/${totalSteps}] ${label}: depuis le cache`);
312
+ if (status === 'done') console.log(`\r✓ [${step}/${totalSteps}] ${label}: terminé`);
313
+ if (status === 'error') console.log(`✗ [${step}/${totalSteps}] ${label}: erreur`);
314
+ }
315
+ });
316
+
317
+ if (!report.ok) throw new Error(`Initialisation OGLAP échouée : ${report.error}`);
318
+ this.#initialized = true;
319
+ }
320
+
321
+ /** Encoder la position GPS de l'utilisateur */
322
+ static encodePosition(lat, lon) {
323
+ const result = coordinatesToLap(lat, lon);
324
+ return result?.lapCode ?? null;
325
+ }
326
+
327
+ /** Partager une localisation : retourne le code LAP et l'adresse lisible */
328
+ static shareLocation(lat, lon) {
329
+ const result = coordinatesToLap(lat, lon);
330
+ if (!result) return null;
331
+ return {
332
+ code: result.lapCode,
333
+ label: result.humanAddress,
334
+ };
335
+ }
336
+
337
+ /** Naviguer vers un code LAP en le convertissant en coordonnées */
338
+ static decodeToCoords(lapCode) {
339
+ return lapToCoordinates(lapCode); // { lat, lon } ou null
340
+ }
341
+
342
+ /** Valider la saisie d'un code LAP par l'utilisateur */
343
+ static validateInput(input) {
344
+ return validateLapCode(input); // null = valide, string = message d'erreur
345
+ }
346
+
347
+ /** Résoudre un code LAP vers les détails du lieu */
348
+ static resolvePlace(lapCode) {
349
+ const resolved = getPlaceByLapCode(lapCode);
350
+ if (!resolved?.place) return null;
351
+
352
+ const addr = resolved.place.address ?? {};
353
+ return {
354
+ name: addr.village ?? addr.town ?? addr.city ?? resolved.place.display_name,
355
+ adminCode: resolved.parsed.admin_level_3_code,
356
+ originLat: resolved.originLat,
357
+ originLon: resolved.originLon,
358
+ };
359
+ }
360
+ }
361
+
362
+ // Utilisation
363
+ await LocationService.init();
364
+
365
+ // Encoder le centre de Conakry
366
+ const code = LocationService.encodePosition(9.5370, -13.6773);
367
+ console.log(code); // GN-CKY-QKAR-B4A4-2798
368
+
369
+ // Décoder
370
+ const coords = LocationService.decodeToCoords(code);
371
+ console.log(coords); // { lat: 9.537..., lon: -13.677... }
372
+
373
+ // Partager
374
+ const share = LocationService.shareLocation(9.5370, -13.6773);
375
+ console.log(share.label); // Quartier Almamya, Conakry, Kindia, Guinée
376
+
377
+ // Valider la saisie utilisateur
378
+ const err = LocationService.validateInput('GN-CKY-QKAR-B4A4-2798');
379
+ console.log(err); // null (valide)
380
+
381
+ // Résoudre un lieu
382
+ const place = LocationService.resolvePlace('GN-CKY-QKAR-B4A4-2798');
383
+ console.log(place.name); // Quartier Almamya
384
+ ```
385
+
386
+ ---
387
+
388
+ ## Exécuter les tests
389
+
390
+ ```bash
391
+ node test.js
392
+ ```
393
+
394
+ ---
395
+
396
+ ## Informations complémentaires
397
+
398
+ - **Protocole** : OGLAP est conçu pour la Guinée (`GN`) mais configurable pour tout pays via le fichier profil JSON.
399
+ - **Offline-first** : tout l'encodage et décodage est effectué localement avec les données chargées — aucun réseau requis.
400
+ - **Déterministe** : les mêmes coordonnées produisent toujours le même code LAP, à données identiques.
401
+ - **Bugs** : signaler les problèmes dans le dépôt principal Kiraa.
package/package.json CHANGED
@@ -1,37 +1,43 @@
1
1
  {
2
2
  "name": "oglap-ggp-node",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Permettre aux développeurs d'installer rapidement le SDK et ses dépendances pour commencer à l'utiliser dans leurs projets.",
5
+ "type": "module",
6
+ "main": "oglap.js",
7
+ "module": "oglap.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./oglap.js",
11
+ "default": "./oglap.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "oglap.js",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node test.js"
20
+ },
21
+ "dependencies": {
22
+ "@turf/boolean-point-in-polygon": "^7.2.0",
23
+ "@turf/area": "^7.2.0"
24
+ },
5
25
  "keywords": [
6
26
  "oglap",
7
- "oglap-ggp",
8
- "geolocation",
9
- "maps",
27
+ "ggp",
28
+ "geocoding",
29
+ "coordinates",
10
30
  "guinea",
11
- "conakry",
12
- "guinee",
13
- "addressing",
14
- "protocol",
15
- "offline",
16
- "address",
17
- "offline",
18
- "maps",
19
- "oglap-maps",
20
- "kiraa"
31
+ "geolocation"
21
32
  ],
22
- "homepage": "https://github.com/Guinee-IO/oglap-ggp-node-js#readme",
23
- "bugs": {
24
- "url": "https://github.com/Guinee-IO/oglap-ggp-node-js/issues"
25
- },
33
+ "author": "Guinee IO",
34
+ "license": "ISC",
26
35
  "repository": {
27
36
  "type": "git",
28
37
  "url": "git+https://github.com/Guinee-IO/oglap-ggp-node-js.git"
29
38
  },
30
- "license": "ISC",
31
- "author": "Guinee IO",
32
- "type": "commonjs",
33
- "main": "oglap.js",
34
- "scripts": {
35
- "test": "node test.js"
36
- }
39
+ "bugs": {
40
+ "url": "https://github.com/Guinee-IO/oglap-ggp-node-js/issues"
41
+ },
42
+ "homepage": "https://github.com/Guinee-IO/oglap-ggp-node-js#readme"
37
43
  }
Binary file
package/test.js DELETED
@@ -1,349 +0,0 @@
1
- // test.js — Full OGLAP engine test
2
- import {
3
- // Init & state
4
- initOglap,
5
- loadOglap,
6
- checkOglap,
7
- getPackageVersion,
8
- getCountryProfile,
9
- getCountryCode,
10
- getCountrySW,
11
- getOglapPrefectures,
12
- getOglapPlaces,
13
- // Core functions
14
- parseLapCode,
15
- validateLapCode,
16
- getPlaceByLapCode,
17
- lapToCoordinates,
18
- coordinatesToLap,
19
- bboxFromGeometry,
20
- centroidFromBbox,
21
- } from './oglap.js';
22
-
23
- // ── Helpers ──
24
- let passed = 0, failed = 0;
25
- function section(title) { console.log(`\n${'═'.repeat(60)}\n ${title}\n${'═'.repeat(60)}`); }
26
- function log(label, value) { console.log(` ${label}:`, value); }
27
- function ok(label, value) { passed++; console.log(` ✓ ${label}:`, value); }
28
- function fail(label, value) { failed++; console.error(` ✗ ${label}:`, value); }
29
-
30
- // ══════════════════════════════════════════════════════════════
31
- // 1. PRE-INIT STATE
32
- // ══════════════════════════════════════════════════════════════
33
- section('1. Pre-init state');
34
-
35
- log('Package version', getPackageVersion());
36
-
37
- const preCheck = checkOglap();
38
- if (!preCheck.ok) ok('checkOglap() before init', preCheck.error);
39
- else fail('checkOglap() should not be ok before init', preCheck);
40
-
41
- // ══════════════════════════════════════════════════════════════
42
- // 2. INIT (download mode)
43
- // ══════════════════════════════════════════════════════════════
44
- section('2. initOglap() — downloading latest');
45
-
46
- const report = await initOglap({
47
- onProgress({ label, status, percent, step, totalSteps }) {
48
- if (status === 'downloading') {
49
- process.stdout.write(`\r ↓ [${step}/${totalSteps}] ${label}: ${percent}% `);
50
- } else if (status === 'cached') {
51
- console.log(` ⚡ [${step}/${totalSteps}] ${label}: loaded from cache`);
52
- } else if (status === 'slow') {
53
- console.log(`\n ⚠ Slow network detected for ${label}`);
54
- } else if (status === 'done') {
55
- console.log(`\r ✓ [${step}/${totalSteps}] ${label}: done `);
56
- } else if (status === 'error') {
57
- console.log(`\n ✗ [${step}/${totalSteps}] ${label}: ERROR`);
58
- } else if (status === 'validating') {
59
- console.log(` … Validating configuration`);
60
- }
61
- }
62
- });
63
-
64
- console.log('\n --- Init report ---');
65
- log('OK', report.ok);
66
- log('Country', `${report.countryName} (${report.countryCode})`);
67
- log('Bounds', report.bounds);
68
- log('Data dir', report.dataDir);
69
- if (report.dataLoaded) log('Places loaded', report.dataLoaded.message);
70
- console.log(' --- Checks ---');
71
- report.checks.forEach(c => {
72
- const icon = c.status === 'pass' ? '✓' : c.status === 'warn' ? '⚠' : '✗';
73
- console.log(` ${icon} [${c.id}] ${c.message}`);
74
- });
75
-
76
- if (!report.ok) {
77
- fail('initOglap failed — cannot continue', report.error);
78
- process.exit(1);
79
- }
80
- ok('initOglap', 'success');
81
-
82
- // ══════════════════════════════════════════════════════════════
83
- // 3. STATE GETTERS
84
- // ══════════════════════════════════════════════════════════════
85
- section('3. State getters');
86
-
87
- const postCheck = checkOglap();
88
- postCheck.ok ? ok('checkOglap()', `ok, country=${postCheck.countryCode}`) : fail('checkOglap()', postCheck);
89
-
90
- log('getCountryCode()', getCountryCode());
91
- log('getCountrySW()', getCountrySW());
92
-
93
- const profile = getCountryProfile();
94
- log('getCountryProfile() schema', profile.schema_id);
95
-
96
- const prefectures = getOglapPrefectures();
97
- const prefKeys = Object.keys(prefectures);
98
- log('getOglapPrefectures()', `${prefKeys.length} entries (first 5: ${prefKeys.slice(0, 5).join(', ')})`);
99
-
100
- const places = getOglapPlaces();
101
- log('getOglapPlaces()', `${places.length} places`);
102
-
103
- // ══════════════════════════════════════════════════════════════
104
- // 4. coordinatesToLap — encode GPS → LAP code
105
- // ══════════════════════════════════════════════════════════════
106
- section('4. coordinatesToLap — GPS → LAP');
107
-
108
- const testCoords = [
109
- { name: 'Conakry center', lat: 9.5370, lon: -13.6785 },
110
- { name: 'Nzérékoré', lat: 7.7562, lon: -8.8179 },
111
- { name: 'Kankan', lat: 10.3854, lon: -9.3057 },
112
- { name: 'Labé', lat: 11.3183, lon: -12.2860 },
113
- { name: 'Kindia', lat: 10.0565, lon: -12.8665 },
114
- ];
115
-
116
- const generatedLaps = [];
117
- for (const { name, lat, lon } of testCoords) {
118
- try {
119
- const result = coordinatesToLap(lat, lon, places);
120
- if (result?.lapCode) {
121
- ok(`${name} (${lat}, ${lon})`, `${result.lapCode} → ${result.humanAddress}`);
122
- generatedLaps.push({ name, lat, lon, lap: result.lapCode, result });
123
- } else {
124
- fail(`${name} (${lat}, ${lon})`, 'returned null/undefined');
125
- }
126
- } catch (err) {
127
- fail(`${name} (${lat}, ${lon})`, err.message);
128
- }
129
- }
130
-
131
- // ══════════════════════════════════════════════════════════════
132
- // 4b. coordinatesToLap — National grid (fallback for admin_level < 9)
133
- // ══════════════════════════════════════════════════════════════
134
- section('4b. coordinatesToLap — National grid');
135
-
136
- const nationalTestCoords = [
137
- { name: 'Rural Siguiri (Kankan)', lat: 11.70, lon: -9.30 },
138
- { name: 'Rural Macenta (Nzérékoré)', lat: 8.40, lon: -9.40 },
139
- { name: 'Rural Boké (Boké)', lat: 11.20, lon: -14.20 },
140
- { name: 'Rural Faranah (Faranah)', lat: 10.10, lon: -10.80 },
141
- ];
142
-
143
- const generatedNationalLaps = [];
144
- for (const { name, lat, lon } of nationalTestCoords) {
145
- try {
146
- const result = coordinatesToLap(lat, lon, places);
147
- if (result?.lapCode) {
148
- if (result.isNationalGrid) {
149
- ok(`${name} (${lat}, ${lon})`, `${result.lapCode} → ${result.humanAddress} [NATIONAL]`);
150
- } else {
151
- ok(`${name} (${lat}, ${lon})`, `${result.lapCode} → ${result.humanAddress} [LOCAL — zone found]`);
152
- }
153
- generatedNationalLaps.push({ name, lat, lon, lap: result.lapCode, result });
154
- } else {
155
- fail(`${name} (${lat}, ${lon})`, 'returned null/undefined');
156
- }
157
- } catch (err) {
158
- fail(`${name} (${lat}, ${lon})`, err.message);
159
- }
160
- }
161
-
162
- // Verify at least one is actually national grid
163
- const nationalCount = generatedNationalLaps.filter(g => g.result.isNationalGrid).length;
164
- if (nationalCount > 0) {
165
- ok('National grid coverage', `${nationalCount}/${generatedNationalLaps.length} used national grid`);
166
- } else {
167
- fail('National grid coverage', 'None of the test coordinates triggered national grid fallback — adjust coordinates');
168
- }
169
-
170
- // Merge into generatedLaps for sections 5-10
171
- generatedLaps.push(...generatedNationalLaps);
172
-
173
- // ══════════════════════════════════════════════════════════════
174
- // 4c. coordinatesToLap — Out-of-bounds rejection
175
- // ══════════════════════════════════════════════════════════════
176
- section('4c. coordinatesToLap — Out-of-bounds rejection');
177
-
178
- const outOfBoundsCoords = [
179
- // Far away — caught by bbox
180
- { name: 'Dakar, Senegal', lat: 14.6928, lon: -17.4467 },
181
- { name: 'Atlantic Ocean', lat: 9.00, lon: -18.00 },
182
- // Inside bbox but outside Guinea polygon — caught by country border check
183
- { name: 'Bamako, Mali', lat: 12.6392, lon: -8.0029 },
184
- { name: 'Freetown, Sierra Leone', lat: 8.4657, lon: -13.2317 },
185
- // Tricky: neighboring countries very close to Guinea border
186
- { name: 'Bissau, Guinea-Bissau', lat: 11.8617, lon: -15.5977 },
187
- { name: 'Monrovia, Liberia', lat: 6.3156, lon: -10.8074 },
188
- { name: 'Kédougou, Senegal (near GN border)', lat: 12.5605, lon: -12.1747 },
189
- ];
190
-
191
- for (const { name, lat, lon } of outOfBoundsCoords) {
192
- try {
193
- const result = coordinatesToLap(lat, lon, places);
194
- if (result === null) {
195
- ok(`${name} (${lat}, ${lon})`, 'correctly rejected — outside country bounds');
196
- } else {
197
- fail(`${name} (${lat}, ${lon})`, `should be null but got ${result.lapCode}`);
198
- }
199
- } catch (err) {
200
- fail(`${name} (${lat}, ${lon})`, err.message);
201
- }
202
- }
203
-
204
- // ══════════════════════════════════════════════════════════════
205
- // 5. parseLapCode — parse a LAP code into segments
206
- // ══════════════════════════════════════════════════════════════
207
- section('5. parseLapCode');
208
-
209
- for (const { name, lap } of generatedLaps) {
210
- try {
211
- const parsed = parseLapCode(lap);
212
- parsed ? ok(`parse "${lap}"`, JSON.stringify(parsed)) : fail(`parse "${lap}"`, 'returned null');
213
- } catch (err) {
214
- fail(`parse "${lap}"`, err.message);
215
- }
216
- }
217
-
218
- // ══════════════════════════════════════════════════════════════
219
- // 6. validateLapCode
220
- // ══════════════════════════════════════════════════════════════
221
- section('6. validateLapCode');
222
-
223
- for (const { lap } of generatedLaps) {
224
- try {
225
- const result = validateLapCode(lap);
226
- // null = valid (no error), string = error message
227
- result === null ? ok(`validate "${lap}"`, 'valid') : fail(`validate "${lap}"`, result);
228
- } catch (err) {
229
- fail(`validate "${lap}"`, err.message);
230
- }
231
- }
232
-
233
- // Test with an invalid code — should return an error string
234
- try {
235
- const bad = validateLapCode('QQ-ZZZ-GARBAGE');
236
- bad ? ok('validate invalid "QQ-ZZZ-GARBAGE"', bad) : fail('validate invalid should return error string', 'got null');
237
- } catch (err) {
238
- ok('validate invalid throws', err.message);
239
- }
240
-
241
- // ══════════════════════════════════════════════════════════════
242
- // 7. lapToCoordinates — decode LAP → GPS
243
- // ══════════════════════════════════════════════════════════════
244
- section('7. lapToCoordinates — LAP → GPS');
245
-
246
- // lapToCoordinates(lapCode) — just pass the LAP code string
247
- for (const { name, lat, lon, lap, result: encResult } of generatedLaps) {
248
- try {
249
- const parsed = parseLapCode(lap);
250
- const grid = parsed.isNationalGrid ? 'national' : 'local';
251
- const coords = lapToCoordinates(lap);
252
- if (coords) {
253
- const dist = Math.sqrt((coords.lat - lat) ** 2 + (coords.lon - lon) ** 2);
254
- ok(`decode "${lap}" [${grid}]`, `lat=${coords.lat.toFixed(6)}, lon=${coords.lon.toFixed(6)} (Δ≈${(dist * 111320).toFixed(1)}m from original)`);
255
- } else {
256
- fail(`decode "${lap}" [${grid}]`, 'returned null');
257
- }
258
- } catch (err) {
259
- fail(`decode "${lap}"`, err.message);
260
- }
261
- }
262
-
263
- // Test without country prefix (strip "GN-" from a code)
264
- try {
265
- const sampleLap = generatedLaps[0]?.lap;
266
- if (sampleLap && sampleLap.startsWith(getCountryCode() + '-')) {
267
- const withoutCC = sampleLap.slice(getCountryCode().length + 1);
268
- const coords = lapToCoordinates(withoutCC);
269
- coords ? ok(`decode without CC "${withoutCC}"`, `lat=${coords.lat.toFixed(6)}, lon=${coords.lon.toFixed(6)}`)
270
- : fail(`decode without CC "${withoutCC}"`, 'returned null');
271
- }
272
- } catch (err) {
273
- fail('decode without CC', err.message);
274
- }
275
-
276
- // ══════════════════════════════════════════════════════════════
277
- // 8. getPlaceByLapCode — look up place from LAP code
278
- // ══════════════════════════════════════════════════════════════
279
- section('8. getPlaceByLapCode');
280
-
281
- for (const { lap } of generatedLaps) {
282
- try {
283
- // getPlaceByLapCode returns { place, parsed, originLat?, originLon? }
284
- const match = getPlaceByLapCode(lap);
285
- if (match?.place) {
286
- const addr = match.place.address || {};
287
- const pName = addr.village || addr.town || addr.city || addr.suburb || '(unnamed)';
288
- ok(`lookup "${lap}"`, `place_id=${match.place.place_id}, name="${pName}"`);
289
- } else if (match) {
290
- ok(`lookup "${lap}"`, `matched (parsed: ${JSON.stringify(match.parsed)}, no place — likely national grid)`);
291
- } else {
292
- log(`lookup "${lap}"`, 'no match found');
293
- }
294
- } catch (err) {
295
- fail(`lookup "${lap}"`, err.message);
296
- }
297
- }
298
-
299
- // ══════════════════════════════════════════════════════════════
300
- // 9. bboxFromGeometry & centroidFromBbox
301
- // ══════════════════════════════════════════════════════════════
302
- section('9. bboxFromGeometry & centroidFromBbox');
303
-
304
- const samplePlace = places.find(p => p.geojson?.type === 'Polygon' || p.geojson?.type === 'MultiPolygon');
305
- if (samplePlace) {
306
- try {
307
- const bbox = bboxFromGeometry(samplePlace.geojson);
308
- ok('bboxFromGeometry', JSON.stringify(bbox));
309
-
310
- const centroid = centroidFromBbox(bbox);
311
- ok('centroidFromBbox', JSON.stringify(centroid));
312
- } catch (err) {
313
- fail('bboxFromGeometry/centroidFromBbox', err.message);
314
- }
315
- } else {
316
- log('skip', 'no polygon geometry found in places');
317
- }
318
-
319
- // ══════════════════════════════════════════════════════════════
320
- // 10. Round-trip: encode → decode → re-encode
321
- // ══════════════════════════════════════════════════════════════
322
- section('10. Round-trip consistency');
323
-
324
- for (const { name, lat, lon, lap, result: encResult } of generatedLaps) {
325
- try {
326
- const parsed = parseLapCode(lap);
327
- const grid = parsed.isNationalGrid ? 'national' : 'local';
328
- const decoded = lapToCoordinates(lap);
329
- if (!decoded) { fail(`round-trip ${name} [${grid}]`, 'decode returned null'); continue; }
330
- const reResult = coordinatesToLap(decoded.lat, decoded.lon, places);
331
- const reEncoded = reResult?.lapCode;
332
- if (reEncoded === lap) {
333
- ok(`${name} [${grid}]: encode→decode→encode`, `${lap} ✓`);
334
- } else {
335
- fail(`${name} [${grid}]: encode→decode→encode`, `${lap} → (${decoded.lat},${decoded.lon}) → ${reEncoded}`);
336
- }
337
- } catch (err) {
338
- fail(`round-trip ${name}`, err.message);
339
- }
340
- }
341
-
342
- // ══════════════════════════════════════════════════════════════
343
- // SUMMARY
344
- // ══════════════════════════════════════════════════════════════
345
- section('SUMMARY');
346
- console.log(` Passed: ${passed}`);
347
- console.log(` Failed: ${failed}`);
348
- console.log(` Total: ${passed + failed}`);
349
- process.exit(failed > 0 ? 1 : 0);