""" Server-side map clustering using Uber H3 hexagonal grid. Maps zoom levels to h3 resolutions and groups nodes by cell. """ import logging import h3 logger = logging.getLogger(__name__) # 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, west, south, east, north, transport_type=None, node_type=None, product_uuid=None, hub_uuid=None, supplier_uuid=None, ): """Fetch nodes from database for a bounding box. Args: db: Database connection west, south, east, north: Bounding box coordinates transport_type: Filter by transport type (auto, rail, sea, air) node_type: Type of nodes to fetch ('logistics', 'offer', 'supplier') """ bind_vars = { 'west': west, 'south': south, 'east': east, 'north': north, 'product_uuid': product_uuid, 'hub_uuid': hub_uuid, 'supplier_uuid': supplier_uuid, } # Select AQL query based on node_type if node_type == 'offer': aql = """ FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.latitude != null AND node.longitude != null FILTER node.latitude >= @south AND node.latitude <= @north FILTER node.longitude >= @west AND node.longitude <= @east FILTER @product_uuid == null OR node.product_uuid == @product_uuid FILTER @supplier_uuid == null OR node.supplier_uuid == @supplier_uuid LET has_hub = @hub_uuid == null ? true : LENGTH( FOR edge IN edges FILTER edge.transport_type == 'offer' FILTER ( (edge._from == CONCAT('nodes/', node._key) AND edge._to == CONCAT('nodes/', @hub_uuid)) OR (edge._to == CONCAT('nodes/', node._key) AND edge._from == CONCAT('nodes/', @hub_uuid)) ) LIMIT 1 RETURN 1 ) > 0 FILTER has_hub RETURN node """ elif node_type == 'supplier': # Get suppliers that have offers (aggregate through offers) aql = """ FOR offer IN nodes FILTER offer.node_type == 'offer' FILTER offer.supplier_uuid != null FILTER @product_uuid == null OR offer.product_uuid == @product_uuid FILTER @supplier_uuid == null OR offer.supplier_uuid == @supplier_uuid LET has_hub = @hub_uuid == null ? true : LENGTH( FOR edge IN edges FILTER edge.transport_type == 'offer' FILTER ( (edge._from == CONCAT('nodes/', offer._key) AND edge._to == CONCAT('nodes/', @hub_uuid)) OR (edge._to == CONCAT('nodes/', offer._key) AND edge._from == CONCAT('nodes/', @hub_uuid)) ) LIMIT 1 RETURN 1 ) > 0 FILTER has_hub LET supplier = DOCUMENT(CONCAT('nodes/', offer.supplier_uuid)) FILTER supplier != null FILTER supplier.latitude != null AND supplier.longitude != null FILTER supplier.latitude >= @south AND supplier.latitude <= @north FILTER supplier.longitude >= @west AND supplier.longitude <= @east COLLECT sup_uuid = offer.supplier_uuid INTO offers LET sup = DOCUMENT(CONCAT('nodes/', sup_uuid)) RETURN { _key: sup_uuid, name: sup.name, latitude: sup.latitude, longitude: sup.longitude, country: sup.country, country_code: sup.country_code, node_type: 'supplier', offers_count: LENGTH(offers) } """ else: # logistics (default) aql = """ FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null FILTER node.latitude != null AND node.longitude != null FILTER node.latitude >= @south AND node.latitude <= @north FILTER node.longitude >= @west AND node.longitude <= @east FILTER @hub_uuid == null OR node._key == @hub_uuid LET has_offer = (@product_uuid == null AND @supplier_uuid == null) ? true : LENGTH( FOR edge IN edges FILTER edge.transport_type == 'offer' FILTER edge._from == CONCAT('nodes/', node._key) OR edge._to == CONCAT('nodes/', node._key) LET offer_id = edge._from == CONCAT('nodes/', node._key) ? edge._to : edge._from LET offer = DOCUMENT(offer_id) FILTER offer != null AND offer.node_type == 'offer' FILTER @product_uuid == null OR offer.product_uuid == @product_uuid FILTER @supplier_uuid == null OR offer.supplier_uuid == @supplier_uuid LIMIT 1 RETURN 1 ) > 0 FILTER has_offer RETURN node """ cursor = db.aql.execute(aql, bind_vars=bind_vars) nodes = list(cursor) # Filter by transport type if specified (only for logistics nodes) 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 def get_clustered_nodes( db, west, south, east, north, zoom, transport_type=None, node_type=None, product_uuid=None, hub_uuid=None, supplier_uuid=None, ): """ Get clustered nodes for given bounding box and zoom level. Uses H3 hexagonal grid to group nearby nodes. Args: db: Database connection west, south, east, north: Bounding box coordinates zoom: Map zoom level transport_type: Filter by transport type (for logistics nodes) node_type: Type of nodes ('logistics', 'offer', 'supplier') """ resolution = ZOOM_TO_RES.get(int(zoom), 5) nodes = _fetch_nodes( db, west, south, east, north, transport_type, node_type, product_uuid, hub_uuid, supplier_uuid, ) if not nodes: return [] # Group nodes by h3 cell cells = {} for node in nodes: lat = node.get('latitude') lng = node.get('longitude') 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