Files
geo/geo_app/cluster_index.py
Ruslan Bakiev 0330203a58
All checks were successful
Build Docker Image / build (push) Successful in 1m38s
Replace pysupercluster with h3 for clustering
2026-01-14 10:24:40 +07:00

123 lines
3.5 KiB
Python

"""
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")