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