Migrate geo backend from Django/Graphene to Express + Apollo Server + arangojs
All checks were successful
Build Docker Image / build (push) Successful in 1m5s
All checks were successful
Build Docker Image / build (push) Successful in 1m5s
Replace Python stack with TypeScript. All 30+ GraphQL queries preserved including phase-based routing (Dijkstra), H3 clustering, K_SHORTEST_PATHS, and external routing services (GraphHopper, OpenRailRouting). Single public endpoint, no auth.
This commit is contained in:
192
src/cluster.ts
Normal file
192
src/cluster.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { latLngToCell, cellToLatLng } from 'h3-js'
|
||||
import { getDb } from './db.js'
|
||||
|
||||
const ZOOM_TO_RES: Record<number, number> = {
|
||||
0: 0, 1: 0, 2: 1, 3: 1, 4: 2, 5: 2,
|
||||
6: 3, 7: 3, 8: 4, 9: 4, 10: 5, 11: 5,
|
||||
12: 6, 13: 7, 14: 8, 15: 9, 16: 10,
|
||||
}
|
||||
|
||||
interface CachedNode {
|
||||
_key: string
|
||||
name?: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
country?: string
|
||||
country_code?: string
|
||||
node_type?: string
|
||||
transport_types?: string[]
|
||||
}
|
||||
|
||||
const nodesCache = new Map<string, CachedNode[]>()
|
||||
|
||||
function fetchNodes(transportType?: string | null, nodeType?: string | null): CachedNode[] {
|
||||
const cacheKey = `nodes:${transportType || 'all'}:${nodeType || 'logistics'}`
|
||||
if (nodesCache.has(cacheKey)) return nodesCache.get(cacheKey)!
|
||||
|
||||
const db = getDb()
|
||||
let aql: string
|
||||
|
||||
if (nodeType === 'offer') {
|
||||
aql = `
|
||||
FOR node IN nodes
|
||||
FILTER node.node_type == 'offer'
|
||||
FILTER node.latitude != null AND node.longitude != null
|
||||
RETURN node
|
||||
`
|
||||
} else if (nodeType === 'supplier') {
|
||||
aql = `
|
||||
FOR offer IN nodes
|
||||
FILTER offer.node_type == 'offer'
|
||||
FILTER offer.supplier_uuid != null
|
||||
LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid))
|
||||
FILTER supplier != null
|
||||
FILTER supplier.latitude != null AND supplier.longitude != null
|
||||
COLLECT sup_uuid = offer.supplier_uuid INTO offers
|
||||
LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid))
|
||||
RETURN {
|
||||
_key: sup_uuid,
|
||||
name: sup.name,
|
||||
latitude: sup.latitude,
|
||||
longitude: sup.longitude,
|
||||
country: sup.country,
|
||||
country_code: sup.country_code,
|
||||
node_type: 'supplier',
|
||||
offers_count: LENGTH(offers)
|
||||
}
|
||||
`
|
||||
} else {
|
||||
aql = `
|
||||
FOR node IN nodes
|
||||
FILTER node.node_type == 'logistics' OR node.node_type == null
|
||||
FILTER node.latitude != null AND node.longitude != null
|
||||
RETURN node
|
||||
`
|
||||
}
|
||||
|
||||
// arangojs query returns a cursor — we need async. Use a sync cache pattern with pre-fetching.
|
||||
// Since this is called from resolvers which are async, we'll use a different approach.
|
||||
// Store a promise instead.
|
||||
throw new Error('Use fetchNodesAsync instead')
|
||||
}
|
||||
|
||||
export async function fetchNodesAsync(transportType?: string | null, nodeType?: string | null): Promise<CachedNode[]> {
|
||||
const cacheKey = `nodes:${transportType || 'all'}:${nodeType || 'logistics'}`
|
||||
if (nodesCache.has(cacheKey)) return nodesCache.get(cacheKey)!
|
||||
|
||||
const db = getDb()
|
||||
let aql: string
|
||||
|
||||
if (nodeType === 'offer') {
|
||||
aql = `
|
||||
FOR node IN nodes
|
||||
FILTER node.node_type == 'offer'
|
||||
FILTER node.latitude != null AND node.longitude != null
|
||||
RETURN node
|
||||
`
|
||||
} else if (nodeType === 'supplier') {
|
||||
aql = `
|
||||
FOR offer IN nodes
|
||||
FILTER offer.node_type == 'offer'
|
||||
FILTER offer.supplier_uuid != null
|
||||
LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid))
|
||||
FILTER supplier != null
|
||||
FILTER supplier.latitude != null AND supplier.longitude != null
|
||||
COLLECT sup_uuid = offer.supplier_uuid INTO offers
|
||||
LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid))
|
||||
RETURN {
|
||||
_key: sup_uuid,
|
||||
name: sup.name,
|
||||
latitude: sup.latitude,
|
||||
longitude: sup.longitude,
|
||||
country: sup.country,
|
||||
country_code: sup.country_code,
|
||||
node_type: 'supplier',
|
||||
offers_count: LENGTH(offers)
|
||||
}
|
||||
`
|
||||
} else {
|
||||
aql = `
|
||||
FOR node IN nodes
|
||||
FILTER node.node_type == 'logistics' OR node.node_type == null
|
||||
FILTER node.latitude != null AND node.longitude != null
|
||||
RETURN node
|
||||
`
|
||||
}
|
||||
|
||||
const cursor = await db.query(aql)
|
||||
let allNodes: CachedNode[] = await cursor.all()
|
||||
|
||||
if (transportType && (!nodeType || nodeType === 'logistics')) {
|
||||
allNodes = allNodes.filter(n => (n.transport_types || []).includes(transportType))
|
||||
}
|
||||
|
||||
nodesCache.set(cacheKey, allNodes)
|
||||
console.log(`Cached ${allNodes.length} nodes for ${cacheKey}`)
|
||||
return allNodes
|
||||
}
|
||||
|
||||
export interface ClusterPoint {
|
||||
id: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
count: number
|
||||
expansion_zoom: number | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export async function getClusteredNodes(
|
||||
west: number, south: number, east: number, north: number,
|
||||
zoom: number, transportType?: string | null, nodeType?: string | null,
|
||||
): Promise<ClusterPoint[]> {
|
||||
const resolution = ZOOM_TO_RES[Math.floor(zoom)] ?? 5
|
||||
const nodes = await fetchNodesAsync(transportType, nodeType)
|
||||
|
||||
if (!nodes.length) return []
|
||||
|
||||
const cells = new Map<string, CachedNode[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
const lat = node.latitude
|
||||
const lng = node.longitude
|
||||
if (lat == null || lng == null) continue
|
||||
if (lat < south || lat > north || lng < west || lng > east) continue
|
||||
|
||||
const cell = latLngToCell(lat, lng, resolution)
|
||||
if (!cells.has(cell)) cells.set(cell, [])
|
||||
cells.get(cell)!.push(node)
|
||||
}
|
||||
|
||||
const results: ClusterPoint[] = []
|
||||
|
||||
for (const [cell, nodesInCell] of cells) {
|
||||
if (nodesInCell.length === 1) {
|
||||
const node = nodesInCell[0]
|
||||
results.push({
|
||||
id: node._key,
|
||||
latitude: node.latitude!,
|
||||
longitude: node.longitude!,
|
||||
count: 1,
|
||||
expansion_zoom: null,
|
||||
name: node.name || null,
|
||||
})
|
||||
} else {
|
||||
const [lat, lng] = cellToLatLng(cell)
|
||||
results.push({
|
||||
id: `cluster-${cell}`,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
count: nodesInCell.length,
|
||||
expansion_zoom: Math.min(zoom + 2, 16),
|
||||
name: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export function invalidateCache(): void {
|
||||
nodesCache.clear()
|
||||
console.log('Cluster cache invalidated')
|
||||
}
|
||||
27
src/db.ts
Normal file
27
src/db.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Database } from 'arangojs'
|
||||
|
||||
const ARANGODB_URL = process.env.ARANGODB_INTERNAL_URL || 'http://localhost:8529'
|
||||
const ARANGODB_DATABASE = process.env.ARANGODB_DATABASE || 'optovia_maps'
|
||||
const ARANGODB_PASSWORD = process.env.ARANGODB_PASSWORD || ''
|
||||
|
||||
let _db: Database | null = null
|
||||
|
||||
export function getDb(): Database {
|
||||
if (!_db) {
|
||||
const url = ARANGODB_URL.startsWith('http') ? ARANGODB_URL : `http://${ARANGODB_URL}`
|
||||
_db = new Database({ url, databaseName: ARANGODB_DATABASE, auth: { username: 'root', password: ARANGODB_PASSWORD } })
|
||||
console.log(`Connected to ArangoDB: ${url}/${ARANGODB_DATABASE}`)
|
||||
}
|
||||
return _db
|
||||
}
|
||||
|
||||
export async function ensureGraph(): Promise<void> {
|
||||
const db = getDb()
|
||||
const graphs = await db.listGraphs()
|
||||
if (graphs.some(g => g.name === 'optovia_graph')) return
|
||||
|
||||
console.log('Creating graph: optovia_graph')
|
||||
await db.createGraph('optovia_graph', [
|
||||
{ collection: 'edges', from: ['nodes'], to: ['nodes'] },
|
||||
])
|
||||
}
|
||||
90
src/helpers.ts
Normal file
90
src/helpers.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Haversine distance in km. */
|
||||
export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) ** 2
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ArangoDoc = Record<string, any>
|
||||
|
||||
export interface RouteStage {
|
||||
from_uuid: string | null
|
||||
from_name: string | null
|
||||
from_lat: number | null
|
||||
from_lon: number | null
|
||||
to_uuid: string | null
|
||||
to_name: string | null
|
||||
to_lat: number | null
|
||||
to_lon: number | null
|
||||
distance_km: number
|
||||
travel_time_seconds: number
|
||||
transport_type: string | null
|
||||
}
|
||||
|
||||
export interface RoutePath {
|
||||
total_distance_km: number
|
||||
total_time_seconds: number
|
||||
stages: RouteStage[]
|
||||
}
|
||||
|
||||
function buildStage(fromDoc: ArangoDoc | undefined, toDoc: ArangoDoc | undefined, transportType: string, edges: ArangoDoc[]): RouteStage {
|
||||
const distance = edges.reduce((s, e) => s + (e.distance_km || 0), 0)
|
||||
const time = edges.reduce((s, e) => s + (e.travel_time_seconds || 0), 0)
|
||||
return {
|
||||
from_uuid: fromDoc?._key ?? null,
|
||||
from_name: fromDoc?.name ?? null,
|
||||
from_lat: fromDoc?.latitude ?? null,
|
||||
from_lon: fromDoc?.longitude ?? null,
|
||||
to_uuid: toDoc?._key ?? null,
|
||||
to_name: toDoc?.name ?? null,
|
||||
to_lat: toDoc?.latitude ?? null,
|
||||
to_lon: toDoc?.longitude ?? null,
|
||||
distance_km: distance,
|
||||
travel_time_seconds: time,
|
||||
transport_type: transportType,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRouteFromEdges(pathEdges: [string, string, ArangoDoc][], nodeDocs: Map<string, ArangoDoc>): RoutePath | null {
|
||||
if (!pathEdges.length) return null
|
||||
|
||||
// Filter offer edges — not transport stages
|
||||
const filtered = pathEdges.filter(([, , e]) => e.transport_type !== 'offer')
|
||||
if (!filtered.length) return null
|
||||
|
||||
const stages: RouteStage[] = []
|
||||
let currentEdges: ArangoDoc[] = []
|
||||
let currentType: string | null = null
|
||||
let segmentStart: string | null = null
|
||||
|
||||
for (const [fromKey, , edge] of filtered) {
|
||||
const edgeType = edge.transport_type as string
|
||||
if (currentType === null) {
|
||||
currentType = edgeType
|
||||
currentEdges = [edge]
|
||||
segmentStart = fromKey
|
||||
} else if (edgeType === currentType) {
|
||||
currentEdges.push(edge)
|
||||
} else {
|
||||
stages.push(buildStage(nodeDocs.get(segmentStart!), nodeDocs.get(fromKey), currentType, currentEdges))
|
||||
currentType = edgeType
|
||||
currentEdges = [edge]
|
||||
segmentStart = fromKey
|
||||
}
|
||||
}
|
||||
|
||||
const lastTo = filtered[filtered.length - 1][1]
|
||||
stages.push(buildStage(nodeDocs.get(segmentStart!), nodeDocs.get(lastTo), currentType!, currentEdges))
|
||||
|
||||
return {
|
||||
total_distance_km: stages.reduce((s, st) => s + (st.distance_km || 0), 0),
|
||||
total_time_seconds: stages.reduce((s, st) => s + (st.travel_time_seconds || 0), 0),
|
||||
stages,
|
||||
}
|
||||
}
|
||||
33
src/index.ts
Normal file
33
src/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { ApolloServer } from '@apollo/server'
|
||||
import { expressMiddleware } from '@apollo/server/express4'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { typeDefs, resolvers } from './schema.js'
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8000', 10)
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN || ''
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
tracesSampleRate: 0.01,
|
||||
release: process.env.RELEASE_VERSION || '1.0.0',
|
||||
environment: process.env.ENVIRONMENT || 'production',
|
||||
})
|
||||
}
|
||||
|
||||
const app = express()
|
||||
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
|
||||
|
||||
const server = new ApolloServer({ typeDefs, resolvers, introspection: true })
|
||||
await server.start()
|
||||
|
||||
app.use('/graphql/public', express.json(), expressMiddleware(server) as unknown as express.RequestHandler)
|
||||
|
||||
app.get('/health', (_, res) => { res.json({ status: 'ok' }) })
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Geo server ready on port ${PORT}`)
|
||||
console.log(` /graphql/public - public (no auth)`)
|
||||
})
|
||||
1027
src/schema.ts
Normal file
1027
src/schema.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user