From 0987ab95c56050e581d68575689326ac71423c63 Mon Sep 17 00:00:00 2001 From: Raymond Huang Date: Thu, 9 Feb 2023 02:52:53 +0800 Subject: [PATCH] feat: improve marker clustering logic --- lib/Controller/LocationController.php | 71 +++++++++++++++++-- lib/Db/TimelineQueryDays.php | 6 +- .../top-matter/LocationTopMatter.vue | 26 ++++--- 3 files changed, 82 insertions(+), 21 deletions(-) diff --git a/lib/Controller/LocationController.php b/lib/Controller/LocationController.php index 8c9f0d81..9981a5a8 100644 --- a/lib/Controller/LocationController.php +++ b/lib/Controller/LocationController.php @@ -53,29 +53,29 @@ class LocationController extends ApiBase return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); } - // Just check bound parameters but not use them; they are used in transformation + // Just check bound parameters instead of using them; they are used in transformation $minLat = $this->request->getParam('minLat'); $maxLat = $this->request->getParam('maxLat'); $minLng = $this->request->getParam('minLng'); $maxLng = $this->request->getParam('maxLng'); - if (!$minLat || !$maxLat || !$minLng || !$maxLng) { - return new JSONResponse(['message' => 'Parameters missing'], Http::STATUS_PRECONDITION_FAILED); + if (!is_numeric($minLat) || !is_numeric($maxLat) || !is_numeric($minLng) || !is_numeric($maxLng)) { + return new JSONResponse(['message' => 'Invalid perameters'], Http::STATUS_PRECONDITION_FAILED); } - // Zoom level is used to determine the size of boxes + // Zoom level is used to determine the grid length $zoomLevel = $this->request->getParam('zoom'); if (!$zoomLevel || !is_numeric($zoomLevel)) { return new JSONResponse(['message' => 'Invalid zoom level'], Http::STATUS_PRECONDITION_FAILED); } // A tweakable parameter to determine the number of boxes in the map - $clusterDensity = 3; - $boxSize = 180.0 / (2 ** $zoomLevel * $clusterDensity); + $clusterDensity = 2; + $gridLength = 180.0 / (2 ** $zoomLevel * $clusterDensity); try { $clusters = $this->timelineQuery->getMapClusters( - $boxSize, + $gridLength, $root, $uid, $this->isRecursive(), @@ -83,9 +83,66 @@ class LocationController extends ApiBase $this->getTransformations(true), ); + // Merge clusters that are close together + $distanceThreshold = $gridLength / 3; + $clusters = $this->mergeClusters($clusters, $distanceThreshold); + return new JSONResponse($clusters); } catch (\Exception $e) { return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); } } + + private function mergeClusters($clusters, $distanceThreshold): array + { + $valid = array_fill(0, \count($clusters), true); + for ($i = 0; $i < \count($clusters); ++$i) { + if (!$valid[$i]) { + continue; + } + for ($j = 0; $j < \count($clusters); ++$j) { + if ($i === $j) { + continue; + } + if (!$valid[$i] || !$valid[$j]) { + continue; + } + if ($this->isClose($clusters[$i], $clusters[$j], $distanceThreshold)) { + $this->merge($valid, $clusters, $i, $j); + } + } + } + + $updatedClusters = []; + for ($i = 0; $i < \count($clusters); ++$i) { + if ($valid[$i]) { + $updatedClusters[] = $clusters[$i]; + } + } + + return $updatedClusters; + } + + private function isCLose(array $cluster1, array $cluster2, float $threshold): bool + { + $deltaX = (float) $cluster1['center'][0] - (float) $cluster2['center'][0]; + $deltaY = (float) $cluster1['center'][1] - (float) $cluster2['center'][1]; + + return $deltaX * $deltaX + $deltaY * $deltaY < $threshold * $threshold; + } + + private function merge(array &$valid, array &$clusters, int $index1, int $index2): void + { + $cluster1Count = (int) $clusters[$index1]['count']; + $cluster1Center = $clusters[$index1]['center']; + $cluster2Count = (int) $clusters[$index2]['count']; + $cluster2Center = $clusters[$index2]['center']; + $newCenter = [ + ($cluster1Count * $cluster1Center[0] + $cluster2Count * $cluster2Center[0]) / ($cluster1Count + $cluster2Count), + ($cluster1Count * $cluster1Center[1] + $cluster2Count * $cluster2Center[1]) / ($cluster1Count + $cluster2Count), + ]; + $clusters[] = ['center' => $newCenter, 'count' => $cluster1Count + $cluster2Count]; + $valid[] = true; + $valid[$index1] = $valid[$index2] = false; + } } diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index ef0a0509..b8eebac5 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -196,7 +196,7 @@ trait TimelineQueryDays } public function getMapClusters( - float $boxSize, + float $gridLength, TimelineRoot &$root, string $uid, bool $recursive, @@ -217,7 +217,7 @@ trait TimelineQueryDays $query = $this->joinFilecache($query, $root, $recursive, $archive); // Group by cluster - $groupFunction = $query->createFunction('latitude DIV '.$boxSize.', longitude DIV '.$boxSize); + $groupFunction = $query->createFunction('latitude DIV '.$gridLength.', longitude DIV '.$gridLength); $query->groupBy($groupFunction); // Apply all transformations (including map bounds) @@ -229,7 +229,7 @@ trait TimelineQueryDays $clusters = []; foreach ($res as $cluster) { - $clusters[] = + $clusters[] = ['center' => [(float) $cluster['avgLat'], (float) $cluster['avgLng']], 'count' => (float) $cluster['count']] ; } diff --git a/src/components/top-matter/LocationTopMatter.vue b/src/components/top-matter/LocationTopMatter.vue index 1c3c505e..f4cb1dd1 100644 --- a/src/components/top-matter/LocationTopMatter.vue +++ b/src/components/top-matter/LocationTopMatter.vue @@ -8,7 +8,6 @@ @zoomend="updateMapAndTimeline" > - - @@ -88,22 +86,28 @@ export default defineComponent({ async updateMapAndTimeline() { let map = this.$refs.map as LMap; - //let markerCluster = this.$refs.markerCluster as Vue2LeafletMarkerCluster; let boundary = map.mapObject.getBounds(); + let minLat = boundary.getSouth(); + let maxLat = boundary.getNorth(); + let minLng = boundary.getWest(); + let maxLng = boundary.getEast(); let zoomLevel = map.mapObject.getZoom().toString(); this.$parent.$emit("updateBoundary", { - minLat: boundary.getSouth(), - maxLat: boundary.getNorth(), - minLng: boundary.getWest(), - maxLng: boundary.getEast(), + minLat: minLat, + maxLat: maxLat, + minLng: minLng, + maxLng: maxLng, }); + let mapWidth = maxLat - minLat; + let mapHeight = maxLng - minLng; const query = new URLSearchParams(); - query.set("minLat", boundary.getSouth().toString()); - query.set("maxLat", boundary.getNorth().toString()); - query.set("minLng", boundary.getWest().toString()); - query.set("maxLng", boundary.getEast().toString()); + // Show clusters correctly while draging the map + query.set("minLat", (minLat - mapWidth).toString()); + query.set("maxLat", (maxLat + mapWidth).toString()); + query.set("minLng", (minLng - mapHeight).toString()); + query.set("maxLng", (maxLng + mapHeight).toString()); query.set("zoom", zoomLevel); const url = API.Q(API.CLUSTERS(), query);