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