Sideband/sbapp/mapview/clustered_marker_layer.py
2023-10-19 15:01:17 +02:00

450 lines
13 KiB
Python

# coding=utf-8
"""
Layer that support point clustering
===================================
"""
from math import atan, exp, floor, log, pi, sin, sqrt
from os.path import dirname, join
from kivy.lang import Builder
from kivy.metrics import dp
from kivy.properties import (
ListProperty,
NumericProperty,
ObjectProperty,
StringProperty,
)
from mapview.view import MapLayer, MapMarker
Builder.load_string(
"""
<ClusterMapMarker>:
size_hint: None, None
source: root.source
size: list(map(dp, self.texture_size))
allow_stretch: True
Label:
color: root.text_color
pos: root.pos
size: root.size
text: "{}".format(root.num_points)
font_size: dp(18)
"""
)
# longitude/latitude to spherical mercator in [0..1] range
def lngX(lng):
return lng / 360.0 + 0.5
def latY(lat):
if lat == 90:
return 0
if lat == -90:
return 1
s = sin(lat * pi / 180.0)
y = 0.5 - 0.25 * log((1 + s) / (1 - s)) / pi
return min(1, max(0, y))
# spherical mercator to longitude/latitude
def xLng(x):
return (x - 0.5) * 360
def yLat(y):
y2 = (180 - y * 360) * pi / 180
return 360 * atan(exp(y2)) / pi - 90
class KDBush:
"""
kdbush implementation from:
https://github.com/mourner/kdbush/blob/master/src/kdbush.js
"""
def __init__(self, points, node_size=64):
self.points = points
self.node_size = node_size
self.ids = ids = [0] * len(points)
self.coords = coords = [0] * len(points) * 2
for i, point in enumerate(points):
ids[i] = i
coords[2 * i] = point.x
coords[2 * i + 1] = point.y
self._sort(ids, coords, node_size, 0, len(ids) - 1, 0)
def range(self, min_x, min_y, max_x, max_y):
return self._range(
self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size
)
def within(self, x, y, r):
return self._within(self.ids, self.coords, x, y, r, self.node_size)
def _sort(self, ids, coords, node_size, left, right, depth):
if right - left <= node_size:
return
m = int(floor((left + right) / 2.0))
self._select(ids, coords, m, left, right, depth % 2)
self._sort(ids, coords, node_size, left, m - 1, depth + 1)
self._sort(ids, coords, node_size, m + 1, right, depth + 1)
def _select(self, ids, coords, k, left, right, inc):
swap_item = self._swap_item
while right > left:
if (right - left) > 600:
n = float(right - left + 1)
m = k - left + 1
z = log(n)
s = 0.5 + exp(2 * z / 3.0)
sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 if (m - n / 2.0) < 0 else 1)
new_left = max(left, int(floor(k - m * s / n + sd)))
new_right = min(right, int(floor(k + (n - m) * s / n + sd)))
self._select(ids, coords, k, new_left, new_right, inc)
t = coords[2 * k + inc]
i = left
j = right
swap_item(ids, coords, left, k)
if coords[2 * right + inc] > t:
swap_item(ids, coords, left, right)
while i < j:
swap_item(ids, coords, i, j)
i += 1
j -= 1
while coords[2 * i + inc] < t:
i += 1
while coords[2 * j + inc] > t:
j -= 1
if coords[2 * left + inc] == t:
swap_item(ids, coords, left, j)
else:
j += 1
swap_item(ids, coords, j, right)
if j <= k:
left = j + 1
if k <= j:
right = j - 1
def _swap_item(self, ids, coords, i, j):
swap = self._swap
swap(ids, i, j)
swap(coords, 2 * i, 2 * j)
swap(coords, 2 * i + 1, 2 * j + 1)
def _swap(self, arr, i, j):
tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
def _range(self, ids, coords, min_x, min_y, max_x, max_y, node_size):
stack = [0, len(ids) - 1, 0]
result = []
x = y = 0
while stack:
axis = stack.pop()
right = stack.pop()
left = stack.pop()
if right - left <= node_size:
for i in range(left, right + 1):
x = coords[2 * i]
y = coords[2 * i + 1]
if x >= min_x and x <= max_x and y >= min_y and y <= max_y:
result.append(ids[i])
continue
m = int(floor((left + right) / 2.0))
x = coords[2 * m]
y = coords[2 * m + 1]
if x >= min_x and x <= max_x and y >= min_y and y <= max_y:
result.append(ids[m])
nextAxis = (axis + 1) % 2
if min_x <= x if axis == 0 else min_y <= y:
stack.append(left)
stack.append(m - 1)
stack.append(nextAxis)
if max_x >= x if axis == 0 else max_y >= y:
stack.append(m + 1)
stack.append(right)
stack.append(nextAxis)
return result
def _within(self, ids, coords, qx, qy, r, node_size):
sq_dist = self._sq_dist
stack = [0, len(ids) - 1, 0]
result = []
r2 = r * r
while stack:
axis = stack.pop()
right = stack.pop()
left = stack.pop()
if right - left <= node_size:
for i in range(left, right + 1):
if sq_dist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2:
result.append(ids[i])
continue
m = int(floor((left + right) / 2.0))
x = coords[2 * m]
y = coords[2 * m + 1]
if sq_dist(x, y, qx, qy) <= r2:
result.append(ids[m])
nextAxis = (axis + 1) % 2
if (qx - r <= x) if axis == 0 else (qy - r <= y):
stack.append(left)
stack.append(m - 1)
stack.append(nextAxis)
if (qx + r >= x) if axis == 0 else (qy + r >= y):
stack.append(m + 1)
stack.append(right)
stack.append(nextAxis)
return result
def _sq_dist(self, ax, ay, bx, by):
dx = ax - bx
dy = ay - by
return dx * dx + dy * dy
class Cluster:
def __init__(self, x, y, num_points, id, props):
self.x = x
self.y = y
self.num_points = num_points
self.zoom = float("inf")
self.id = id
self.props = props
self.parent_id = None
self.widget = None
# preprocess lon/lat
self.lon = xLng(x)
self.lat = yLat(y)
class Marker:
def __init__(self, lon, lat, cls=MapMarker, options=None):
self.lon = lon
self.lat = lat
self.cls = cls
self.options = options
# preprocess x/y from lon/lat
self.x = lngX(lon)
self.y = latY(lat)
# cluster information
self.id = None
self.zoom = float("inf")
self.parent_id = None
self.widget = None
def __repr__(self):
return "<Marker lon={} lat={} source={}>".format(
self.lon, self.lat, self.source
)
class SuperCluster:
"""Port of supercluster from mapbox in pure python
"""
def __init__(self, min_zoom=0, max_zoom=16, radius=40, extent=512, node_size=64):
self.min_zoom = min_zoom
self.max_zoom = max_zoom
self.radius = radius
self.extent = extent
self.node_size = node_size
def load(self, points):
"""Load an array of markers.
Once loaded, the index is immutable.
"""
from time import time
self.trees = {}
self.points = points
for index, point in enumerate(points):
point.id = index
clusters = points
for z in range(self.max_zoom, self.min_zoom - 1, -1):
start = time()
print("build tree", z)
self.trees[z + 1] = KDBush(clusters, self.node_size)
print("kdbush", (time() - start) * 1000)
start = time()
clusters = self._cluster(clusters, z)
print(len(clusters))
print("clustering", (time() - start) * 1000)
self.trees[self.min_zoom] = KDBush(clusters, self.node_size)
def get_clusters(self, bbox, zoom):
"""For the given bbox [westLng, southLat, eastLng, northLat], and
integer zoom, returns an array of clusters and markers
"""
tree = self.trees[self._limit_zoom(zoom)]
ids = tree.range(lngX(bbox[0]), latY(bbox[3]), lngX(bbox[2]), latY(bbox[1]))
clusters = []
for i in range(len(ids)):
c = tree.points[ids[i]]
if isinstance(c, Cluster):
clusters.append(c)
else:
clusters.append(self.points[c.id])
return clusters
def _limit_zoom(self, z):
return max(self.min_zoom, min(self.max_zoom + 1, z))
def _cluster(self, points, zoom):
clusters = []
c_append = clusters.append
trees = self.trees
r = self.radius / float(self.extent * pow(2, zoom))
# loop through each point
for i in range(len(points)):
p = points[i]
# if we've already visited the point at this zoom level, skip it
if p.zoom <= zoom:
continue
p.zoom = zoom
# find all nearby points
tree = trees[zoom + 1]
neighbor_ids = tree.within(p.x, p.y, r)
num_points = 1
if isinstance(p, Cluster):
num_points = p.num_points
wx = p.x * num_points
wy = p.y * num_points
props = None
for j in range(len(neighbor_ids)):
b = tree.points[neighbor_ids[j]]
# filter out neighbors that are too far or already processed
if zoom < b.zoom:
num_points2 = 1
if isinstance(b, Cluster):
num_points2 = b.num_points
# save the zoom (so it doesn't get processed twice)
b.zoom = zoom
# accumulate coordinates for calculating weighted center
wx += b.x * num_points2
wy += b.y * num_points2
num_points += num_points2
b.parent_id = i
if num_points == 1:
c_append(p)
else:
p.parent_id = i
c_append(
Cluster(wx / num_points, wy / num_points, num_points, i, props)
)
return clusters
class ClusterMapMarker(MapMarker):
source = StringProperty(join(dirname(__file__), "icons", "cluster.png"))
cluster = ObjectProperty()
num_points = NumericProperty()
text_color = ListProperty([0.1, 0.1, 0.1, 1])
def on_cluster(self, instance, cluster):
self.num_points = cluster.num_points
def on_touch_down(self, touch):
return False
class ClusteredMarkerLayer(MapLayer):
cluster_cls = ObjectProperty(ClusterMapMarker)
cluster_min_zoom = NumericProperty(0)
cluster_max_zoom = NumericProperty(16)
cluster_radius = NumericProperty("40dp")
cluster_extent = NumericProperty(512)
cluster_node_size = NumericProperty(64)
def __init__(self, **kwargs):
self.cluster = None
self.cluster_markers = []
super().__init__(**kwargs)
def add_marker(self, lon, lat, cls=MapMarker, options=None):
if options is None:
options = {}
marker = Marker(lon, lat, cls, options)
self.cluster_markers.append(marker)
return marker
def remove_marker(self, marker):
self.cluster_markers.remove(marker)
def reposition(self):
if self.cluster is None:
self.build_cluster()
margin = dp(48)
mapview = self.parent
set_marker_position = self.set_marker_position
bbox = mapview.get_bbox(margin)
bbox = (bbox[1], bbox[0], bbox[3], bbox[2])
self.clear_widgets()
for point in self.cluster.get_clusters(bbox, mapview.zoom):
widget = point.widget
if widget is None:
widget = self.create_widget_for(point)
set_marker_position(mapview, widget)
self.add_widget(widget)
def build_cluster(self):
self.cluster = SuperCluster(
min_zoom=self.cluster_min_zoom,
max_zoom=self.cluster_max_zoom,
radius=self.cluster_radius,
extent=self.cluster_extent,
node_size=self.cluster_node_size,
)
self.cluster.load(self.cluster_markers)
def create_widget_for(self, point):
if isinstance(point, Marker):
point.widget = point.cls(lon=point.lon, lat=point.lat, **point.options)
elif isinstance(point, Cluster):
point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point)
return point.widget
def set_marker_position(self, mapview, marker):
x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom)
marker.x = int(x - marker.width * marker.anchor_x)
marker.y = int(y - marker.height * marker.anchor_y)