feat: improve marker clustering logic
parent
7d01849f8e
commit
0987ab95c5
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in New Issue