From 04e275da93c3007e8d2cb450fa7fc0bc6f05fa69 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:41 +0700 Subject: [PATCH] Restrict routing to maritime ports --- scripts/seed-reference.ts | 2 + src/maritime.test.ts | 18 +++++++ src/maritime.ts | 102 ++++++++++++++++++++++++++++++++++++++ src/schema.ts | 12 +++-- 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/maritime.test.ts create mode 100644 src/maritime.ts diff --git a/scripts/seed-reference.ts b/scripts/seed-reference.ts index f38b295..9fd149c 100644 --- a/scripts/seed-reference.ts +++ b/scripts/seed-reference.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { ensureGraph, getDb } from '../dist/db.js' +import { AFRICA_MARITIME_LOCODES } from '../dist/maritime.js' type SupplierSeed = { lei: string; name: string; countryCode: string; city: string } type HubSeed = { @@ -155,6 +156,7 @@ const hubNodes = hubs.map((hub) => { country, country_code: hub.countryCode, transport_types: transportTypes(hub.functionCode), + is_maritime_port: AFRICA_MARITIME_LOCODES.has(hub.code), navigation_priority: priorityHubCodes.has(hub.code) ? 1 : 0, source: 'UN/LOCODE Africa', locode: hub.code, diff --git a/src/maritime.test.ts b/src/maritime.test.ts new file mode 100644 index 0000000..b803c96 --- /dev/null +++ b/src/maritime.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { isMaritimePort } from './maritime.js' + +test('accepts curated coastal LOCODE ports', () => { + assert.equal(isMaritimePort({ locode: 'NGLAG' }), true) + assert.equal(isMaritimePort({ locode: 'CDBNW' }), true) +}) + +test('rejects inland ports even when UN/LOCODE function 1 is present', () => { + assert.equal(isMaritimePort({ locode: 'CMGOU', function_code: '1-34----' }), false) + assert.equal(isMaritimePort({ locode: 'CGOYO', function_code: '1-3-----' }), false) +}) + +test('accepts an explicitly curated database record', () => { + assert.equal(isMaritimePort({ locode: 'CUSTOM', is_maritime_port: true }), true) +}) diff --git a/src/maritime.ts b/src/maritime.ts new file mode 100644 index 0000000..95d8f22 --- /dev/null +++ b/src/maritime.ts @@ -0,0 +1,102 @@ +import type { ArangoDoc } from './helpers.js' + +// UN/LOCODE function 1 also includes inland waterway ports. This curated set +// keeps the maritime planner on coastal and ocean-accessible terminals only. +export const AFRICA_MARITIME_LOCODES = new Set([ + 'BJPTN', + 'CDBNW', + 'CIASS', + 'CIBAO', + 'CIDAB', + 'CIFRE', + 'CIGLU', + 'CISPY', + 'CMLIM', + 'CMMOU', + 'CVMIN', + 'DJDMJ', + 'DJPOD', + 'DJDCT', + 'DZALG', + 'DZAZW', + 'DZCHE', + 'DZCOL', + 'DZDEL', + 'DZGHZ', + 'EGAGN', + 'EGADA', + 'EGAQU', + 'EGSUZ', + 'EGDOM', + 'EGDAM', + 'EGGUA', + 'EGGAL', + 'EGIKU', + 'EGEPP', + 'EGOPT', + 'EGGLB', + 'EGPSE', + 'EGMEA', + 'EGSBY', + 'ERASA', + 'ERMSW', + 'GNBEL', + 'GNBFA', + 'GNDPL', + 'GNPMT', + 'GNDKA', + 'GNKMC', + 'GWOXB', + 'GWBOL', + 'GWBQE', + 'GWCAC', + 'KESMN', + 'KMAJN', + 'LRHAR', + 'LRMLW', + 'LYKHO', + 'LYELK', + 'LYMEL', + 'MAANZ', + 'MABUJ', + 'MAELJ', + 'MAESS', + 'MARNA', + 'MGNOS', + 'MUPMA', + 'MZCOR', + 'MZMZB', + 'NGFLM', + 'NGLAG', + 'NGLKK', + 'NGONN', + 'SCVIC', + 'SLKSY', + 'SOELM', + 'SOGRC', + 'STTMS', + 'TNHMM', + 'TNTZE', + 'TNBJC', + 'TNCHB', + 'TNMAD', + 'TNRDS', + 'TNSLN', + 'TNZRZ', + 'TZDAR', + 'TZIKW', + 'ZACPT', + 'ZADUR', + 'ZAKOM', + 'ZAPNL', + 'ZAPED', + 'ZAJOH', + 'ZAZE4', + 'ZASMN', + 'ZASTD', +]) + +export function isMaritimePort(document: ArangoDoc): boolean { + return document.is_maritime_port === true + || AFRICA_MARITIME_LOCODES.has(String(document.locode ?? '').toUpperCase()) +} diff --git a/src/schema.ts b/src/schema.ts index 67c0968..6afb6e2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -378,18 +378,18 @@ async function maritimeRoutesBetweenNodes( FOR port IN nodes FILTER port.node_type == 'logistics' OR port.node_type == null FILTER port.latitude != null AND port.longitude != null - FILTER 'sea' IN port.transport_types + FILTER port.is_maritime_port == true SORT DISTANCE(@source_lat, @source_lon, port.latitude, port.longitude) ASC - LIMIT 8 + LIMIT 12 RETURN port ) LET destination_ports = ( FOR port IN nodes FILTER port.node_type == 'logistics' OR port.node_type == null FILTER port.latitude != null AND port.longitude != null - FILTER 'sea' IN port.transport_types + FILTER port.is_maritime_port == true SORT DISTANCE(@destination_lat, @destination_lon, port.latitude, port.longitude) ASC - LIMIT 8 + LIMIT 12 RETURN port ) FOR origin_port IN origin_ports @@ -398,6 +398,10 @@ async function maritimeRoutesBetweenNodes( LET first_mile = DISTANCE(@source_lat, @source_lon, origin_port.latitude, origin_port.longitude) LET sea_leg = DISTANCE(origin_port.latitude, origin_port.longitude, destination_port.latitude, destination_port.longitude) LET last_mile = DISTANCE(destination_port.latitude, destination_port.longitude, @destination_lat, @destination_lon) + LET origin_to_destination = DISTANCE(@destination_lat, @destination_lon, origin_port.latitude, origin_port.longitude) + LET destination_to_source = DISTANCE(@source_lat, @source_lon, destination_port.latitude, destination_port.longitude) + FILTER first_mile < origin_to_destination + FILTER last_mile < destination_to_source SORT first_mile + sea_leg + last_mile ASC LIMIT @limit RETURN { origin_port, destination_port }