oglap-ggp-node 1.0.0 → 1.0.3

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,386 @@
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-CON-QYTC-B0B1-2282`) 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 - CON - QYTC - B0B1 - 2282
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, indiquant la localite administrative immediate de niveau 8 et plus (example - QYTC pour Yattaya - Fossedè)
30
+ │ └───────────────────── Région — 3 chars, code de la localite administrative de niveau 4 ou 6 immediate (example - CON pour Conakry)
31
+ └──────────────────────────── Pays — code ISO alpha-2 du pays (example - gn pour Guinee)
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 - NZE - AABCDE - 4250
38
+ │ │ │ └─ Microspot — 4 chiffres
39
+ │ │ └────────── Macrobloc — 6 lettres, grille kilométrique nationale
40
+ │ └──────────────── Région — 3 chars, code de la localite administrative de niveau 4 ou 6 immediate (example - NZE pour Nzérékoré)
41
+ └─────────────────────── Pays — code ISO alpha-2 du pays (example - gn pour Guinee)
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
+ ---
89
+
90
+ ## Utilisation
91
+
92
+ ### Encoder des coordonnées en code LAP
93
+
94
+ ```js
95
+ import { coordinatesToLap } from 'oglap-ggp-node-js';
96
+
97
+ const result = coordinatesToLap(9.5370, -13.6773); // lat, lon — Conakry, Guinée
98
+
99
+ console.log(result.lapCode); // GN-CON-QYTC-B0B1-2282
100
+ console.log(result.humanAddress); // B0B1-2282, Yattaya Fossedè, Conakry, Guinée
101
+ console.log(result.admin_level_2); // CON (code de région)
102
+ console.log(result.admin_level_3); // QYTC (code de zone)
103
+ console.log(result.macroblock); // B0B1
104
+ console.log(result.microspot); // 2282
105
+ console.log(result.isNationalGrid); // false
106
+ console.log(result.originLat); // latitude d'origine de la bbox
107
+ console.log(result.originLon); // longitude d'origine de la bbox
108
+ ```
109
+
110
+ Retourne `null` si les coordonnées sont hors du territoire.
111
+
112
+ ### Décoder un code LAP en coordonnées
113
+
114
+ ```js
115
+ import { lapToCoordinates } from 'oglap-ggp-node-js';
116
+
117
+ const coords = lapToCoordinates('GN-CON-QYTC-B0B1-2282');
118
+
119
+ if (coords) {
120
+ console.log(`lat: ${coords.lat}, lon: ${coords.lon}`);
121
+ // lat: 9.5370..., lon: -13.6773...
122
+ }
123
+
124
+ // Le préfixe pays est optionnel
125
+ const coords2 = lapToCoordinates('GN-CON-QYTC-B0B1-2282');
126
+ ```
127
+
128
+ ### Parser et valider un code LAP
129
+
130
+ ```js
131
+ import { validateLapCode, parseLapCode } from 'oglap-ggp-node-js';
132
+
133
+ // Valider — retourne un message d'erreur, ou null si valide
134
+ const error = validateLapCode('GN-CON-QYTC-B0B1-2282');
135
+ if (error) {
136
+ console.log('Invalide :', error);
137
+ } else {
138
+ console.log('Code valide');
139
+ }
140
+
141
+ // Parser en composants
142
+ const parsed = parseLapCode('GN-CON-QYTC-B0B1-2282');
143
+ if (parsed) {
144
+ console.log(parsed.admin_level_2_Iso); // code ISO du pays : GN
145
+ console.log(parsed.admin_level_3_code); // code de la localite administrative de niveau 4 ou 6 immediate : CON
146
+ console.log(parsed.admin_level_4_code); // code de la localite administrative de niveau 8 et plus : QYTC
147
+ console.log(parsed.macroblock); // B0B1 macrobloc ~100m x 100m dans la zone
148
+ console.log(parsed.microspot); // 2282 microspot ~1m x 1m dans la zone
149
+ console.log(parsed.isNationalGrid); // false
150
+ }
151
+
152
+ // Les codes partiels sont aussi supportés
153
+ parseLapCode('GN-CON-QYTC'); // région + zone seulement
154
+ parseLapCode('QYTC'); // zone seulement
155
+ ```
156
+
157
+ ### Résoudre un code LAP vers un lieu
158
+
159
+ ```js
160
+ import { getPlaceByLapCode } from 'oglap-ggp-node-js';
161
+
162
+ const resolved = getPlaceByLapCode('GN-CON-QYTC-B0B1-2282');
163
+
164
+ if (resolved) {
165
+ console.log(resolved.originLat); // latitude d'origine de la bbox
166
+ console.log(resolved.originLon); // longitude d'origine de la bbox
167
+
168
+ if (resolved.place) {
169
+ const addr = resolved.place.address;
170
+ const name = addr?.village ?? addr?.town ?? addr?.city ?? resolved.place.display_name;
171
+ console.log(name); // nom du lieu lisible
172
+ }
173
+
174
+ // Accéder aux composants parsés
175
+ console.log(resolved.parsed.macroblock); // B0B1
176
+ console.log(resolved.parsed.microspot); // 2282
177
+ }
178
+ ```
179
+
180
+ ### Boîte englobante et centroïde
181
+
182
+ ```js
183
+ import { bboxFromGeometry, centroidFromBbox } from 'oglap-ggp-node-js';
184
+
185
+ const geometry = {
186
+ type: 'Polygon',
187
+ coordinates: [[
188
+ [-13.70, 9.50],
189
+ [-13.65, 9.50],
190
+ [-13.65, 9.55],
191
+ [-13.70, 9.55],
192
+ [-13.70, 9.50],
193
+ ]]
194
+ };
195
+
196
+ const bbox = bboxFromGeometry(geometry);
197
+ // [minLat, maxLat, minLon, maxLon]
198
+
199
+ if (bbox) {
200
+ const center = centroidFromBbox(bbox);
201
+ // [lat, lon] du point central de la bbox
202
+ console.log(`Centre : ${center[0]}, ${center[1]}`);
203
+ }
204
+ ```
205
+
206
+ ### Accéder aux métadonnées du pays
207
+
208
+ ```js
209
+ import { getCountryCode, getCountrySW, getOglapPrefectures } from 'oglap-ggp-node-js';
210
+
211
+ console.log(getCountryCode()); // "GN"
212
+ console.log(getCountrySW()); // [lat, lon] du coin sud-ouest
213
+
214
+ const prefectures = getOglapPrefectures();
215
+ // { [isoCode]: prefectureOglapCode, ... }
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Modèles de données
221
+
222
+ ### Résultat de `coordinatesToLap`
223
+
224
+ | Champ | Type | Description |
225
+ |---|---|---|
226
+ | `lapCode` | `string` | Code LAP complet, ex. `GN-CON-QYTC-B0B1-2282` |
227
+ | `country` | `string` | Code pays, ex. `GN` |
228
+ | `admin_level_2` | `string` | Code de région, ex. `CON` |
229
+ | `admin_level_3` | `string\|null` | Code de zone, ex. `QYTC` |
230
+ | `macroblock` | `string` | Composant macrobloc |
231
+ | `microspot` | `string` | Composant microspot |
232
+ | `isNationalGrid` | `boolean` | `true` si grille nationale utilisée |
233
+ | `displayName` | `string` | Nom du lieu issu du geocodage inversé |
234
+ | `humanAddress` | `string` | Adresse complète lisible |
235
+ | `address` | `object` | Composants d'adresse structurés |
236
+ | `originLat` | `number` | Latitude d'origine de la bbox |
237
+ | `originLon` | `number` | Longitude d'origine de la bbox |
238
+ | `pcode` | `string[]` | P-codes UNOCHA pour la localisation |
239
+
240
+ ### Résultat de `parseLapCode`
241
+
242
+ | Champ | Type | Description |
243
+ |---|---|---|
244
+ | `admin_level_2_Iso` | `string\|undefined` | Code ISO de la région |
245
+ | `admin_level_3_code` | `string\|undefined` | Code de zone |
246
+ | `macroblock` | `string\|undefined` | Composant macrobloc |
247
+ | `microspot` | `string\|undefined` | Composant microspot |
248
+ | `isNationalGrid` | `boolean` | `true` si grille nationale |
249
+
250
+ ### Résultat de `getPlaceByLapCode`
251
+
252
+ | Champ | Type | Description |
253
+ |---|---|---|
254
+ | `place` | `object\|null` | Données OSM du lieu |
255
+ | `parsed` | `object` | Composants LAP parsés |
256
+ | `originLat` | `number\|undefined` | Latitude d'origine de la bbox |
257
+ | `originLon` | `number\|undefined` | Longitude d'origine de la bbox |
258
+
259
+ ### Rapport de `initOglap`
260
+
261
+ | Champ | Type | Description |
262
+ |---|---|---|
263
+ | `ok` | `boolean` | Initialisation réussie |
264
+ | `countryCode` | `string\|null` | Ex. `GN` |
265
+ | `countryName` | `string\|null` | Ex. `Guinée` |
266
+ | `bounds` | `number[][]\|null` | `[[swLat, swLon], [neLat, neLon]]` |
267
+ | `checks` | `Array` | Résultats de validation (`pass`, `warn`, `fail`) |
268
+ | `error` | `string\|null` | Message d'erreur si `!ok` |
269
+ | `dataDir` | `string\|undefined` | Dossier de cache local |
270
+ | `dataLoaded` | `object\|undefined` | Résultat du chargement des lieux |
271
+
272
+ ---
273
+
274
+ ## Exemple complet de bout en bout
275
+
276
+ ```js
277
+ import {
278
+ initOglap,
279
+ checkOglap,
280
+ coordinatesToLap,
281
+ lapToCoordinates,
282
+ getPlaceByLapCode,
283
+ validateLapCode,
284
+ parseLapCode,
285
+ } from 'oglap-ggp-node-js';
286
+
287
+ class LocationService {
288
+ static #initialized = false;
289
+
290
+ static async init() {
291
+ if (this.#initialized) return;
292
+
293
+ const report = await initOglap({
294
+ onProgress({ label, status, percent, step, totalSteps }) {
295
+ if (status === 'downloading') process.stdout.write(`\r↓ [${step}/${totalSteps}] ${label}: ${percent}%`);
296
+ if (status === 'cached') console.log(`⚡ [${step}/${totalSteps}] ${label}: depuis le cache`);
297
+ if (status === 'done') console.log(`\r✓ [${step}/${totalSteps}] ${label}: terminé`);
298
+ if (status === 'error') console.log(`✗ [${step}/${totalSteps}] ${label}: erreur`);
299
+ }
300
+ });
301
+
302
+ if (!report.ok) throw new Error(`Initialisation OGLAP échouée : ${report.error}`);
303
+ this.#initialized = true;
304
+ }
305
+
306
+ /** Encoder la position GPS de l'utilisateur */
307
+ static encodePosition(lat, lon) {
308
+ const result = coordinatesToLap(lat, lon);
309
+ return result?.lapCode ?? null;
310
+ }
311
+
312
+ /** Partager une localisation : retourne le code LAP et l'adresse lisible */
313
+ static shareLocation(lat, lon) {
314
+ const result = coordinatesToLap(lat, lon);
315
+ if (!result) return null;
316
+ return {
317
+ code: result.lapCode,
318
+ label: result.humanAddress,
319
+ };
320
+ }
321
+
322
+ /** Naviguer vers un code LAP en le convertissant en coordonnées */
323
+ static decodeToCoords(lapCode) {
324
+ return lapToCoordinates(lapCode); // { lat, lon } ou null
325
+ }
326
+
327
+ /** Valider la saisie d'un code LAP par l'utilisateur */
328
+ static validateInput(input) {
329
+ return validateLapCode(input); // null = valide, string = message d'erreur
330
+ }
331
+
332
+ /** Résoudre un code LAP vers les détails du lieu */
333
+ static resolvePlace(lapCode) {
334
+ const resolved = getPlaceByLapCode(lapCode);
335
+ if (!resolved?.place) return null;
336
+
337
+ const addr = resolved.place.address ?? {};
338
+ return {
339
+ name: addr.village ?? addr.town ?? addr.city ?? resolved.place.display_name,
340
+ adminCode: resolved.parsed.admin_level_3_code,
341
+ originLat: resolved.originLat,
342
+ originLon: resolved.originLon,
343
+ };
344
+ }
345
+ }
346
+
347
+ // Utilisation
348
+ await LocationService.init();
349
+
350
+ // Encoder
351
+ const code = LocationService.encodePosition(9.660147, -13.588009);
352
+ console.log(code); // GN-CON-QYTC-B0B1-2282
353
+
354
+ // Décoder
355
+ const coords = LocationService.decodeToCoords(code);
356
+ console.log(coords); // { lat: 9.660147, lon: -13.588009 }
357
+
358
+ // Partager
359
+ const share = LocationService.shareLocation(9.660147, -13.588009);
360
+ console.log(share.label); // B0B1-2282, Yattaya Fossedè, Conakry, Guinée
361
+
362
+ // Valider la saisie utilisateur
363
+ const err = LocationService.validateInput('GN-CON-QYTC-B0B1-2282');
364
+ console.log(err); // null (valide)
365
+
366
+ // Résoudre un lieu
367
+ const place = LocationService.resolvePlace('GN-CON-QYTC-B0B1-2282');
368
+ console.log(place.display_name); // Yattaya Fossedè, Ratoma, Conakry, Guinée
369
+ ```
370
+
371
+ ---
372
+
373
+ ## Exécuter les tests
374
+
375
+ ```bash
376
+ node test.js
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Informations complémentaires
382
+
383
+ - **Protocole** : OGLAP est conçu pour la Guinée (`GN`) mais configurable pour tout pays via le fichier profil JSON.
384
+ - **Offline-first** : tout l'encodage et décodage est effectué localement avec les données chargées — aucun réseau requis.
385
+ - **Déterministe** : les mêmes coordonnées produisent toujours le même code LAP, à données identiques.
386
+ - **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.3",
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);