Build maritime route alternatives from GEO hubs
Build and deploy Docker image / build (push) Successful in 1m2s
Build and deploy Docker image / build (push) Successful in 1m2s
This commit is contained in:
@@ -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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -37,6 +37,72 @@ export interface RoutePath {
|
|||||||
stages: RouteStage[]
|
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 {
|
export function normalizeRouteNodeType(nodeType: unknown): string | null {
|
||||||
if (nodeType === 'logistics' || nodeType === null || nodeType === undefined) return 'hub'
|
if (nodeType === 'logistics' || nodeType === null || nodeType === undefined) return 'hub'
|
||||||
return String(nodeType)
|
return String(nodeType)
|
||||||
|
|||||||
+75
-1
@@ -1,6 +1,6 @@
|
|||||||
import { getDb, ensureGraph } from './db.js'
|
import { getDb, ensureGraph } from './db.js'
|
||||||
import { getClusteredNodes } from './cluster.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 GRAPHHOPPER_URL = process.env.GRAPHHOPPER_EXTERNAL_URL || 'https://graphhopper.optovia.ru'
|
||||||
const OPENRAILROUTING_URL = process.env.OPENRAILROUTING_EXTERNAL_URL || 'https://openrailrouting.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) {
|
function productView(doc: ArangoDoc) {
|
||||||
const productUuid = doc.product_uuid ?? doc.uuid ?? null
|
const productUuid = doc.product_uuid ?? doc.uuid ?? null
|
||||||
return {
|
return {
|
||||||
@@ -1261,6 +1327,14 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
const fromNode = await resolveGraphRouteEndpoint(fromEndpoint)
|
const fromNode = await resolveGraphRouteEndpoint(fromEndpoint)
|
||||||
const toNode = await resolveGraphRouteEndpoint(toEndpoint)
|
const toNode = await resolveGraphRouteEndpoint(toEndpoint)
|
||||||
|
const maritimeRoutes = await maritimeRoutesBetweenNodes(
|
||||||
|
fromEndpoint,
|
||||||
|
fromNode,
|
||||||
|
toEndpoint,
|
||||||
|
toNode,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if (maritimeRoutes.length) return maritimeRoutes
|
||||||
const cursor = await db.query(`
|
const cursor = await db.query(`
|
||||||
FOR path IN ANY K_SHORTEST_PATHS @from_id TO @to_id
|
FOR path IN ANY K_SHORTEST_PATHS @from_id TO @to_id
|
||||||
GRAPH 'optovia_graph'
|
GRAPH 'optovia_graph'
|
||||||
|
|||||||
Reference in New Issue
Block a user