You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

179 lines
6.1 KiB

import math
from typing import List, Dict, Any, Optional
from django.db.models import QuerySet
def apply_bounding_box(
queryset: QuerySet,
north: Optional[float],
south: Optional[float],
east: Optional[float],
west: Optional[float]
) -> QuerySet:
"""
Filters a Django QuerySet of models having latitude and longitude
within the viewport bounding box [north, south, east, west].
"""
# Ensure items have non-null geo coordinates
queryset = queryset.filter(latitude__isnull=False, longitude__isnull=False)
if None in (north, south, east, west):
return queryset
# Standard bounding box
if west <= east:
return queryset.filter(
latitude__gte=south,
latitude__lte=north,
longitude__gte=west,
longitude__lte=east
)
else:
# Crosses the antimeridian (180th meridian)
from django.db.models import Q
return queryset.filter(
latitude__gte=south,
latitude__lte=north
).filter(
Q(longitude__gte=west) | Q(longitude__lte=east)
)
def lat_lng_to_pixel(lat: float, lng: float, zoom: int) -> tuple:
"""
Projects latitude/longitude into Web Mercator pixel coordinates at a given zoom level.
"""
sin_lat = math.sin(math.radians(lat))
# Clip sin_lat between -0.9999 and 0.9999 to prevent math domain error
sin_lat = max(min(sin_lat, 0.9999), -0.9999)
scale = 256 * (2 ** zoom)
x = (lng + 180.0) / 360.0 * scale
y = (0.5 - math.log((1.0 + sin_lat) / (1.0 - sin_lat)) / (4.0 * math.pi)) * scale
return x, y
def cluster_institutions(
institutions_list: List[Dict[str, Any]],
zoom: int = 10,
cluster_radius_pixels: int = 60,
max_zoom_cluster: int = 15
) -> List[Dict[str, Any]]:
"""
Grid-distance spatial clustering algorithm.
Groups nearby pins on the map at the given zoom level.
"""
if zoom >= max_zoom_cluster or not institutions_list:
# Return individual items directly
return [
{
"is_cluster": False,
"id": inst["id"],
"name": inst["name"],
"slug": inst["slug"],
"type": inst["type"],
"type_display": inst.get("type_display", inst["type"]),
"lat": inst["lat"],
"lng": inst["lng"],
"city": inst["city"],
"country": inst["country"],
"avatar": inst.get("avatar"),
"cover_image": inst.get("cover_image"),
"is_featured": inst.get("is_featured", False),
"verification_status": inst.get("verification_status", "pending"),
"follower_count": inst.get("follower_count", 0),
}
for inst in institutions_list
]
# Pre-calculate pixel positions
points = []
for inst in institutions_list:
lat = inst.get("lat") or inst.get("latitude")
lng = inst.get("lng") or inst.get("longitude")
if lat is None or lng is None:
continue
px, py = lat_lng_to_pixel(float(lat), float(lng), zoom)
points.append({
"data": inst,
"lat": float(lat),
"lng": float(lng),
"px": px,
"py": py,
"clustered": False
})
clusters_result = []
for i, pt in enumerate(points):
if pt["clustered"]:
continue
cluster_points = [pt]
pt["clustered"] = True
for j in range(i + 1, len(points)):
other_pt = points[j]
if other_pt["clustered"]:
continue
dx = pt["px"] - other_pt["px"]
dy = pt["py"] - other_pt["py"]
distance_sq = dx * dx + dy * dy
if distance_sq <= (cluster_radius_pixels * cluster_radius_pixels):
cluster_points.append(other_pt)
other_pt["clustered"] = True
if len(cluster_points) == 1:
inst = cluster_points[0]["data"]
clusters_result.append({
"is_cluster": False,
"id": inst["id"],
"name": inst["name"],
"slug": inst["slug"],
"type": inst["type"],
"type_display": inst.get("type_display", inst["type"]),
"lat": cluster_points[0]["lat"],
"lng": cluster_points[0]["lng"],
"city": inst["city"],
"country": inst["country"],
"avatar": inst.get("avatar"),
"cover_image": inst.get("cover_image"),
"is_featured": inst.get("is_featured", False),
"verification_status": inst.get("verification_status", "pending"),
"follower_count": inst.get("follower_count", 0),
})
else:
# Multi-point cluster
total_lat = sum(p["lat"] for p in cluster_points)
total_lng = sum(p["lng"] for p in cluster_points)
center_lat = total_lat / len(cluster_points)
center_lng = total_lng / len(cluster_points)
type_breakdown = {}
for p in cluster_points:
t = p["data"]["type"]
type_breakdown[t] = type_breakdown.get(t, 0) + 1
clusters_result.append({
"is_cluster": True,
"cluster_id": f"c_{zoom}_{int(center_lat*1000)}_{int(center_lng*1000)}",
"count": len(cluster_points),
"lat": round(center_lat, 6),
"lng": round(center_lng, 6),
"type_breakdown": type_breakdown,
"country": cluster_points[0]["data"]["country"],
"preview_institutions": [
{
"id": p["data"]["id"],
"name": p["data"]["name"],
"slug": p["data"]["slug"],
"type": p["data"]["type"],
"avatar": p["data"].get("avatar")
}
for p in cluster_points[:4]
]
})
return clusters_result