From 77202f1f8d8409947f2b953477578f56e344984d Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:43:34 +0700 Subject: [PATCH] Build maritime route alternatives from GEO hubs --- src/helpers.test.ts | 62 ++++++++++++++++++++++++++++++++++++ src/helpers.ts | 66 +++++++++++++++++++++++++++++++++++++++ src/schema.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/helpers.test.ts diff --git a/src/helpers.test.ts b/src/helpers.test.ts new file mode 100644 index 0000000..3bdc39f --- /dev/null +++ b/src/helpers.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { buildMaritimeRoute } from './helpers.js' + +const source = { + _key: 'source', + name: 'Source', + country_code: 'NE', + latitude: 13.3, + longitude: 12.6, + node_type: 'logistics', +} +const originPort = { + _key: 'origin-port', + name: 'Origin port', + country_code: 'NG', + latitude: 6.45, + longitude: 3.4, + node_type: 'logistics', +} +const destinationPort = { + _key: 'destination-port', + name: 'Destination port', + country_code: 'CM', + latitude: 4.05, + longitude: 9.7, + node_type: 'logistics', +} +const destination = { + _key: 'destination', + name: 'Destination', + country_code: 'CF', + latitude: 4.95, + longitude: 17.05, + node_type: 'logistics', +} + +test('buildMaritimeRoute creates road, sea, road stages', () => { + const route = buildMaritimeRoute(source, originPort, destinationPort, destination) + + assert.deepEqual(route.stages.map((stage) => stage.transportType), ['road', 'sea', 'road']) + assert.equal(route.stages[0].fromUuid, 'source') + assert.equal(route.stages[1].fromUuid, 'origin-port') + assert.equal(route.stages[1].toUuid, 'destination-port') + assert.equal(route.stages[2].toUuid, 'destination') + assert.ok(route.totalDistanceKm > 0) + assert.ok(route.totalTimeSeconds > 0) +}) + +test('buildMaritimeRoute omits a zero-length first mile at the origin port', () => { + const route = buildMaritimeRoute(originPort, originPort, destinationPort, destination) + + assert.deepEqual(route.stages.map((stage) => stage.transportType), ['sea', 'road']) +}) + +test('buildMaritimeRoute rejects a route without a sea leg', () => { + assert.throws( + () => buildMaritimeRoute(source, originPort, originPort, destination), + /distinct origin and destination ports/, + ) +}) diff --git a/src/helpers.ts b/src/helpers.ts index 25377fb..68fdd6b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -37,6 +37,72 @@ export interface RoutePath { stages: RouteStage[] } +const ROAD_DISTANCE_FACTOR = 1.25 +const SEA_DISTANCE_FACTOR = 1.15 +const ROAD_SPEED_KMH = 60 +const SEA_SPEED_KMH = 30 + +function roundedDistance(value: number): number { + return Math.round(value * 100) / 100 +} + +function routeStage( + fromDoc: ArangoDoc, + toDoc: ArangoDoc, + transportType: 'road' | 'sea', + distanceFactor: number, + speedKmh: number, +): RouteStage | null { + const fromLat = fromDoc.latitude as number | null | undefined + const fromLon = fromDoc.longitude as number | null | undefined + const toLat = toDoc.latitude as number | null | undefined + const toLon = toDoc.longitude as number | null | undefined + if (fromLat == null || fromLon == null || toLat == null || toLon == null) { + throw new Error('Route stage endpoints require coordinates') + } + if (fromDoc._key === toDoc._key) return null + const distance = roundedDistance(distanceKm(fromLat, fromLon, toLat, toLon) * distanceFactor) + if (distance === 0) return null + return { + fromUuid: fromDoc._key ?? null, + fromName: fromDoc.name ?? null, + fromCountryCode: fromDoc.country_code ?? null, + fromNodeType: normalizeRouteNodeType(fromDoc.node_type), + fromLat, + fromLon, + toUuid: toDoc._key ?? null, + toName: toDoc.name ?? null, + toCountryCode: toDoc.country_code ?? null, + toNodeType: normalizeRouteNodeType(toDoc.node_type), + toLat, + toLon, + distanceKm: distance, + travelTimeSeconds: Math.round((distance / speedKmh) * 3600), + transportType, + } +} + +export function buildMaritimeRoute( + source: ArangoDoc, + originPort: ArangoDoc, + destinationPort: ArangoDoc, + destination: ArangoDoc, +): RoutePath { + if (originPort._key === destinationPort._key) { + throw new Error('A maritime route requires distinct origin and destination ports') + } + const stages = [ + routeStage(source, originPort, 'road', ROAD_DISTANCE_FACTOR, ROAD_SPEED_KMH), + routeStage(originPort, destinationPort, 'sea', SEA_DISTANCE_FACTOR, SEA_SPEED_KMH), + routeStage(destinationPort, destination, 'road', ROAD_DISTANCE_FACTOR, ROAD_SPEED_KMH), + ].filter((stage): stage is RouteStage => stage !== null) + return { + totalDistanceKm: roundedDistance(stages.reduce((sum, stage) => sum + stage.distanceKm, 0)), + totalTimeSeconds: stages.reduce((sum, stage) => sum + stage.travelTimeSeconds, 0), + stages, + } +} + export function normalizeRouteNodeType(nodeType: unknown): string | null { if (nodeType === 'logistics' || nodeType === null || nodeType === undefined) return 'hub' return String(nodeType) diff --git a/src/schema.ts b/src/schema.ts index 90d5c4e..67c0968 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,6 +1,6 @@ import { getDb, ensureGraph } from './db.js' import { getClusteredNodes } from './cluster.js' -import { distanceKm, buildRouteFromEdges, normalizeRouteNodeType, type ArangoDoc, type RoutePath, type RouteStage } from './helpers.js' +import { buildMaritimeRoute, distanceKm, buildRouteFromEdges, normalizeRouteNodeType, type ArangoDoc, type RoutePath, type RouteStage } from './helpers.js' const GRAPHHOPPER_URL = process.env.GRAPHHOPPER_EXTERNAL_URL || 'https://graphhopper.optovia.ru' const OPENRAILROUTING_URL = process.env.OPENRAILROUTING_EXTERNAL_URL || 'https://openrailrouting.optovia.ru' @@ -351,6 +351,72 @@ function withRouteConnectors( } } +function routeEndpointDocument(endpoint: RouteEndpointArgs, graphNode: ArangoDoc): ArangoDoc { + if (endpoint.lat === undefined || endpoint.lon === undefined) return graphNode + return { + _key: endpoint.uuid, + name: endpoint.name ?? graphNode.name ?? endpoint.uuid, + country_code: endpoint.countryCode ?? graphNode.country_code ?? null, + latitude: endpoint.lat, + longitude: endpoint.lon, + node_type: endpoint.uuid === graphNode._key ? graphNode.node_type : 'external', + } +} + +async function maritimeRoutesBetweenNodes( + fromEndpoint: RouteEndpointArgs, + fromNode: ArangoDoc, + toEndpoint: RouteEndpointArgs, + toNode: ArangoDoc, + limit: number, +): Promise { + const db = getDb() + const source = routeEndpointDocument(fromEndpoint, fromNode) + const destination = routeEndpointDocument(toEndpoint, toNode) + const cursor = await db.query(` + LET origin_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 + SORT DISTANCE(@source_lat, @source_lon, port.latitude, port.longitude) ASC + LIMIT 8 + 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 + SORT DISTANCE(@destination_lat, @destination_lon, port.latitude, port.longitude) ASC + LIMIT 8 + RETURN port + ) + FOR origin_port IN origin_ports + FOR destination_port IN destination_ports + FILTER origin_port._key != destination_port._key + 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) + SORT first_mile + sea_leg + last_mile ASC + LIMIT @limit + RETURN { origin_port, destination_port } + `, { + source_lat: source.latitude, + source_lon: source.longitude, + destination_lat: destination.latitude, + destination_lon: destination.longitude, + limit, + }) + const portPairs = await cursor.all() + return portPairs.map((pair: ArangoDoc) => buildMaritimeRoute( + source, + pair.origin_port, + pair.destination_port, + destination, + )) +} + function productView(doc: ArangoDoc) { const productUuid = doc.product_uuid ?? doc.uuid ?? null return { @@ -1261,6 +1327,14 @@ export const resolvers = { } const fromNode = await resolveGraphRouteEndpoint(fromEndpoint) const toNode = await resolveGraphRouteEndpoint(toEndpoint) + const maritimeRoutes = await maritimeRoutesBetweenNodes( + fromEndpoint, + fromNode, + toEndpoint, + toNode, + limit, + ) + if (maritimeRoutes.length) return maritimeRoutes const cursor = await db.query(` FOR path IN ANY K_SHORTEST_PATHS @from_id TO @to_id GRAPH 'optovia_graph'