Build maritime route alternatives from GEO hubs
Build and deploy Docker image / build (push) Successful in 1m2s

This commit is contained in:
Ruslan Bakiev
2026-09-29 19:43:34 +07:00
parent 06519c9b15
commit 77202f1f8d
3 changed files with 203 additions and 1 deletions
+62
View File
@@ -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/,
)
})
+66
View File
@@ -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)
+75 -1
View File
@@ -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<RoutePath[]> {
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'