feat: improve marker clustering logic

pull/376/head
Raymond Huang 2023-02-09 02:52:53 +08:00
parent 7d01849f8e
commit 0987ab95c5
3 changed files with 82 additions and 21 deletions

View File

@ -53,29 +53,29 @@ class LocationController extends ApiBase
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); 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'); $minLat = $this->request->getParam('minLat');
$maxLat = $this->request->getParam('maxLat'); $maxLat = $this->request->getParam('maxLat');
$minLng = $this->request->getParam('minLng'); $minLng = $this->request->getParam('minLng');
$maxLng = $this->request->getParam('maxLng'); $maxLng = $this->request->getParam('maxLng');
if (!$minLat || !$maxLat || !$minLng || !$maxLng) { if (!is_numeric($minLat) || !is_numeric($maxLat) || !is_numeric($minLng) || !is_numeric($maxLng)) {
return new JSONResponse(['message' => 'Parameters missing'], Http::STATUS_PRECONDITION_FAILED); 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'); $zoomLevel = $this->request->getParam('zoom');
if (!$zoomLevel || !is_numeric($zoomLevel)) { if (!$zoomLevel || !is_numeric($zoomLevel)) {
return new JSONResponse(['message' => 'Invalid zoom level'], Http::STATUS_PRECONDITION_FAILED); return new JSONResponse(['message' => 'Invalid zoom level'], Http::STATUS_PRECONDITION_FAILED);
} }
// A tweakable parameter to determine the number of boxes in the map // A tweakable parameter to determine the number of boxes in the map
$clusterDensity = 3; $clusterDensity = 2;
$boxSize = 180.0 / (2 ** $zoomLevel * $clusterDensity); $gridLength = 180.0 / (2 ** $zoomLevel * $clusterDensity);
try { try {
$clusters = $this->timelineQuery->getMapClusters( $clusters = $this->timelineQuery->getMapClusters(
$boxSize, $gridLength,
$root, $root,
$uid, $uid,
$this->isRecursive(), $this->isRecursive(),
@ -83,9 +83,66 @@ class LocationController extends ApiBase
$this->getTransformations(true), $this->getTransformations(true),
); );
// Merge clusters that are close together
$distanceThreshold = $gridLength / 3;
$clusters = $this->mergeClusters($clusters, $distanceThreshold);
return new JSONResponse($clusters); return new JSONResponse($clusters);
} catch (\Exception $e) { } catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); 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;
}
} }

View File

@ -196,7 +196,7 @@ trait TimelineQueryDays
} }
public function getMapClusters( public function getMapClusters(
float $boxSize, float $gridLength,
TimelineRoot &$root, TimelineRoot &$root,
string $uid, string $uid,
bool $recursive, bool $recursive,
@ -217,7 +217,7 @@ trait TimelineQueryDays
$query = $this->joinFilecache($query, $root, $recursive, $archive); $query = $this->joinFilecache($query, $root, $recursive, $archive);
// Group by cluster // Group by cluster
$groupFunction = $query->createFunction('latitude DIV '.$boxSize.', longitude DIV '.$boxSize); $groupFunction = $query->createFunction('latitude DIV '.$gridLength.', longitude DIV '.$gridLength);
$query->groupBy($groupFunction); $query->groupBy($groupFunction);
// Apply all transformations (including map bounds) // Apply all transformations (including map bounds)

View File

@ -8,7 +8,6 @@
@zoomend="updateMapAndTimeline" @zoomend="updateMapAndTimeline"
> >
<l-tile-layer :url="url" :attribution="attribution" /> <l-tile-layer :url="url" :attribution="attribution" />
<!-- <v-marker-cluster ref="markerCluster"> -->
<l-marker <l-marker
v-for="cluster in clusters" v-for="cluster in clusters"
:key="cluster.center.toString()" :key="cluster.center.toString()"
@ -16,7 +15,6 @@
> >
<l-popup :content="cluster.count.toString()" /> <l-popup :content="cluster.count.toString()" />
</l-marker> </l-marker>
<!-- </v-marker-cluster> -->
</l-map> </l-map>
</div> </div>
</template> </template>
@ -88,22 +86,28 @@ export default defineComponent({
async updateMapAndTimeline() { async updateMapAndTimeline() {
let map = this.$refs.map as LMap; let map = this.$refs.map as LMap;
//let markerCluster = this.$refs.markerCluster as Vue2LeafletMarkerCluster;
let boundary = map.mapObject.getBounds(); 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(); let zoomLevel = map.mapObject.getZoom().toString();
this.$parent.$emit("updateBoundary", { this.$parent.$emit("updateBoundary", {
minLat: boundary.getSouth(), minLat: minLat,
maxLat: boundary.getNorth(), maxLat: maxLat,
minLng: boundary.getWest(), minLng: minLng,
maxLng: boundary.getEast(), maxLng: maxLng,
}); });
let mapWidth = maxLat - minLat;
let mapHeight = maxLng - minLng;
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set("minLat", boundary.getSouth().toString()); // Show clusters correctly while draging the map
query.set("maxLat", boundary.getNorth().toString()); query.set("minLat", (minLat - mapWidth).toString());
query.set("minLng", boundary.getWest().toString()); query.set("maxLat", (maxLat + mapWidth).toString());
query.set("maxLng", boundary.getEast().toString()); query.set("minLng", (minLng - mapHeight).toString());
query.set("maxLng", (maxLng + mapHeight).toString());
query.set("zoom", zoomLevel); query.set("zoom", zoomLevel);
const url = API.Q(API.CLUSTERS(), query); const url = API.Q(API.CLUSTERS(), query);