123 lines
3.5 KiB
Python
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")
|