Compare commits

...

3 Commits

Author SHA1 Message Date
Ruslan Bakiev
fd7e10c193 Filter offer edges from route stages
All checks were successful
Build Docker Image / build (push) Successful in 1m42s
Offer edges connect offer nodes to locations and are not
transport stages. Filter them out in _build_route_from_edges()
to avoid showing 0km "offer" steps in the route stepper.
2026-01-15 00:32:57 +07:00
Ruslan Bakiev
0330203a58 Replace pysupercluster with h3 for clustering
All checks were successful
Build Docker Image / build (push) Successful in 1m38s
2026-01-14 10:24:40 +07:00
Ruslan Bakiev
7efa753092 Add server-side clustering with pysupercluster
Some checks failed
Build Docker Image / build (push) Failing after 2m14s
2026-01-14 10:12:39 +07:00
3 changed files with 157 additions and 1 deletions

122
geo_app/cluster_index.py Normal file
View File

@@ -0,0 +1,122 @@
"""
Server-side map clustering using Uber H3 hexagonal grid.
Maps zoom levels to h3 resolutions and groups nodes by cell.
"""
import logging
import threading
import h3
logger = logging.getLogger(__name__)
# Global cache for nodes
_nodes_cache = {}
_cache_lock = threading.Lock()
# Map zoom level to h3 resolution
# Higher zoom = higher resolution = smaller cells
ZOOM_TO_RES = {
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
}
def _fetch_nodes(db, transport_type=None):
"""Fetch nodes from database with caching."""
cache_key = f"nodes:{transport_type or 'all'}"
with _cache_lock:
if cache_key not in _nodes_cache:
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
"""
cursor = db.aql.execute(aql)
all_nodes = list(cursor)
# Filter by transport type if specified
if transport_type:
all_nodes = [
n for n in all_nodes
if transport_type in (n.get('transport_types') or [])
]
_nodes_cache[cache_key] = all_nodes
logger.info("Cached %d nodes for %s", len(all_nodes), cache_key)
return _nodes_cache[cache_key]
def get_clustered_nodes(db, west, south, east, north, zoom, transport_type=None):
"""
Get clustered nodes for given bounding box and zoom level.
Uses H3 hexagonal grid to group nearby nodes.
"""
resolution = ZOOM_TO_RES.get(int(zoom), 5)
nodes = _fetch_nodes(db, transport_type)
if not nodes:
return []
# Group nodes by h3 cell
cells = {}
for node in nodes:
lat = node.get('latitude')
lng = node.get('longitude')
# Skip nodes outside bounding box (rough filter)
if lat < south or lat > north or lng < west or lng > east:
continue
cell = h3.latlng_to_cell(lat, lng, resolution)
if cell not in cells:
cells[cell] = []
cells[cell].append(node)
# Build results
results = []
for cell, nodes_in_cell in cells.items():
count = len(nodes_in_cell)
if count == 1:
# Single point — return actual node data
node = nodes_in_cell[0]
results.append({
'id': node.get('_key'),
'latitude': node.get('latitude'),
'longitude': node.get('longitude'),
'count': 1,
'expansion_zoom': None,
'name': node.get('name'),
})
else:
# Cluster — return cell centroid
lat, lng = h3.cell_to_latlng(cell)
results.append({
'id': f"cluster-{cell}",
'latitude': lat,
'longitude': lng,
'count': count,
'expansion_zoom': min(zoom + 2, 16),
'name': None,
})
logger.info("Returning %d clusters/points for zoom=%d res=%d", len(results), zoom, resolution)
return results
def invalidate_cache(transport_type=None):
"""Invalidate node cache after data changes."""
with _cache_lock:
if transport_type:
cache_key = f"nodes:{transport_type}"
if cache_key in _nodes_cache:
del _nodes_cache[cache_key]
else:
_nodes_cache.clear()
logger.info("Cluster cache invalidated")

View File

@@ -6,6 +6,7 @@ import requests
import graphene
from django.conf import settings
from .arango_client import get_db, ensure_graph
from .cluster_index import get_clustered_nodes, invalidate_cache
logger = logging.getLogger(__name__)
@@ -80,6 +81,16 @@ class ProductRouteOptionType(graphene.ObjectType):
routes = graphene.List(RoutePathType)
class ClusterPointType(graphene.ObjectType):
"""Cluster or individual point for map display."""
id = graphene.String(description="UUID for points, 'cluster-N' for clusters")
latitude = graphene.Float()
longitude = graphene.Float()
count = graphene.Int(description="1 for single point, >1 for cluster")
expansion_zoom = graphene.Int(description="Zoom level to expand cluster")
name = graphene.String(description="Node name (only for single points)")
class Query(graphene.ObjectType):
"""Root query."""
MAX_EXPANSIONS = 20000
@@ -161,6 +172,17 @@ class Query(graphene.ObjectType):
description="Find routes from product offer nodes to destination",
)
clustered_nodes = graphene.List(
ClusterPointType,
west=graphene.Float(required=True, description="Bounding box west longitude"),
south=graphene.Float(required=True, description="Bounding box south latitude"),
east=graphene.Float(required=True, description="Bounding box east longitude"),
north=graphene.Float(required=True, description="Bounding box north latitude"),
zoom=graphene.Int(required=True, description="Map zoom level (0-16)"),
transport_type=graphene.String(description="Filter by transport type"),
description="Get clustered nodes for map display (server-side clustering)",
)
@staticmethod
def _build_routes(db, from_uuid, to_uuid, limit):
"""Shared helper to compute K shortest routes between two nodes."""
@@ -740,6 +762,12 @@ class Query(graphene.ObjectType):
return found_routes
def resolve_clustered_nodes(self, info, west, south, east, north, zoom, transport_type=None):
"""Get clustered nodes for map display using server-side SuperCluster."""
db = get_db()
clusters = get_clustered_nodes(db, west, south, east, north, zoom, transport_type)
return [ClusterPointType(**c) for c in clusters]
schema = graphene.Schema(query=Query)
@@ -767,6 +795,11 @@ def _build_route_from_edges(path_edges, node_docs):
if not path_edges:
return None
# Фильтруем offer edges - это не транспортные этапы, а связь оффера с локацией
path_edges = [(f, t, e) for f, t, e in path_edges if e.get('transport_type') != 'offer']
if not path_edges:
return None
stages = []
current_edges = []
current_type = None

View File

@@ -15,7 +15,8 @@ dependencies = [
"infisicalsdk (>=1.0.12,<2.0.0)",
"gunicorn (>=23.0.0,<24.0.0)",
"whitenoise (>=6.7.0,<7.0.0)",
"sentry-sdk (>=2.47.0,<3.0.0)"
"sentry-sdk (>=2.47.0,<3.0.0)",
"h3 (>=4.0.0,<5.0.0)"
]
[tool.poetry]