From 09324bb25e9796c7d0a1fa6a4df915a85d00992a Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:41:07 +0700 Subject: [PATCH] Filter hubs to rail/sea and add graph-based nearest --- geo_app/cluster_index.py | 18 +++++--- geo_app/schema.py | 97 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/geo_app/cluster_index.py b/geo_app/cluster_index.py index 6f2bf02..adbde0b 100644 --- a/geo_app/cluster_index.py +++ b/geo_app/cluster_index.py @@ -81,11 +81,18 @@ def _fetch_nodes(db, west, south, east, north, transport_type=None, node_type=No nodes = list(cursor) # Filter by transport type if specified (only for logistics nodes) - if transport_type and node_type in (None, 'logistics'): - nodes = [ - n for n in nodes - if transport_type in (n.get('transport_types') or []) - ] + if node_type in (None, 'logistics'): + if transport_type: + nodes = [ + n for n in nodes + if transport_type in (n.get('transport_types') or []) + ] + else: + # Default: only rail/sea hubs + nodes = [ + n for n in nodes + if ('rail' in (n.get('transport_types') or [])) or ('sea' in (n.get('transport_types') or [])) + ] return nodes @@ -151,4 +158,3 @@ def get_clustered_nodes(db, west, south, east, north, zoom, transport_type=None, logger.info("Returning %d clusters/points for zoom=%d res=%d", len(results), zoom, resolution) return results - diff --git a/geo_app/schema.py b/geo_app/schema.py index 61f268c..91f9c38 100644 --- a/geo_app/schema.py +++ b/geo_app/schema.py @@ -325,6 +325,7 @@ class Query(graphene.ObjectType): lon=graphene.Float(required=True, description="Longitude"), radius=graphene.Float(default_value=1000, description="Search radius in km"), product_uuid=graphene.String(description="Filter hubs by product availability"), + source_uuid=graphene.String(description="Source node UUID for graph-based nearest hubs"), limit=graphene.Int(default_value=12, description="Max results"), description="Find nearest hubs to coordinates (optionally filtered by product)", ) @@ -622,6 +623,8 @@ class Query(graphene.ObjectType): aql = """ FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null + LET types = node.transport_types != null ? node.transport_types : [] + FILTER ('rail' IN types) OR ('sea' IN types) FILTER node.country != null COLLECT country = node.country SORT country ASC @@ -1462,10 +1465,97 @@ class Query(graphene.ObjectType): logger.info("No route found from offer %s to hub %s", offer_uuid, hub_uuid) return None - def resolve_nearest_hubs(self, info, lat, lon, radius=1000, product_uuid=None, limit=12): + def resolve_nearest_hubs(self, info, lat, lon, radius=1000, product_uuid=None, source_uuid=None, limit=12): """Find nearest hubs to coordinates, optionally filtered by product availability.""" db = get_db() + # Graph-based nearest hubs when source_uuid provided + if source_uuid: + nodes_col = db.collection('nodes') + start = nodes_col.get(source_uuid) + if not start: + logger.warning("Source node %s not found for nearest hubs", source_uuid) + return [] + + def is_target_hub(doc): + if doc.get('_key') == source_uuid: + return False + if doc.get('node_type') not in ('logistics', None): + return False + types = doc.get('transport_types') or [] + return ('rail' in types) or ('sea' in types) + + def fetch_neighbors(node_key): + aql = """ + FOR edge IN edges + FILTER edge.transport_type IN ['auto', 'rail', 'offer'] + FILTER edge._from == @node_id OR edge._to == @node_id + LET neighbor_id = edge._from == @node_id ? edge._to : edge._from + LET neighbor = DOCUMENT(neighbor_id) + FILTER neighbor != null + RETURN { + neighbor_key: neighbor._key, + neighbor_doc: neighbor, + transport_type: edge.transport_type, + distance_km: edge.distance_km, + travel_time_seconds: edge.travel_time_seconds + } + """ + cursor = db.aql.execute( + aql, + bind_vars={'node_id': f"nodes/{node_key}"}, + ) + return list(cursor) + + queue = [] + counter = 0 + heapq.heappush(queue, (0, counter, source_uuid)) + visited = {} + node_docs = {source_uuid: start} + found = [] + expansions = 0 + + while queue and len(found) < limit and expansions < Query.MAX_EXPANSIONS: + cost, _, node_key = heapq.heappop(queue) + if node_key in visited and cost > visited[node_key]: + continue + visited[node_key] = cost + + node_doc = node_docs.get(node_key) + if node_doc and is_target_hub(node_doc): + found.append(node_doc) + if len(found) >= limit: + break + + neighbors = fetch_neighbors(node_key) + expansions += 1 + for neighbor in neighbors: + neighbor_key = neighbor.get('neighbor_key') + if not neighbor_key: + continue + node_docs[neighbor_key] = neighbor.get('neighbor_doc') + step_cost = neighbor.get('travel_time_seconds') or neighbor.get('distance_km') or 0 + new_cost = cost + step_cost + if neighbor_key in visited and new_cost >= visited[neighbor_key]: + continue + counter += 1 + heapq.heappush(queue, (new_cost, counter, neighbor_key)) + + hubs = [] + for node in found: + hubs.append(NodeType( + uuid=node.get('_key'), + name=node.get('name'), + latitude=node.get('latitude'), + longitude=node.get('longitude'), + country=node.get('country'), + country_code=node.get('country_code'), + synced_at=node.get('synced_at'), + transport_types=node.get('transport_types') or [], + edges=[], + )) + return hubs + if product_uuid: # Find hubs that have offers for this product within radius aql = """ @@ -1479,6 +1569,8 @@ class Query(graphene.ObjectType): FOR hub IN nodes FILTER hub.node_type == 'logistics' OR hub.node_type == null FILTER hub.product_uuid == null + LET types = hub.transport_types != null ? hub.transport_types : [] + FILTER ('rail' IN types) OR ('sea' IN types) FILTER hub.latitude != null AND hub.longitude != null LET dist_to_hub = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 FILTER dist_to_hub <= @radius @@ -1496,6 +1588,8 @@ class Query(graphene.ObjectType): FOR hub IN nodes FILTER hub.node_type == 'logistics' OR hub.node_type == null FILTER hub.product_uuid == null + LET types = hub.transport_types != null ? hub.transport_types : [] + FILTER ('rail' IN types) OR ('sea' IN types) FILTER hub.latitude != null AND hub.longitude != null LET dist = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 FILTER dist <= @radius @@ -1715,6 +1809,7 @@ class Query(graphene.ObjectType): FILTER node.product_uuid == null LET types = node.transport_types != null ? node.transport_types : [] FILTER @transport_type == null OR @transport_type IN types + FILTER @transport_type != null OR ('rail' IN types) OR ('sea' IN types) FILTER @country == null OR node.country == @country {bounds_filter} SORT node.name ASC