Merge branch 'master' into stable24
commit
f52d0d83aa
|
@ -51,6 +51,7 @@ class MapController extends ApiBase
|
|||
}
|
||||
|
||||
// A tweakable parameter to determine the number of boxes in the map
|
||||
// Note: these parameters need to be changed in MapSplitMatter.vue as well
|
||||
$clusterDensity = 1;
|
||||
$gridLen = 180.0 / (2 ** $zoomLevel * $clusterDensity);
|
||||
|
||||
|
|
|
@ -39,18 +39,19 @@ trait TimelineQueryMap
|
|||
$query = $this->connection->getQueryBuilder();
|
||||
|
||||
// Get the average location of each cluster
|
||||
$id = $query->createFunction('MAX(c.id) as id');
|
||||
$ct = $query->createFunction('COUNT(m.fileid) AS count');
|
||||
$lat = $query->createFunction('AVG(c.lat) AS lat');
|
||||
$lon = $query->createFunction('AVG(c.lon) AS lon');
|
||||
$count = $query->createFunction('COUNT(m.fileid) AS count');
|
||||
|
||||
$query->select($lat, $lon, $count)
|
||||
$query->select($id, $ct, $lat, $lon)
|
||||
->from('memories_mapclusters', 'c')
|
||||
;
|
||||
|
||||
// Coarse grouping
|
||||
$query->addSelect($query->createFunction('MAX(c.id) as id'));
|
||||
$query->addGroupBy($query->createFunction("CAST(c.lat / {$gridLen} AS INT)"));
|
||||
$query->addGroupBy($query->createFunction("CAST(c.lon / {$gridLen} AS INT)"));
|
||||
$gridParam = $query->createNamedParameter($gridLen, IQueryBuilder::PARAM_STR);
|
||||
$query->addGroupBy($query->createFunction("FLOOR(c.lat / {$gridParam})"));
|
||||
$query->addGroupBy($query->createFunction("FLOOR(c.lon / {$gridParam})"));
|
||||
|
||||
// JOIN with memories for files from the current user
|
||||
$query->innerJoin('c', 'memories', 'm', $query->expr()->eq('c.id', 'm.mapcluster'));
|
||||
|
|
|
@ -170,8 +170,7 @@ class TimelineWrite
|
|||
$lon = (float) $exif['GPSLongitude'];
|
||||
|
||||
try {
|
||||
$mapCluster = $this->getMapCluster($mapCluster, $lat, $lon);
|
||||
$mapCluster = $mapCluster <= 0 ? null : $mapCluster;
|
||||
$mapCluster = $this->mapGetCluster($mapCluster, $lat, $lon);
|
||||
} catch (\Error $e) {
|
||||
$logger = \OC::$server->get(LoggerInterface::class);
|
||||
$logger->log(3, 'Error updating map cluster data: '.$e->getMessage(), ['app' => 'memories']);
|
||||
|
@ -185,6 +184,9 @@ class TimelineWrite
|
|||
}
|
||||
}
|
||||
|
||||
// NULL if invalid
|
||||
$mapCluster = $mapCluster <= 0 ? null : $mapCluster;
|
||||
|
||||
// Parameters for insert or update
|
||||
$params = [
|
||||
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
|
||||
|
@ -252,7 +254,7 @@ class TimelineWrite
|
|||
|
||||
// Delete from map cluster
|
||||
if ($record && ($cid = (int) $record['mapcluster']) > 0) {
|
||||
$this->removeFromCluster($cid, (float) $record['lat'], (float) $record['lon']);
|
||||
$this->mapRemoveFromCluster($cid, (float) $record['lat'], (float) $record['lon']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,17 @@ trait TimelineWriteMap
|
|||
{
|
||||
protected IDBConnection $connection;
|
||||
|
||||
protected function getMapCluster(int $prevCluster, float $lat, float $lon): int
|
||||
/**
|
||||
* Get the cluster ID for a given point.
|
||||
* If the cluster ID changes, update the old cluster and the new cluster.
|
||||
*
|
||||
* @param int $prevCluster The current cluster ID of the point
|
||||
* @param float $lat The latitude of the point
|
||||
* @param float $lon The longitude of the point
|
||||
*
|
||||
* @return int The new cluster ID
|
||||
*/
|
||||
protected function mapGetCluster(int $prevCluster, float $lat, float $lon): int
|
||||
{
|
||||
// Get all possible clusters within CLUSTER_DEG radius
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
|
@ -41,9 +51,9 @@ trait TimelineWriteMap
|
|||
|
||||
// If no cluster found, create a new one
|
||||
if ($minId <= 0) {
|
||||
$this->removeFromCluster($prevCluster, $lat, $lon);
|
||||
$this->mapRemoveFromCluster($prevCluster, $lat, $lon);
|
||||
|
||||
return $this->createMapCluster($lat, $lon);
|
||||
return $this->mapCreateCluster($lat, $lon);
|
||||
}
|
||||
|
||||
// If the file was previously in the same cluster, return that
|
||||
|
@ -53,13 +63,20 @@ trait TimelineWriteMap
|
|||
|
||||
// If the file was previously in a different cluster,
|
||||
// remove it from the first cluster and add it to the second
|
||||
$this->removeFromCluster($prevCluster, $lat, $lon);
|
||||
$this->addToCluster($minId, $lat, $lon);
|
||||
$this->mapRemoveFromCluster($prevCluster, $lat, $lon);
|
||||
$this->mapAddToCluster($minId, $lat, $lon);
|
||||
|
||||
return $minId;
|
||||
}
|
||||
|
||||
protected function addToCluster(int $clusterId, float $lat, float $lon): void
|
||||
/**
|
||||
* Add a point to a cluster.
|
||||
*
|
||||
* @param int $clusterId The ID of the cluster
|
||||
* @param float $lat The latitude of the point
|
||||
* @param float $lon The longitude of the point
|
||||
*/
|
||||
protected function mapAddToCluster(int $clusterId, float $lat, float $lon): void
|
||||
{
|
||||
if ($clusterId <= 0) {
|
||||
return;
|
||||
|
@ -70,15 +87,22 @@ trait TimelineWriteMap
|
|||
->set('point_count', $query->createFunction('point_count + 1'))
|
||||
->set('lat_sum', $query->createFunction("lat_sum + {$lat}"))
|
||||
->set('lon_sum', $query->createFunction("lon_sum + {$lon}"))
|
||||
->set('lat', $query->createFunction('lat_sum / point_count'))
|
||||
->set('lon', $query->createFunction('lon_sum / point_count'))
|
||||
->set('last_update', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
|
||||
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
|
||||
;
|
||||
$query->executeStatement();
|
||||
|
||||
$this->mapUpdateAggregates($clusterId);
|
||||
}
|
||||
|
||||
private function createMapCluster(float $lat, float $lon): int
|
||||
/**
|
||||
* Create a new cluster.
|
||||
*
|
||||
* @param float $lat The latitude of the point
|
||||
* @param float $lon The longitude of the point
|
||||
*
|
||||
* @return int The ID of the new cluster
|
||||
*/
|
||||
private function mapCreateCluster(float $lat, float $lon): int
|
||||
{
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->insert('memories_mapclusters')
|
||||
|
@ -86,17 +110,24 @@ trait TimelineWriteMap
|
|||
'point_count' => $query->createNamedParameter(1, IQueryBuilder::PARAM_INT),
|
||||
'lat_sum' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
|
||||
'lon_sum' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
|
||||
'lat' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
|
||||
'lon' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
|
||||
'last_update' => $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
;
|
||||
$query->executeStatement();
|
||||
|
||||
return (int) $query->getLastInsertId();
|
||||
$clusterId = (int) $query->getLastInsertId();
|
||||
$this->mapUpdateAggregates($clusterId);
|
||||
|
||||
return $clusterId;
|
||||
}
|
||||
|
||||
private function removeFromCluster(int $clusterId, float $lat, float $lon): void
|
||||
/**
|
||||
* Remove a point from a cluster.
|
||||
*
|
||||
* @param int $clusterId The ID of the cluster
|
||||
* @param float $lat The latitude of the point
|
||||
* @param float $lon The longitude of the point
|
||||
*/
|
||||
private function mapRemoveFromCluster(int $clusterId, float $lat, float $lon): void
|
||||
{
|
||||
if ($clusterId <= 0) {
|
||||
return;
|
||||
|
@ -107,6 +138,26 @@ trait TimelineWriteMap
|
|||
->set('point_count', $query->createFunction('point_count - 1'))
|
||||
->set('lat_sum', $query->createFunction("lat_sum - {$lat}"))
|
||||
->set('lon_sum', $query->createFunction("lon_sum - {$lon}"))
|
||||
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
|
||||
;
|
||||
$query->executeStatement();
|
||||
|
||||
$this->mapUpdateAggregates($clusterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the aggregate values of a cluster.
|
||||
*
|
||||
* @param int $clusterId The ID of the cluster
|
||||
*/
|
||||
private function mapUpdateAggregates(int $clusterId): void
|
||||
{
|
||||
if ($clusterId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
$query->update('memories_mapclusters')
|
||||
->set('lat', $query->createFunction('lat_sum / point_count'))
|
||||
->set('lon', $query->createFunction('lon_sum / point_count'))
|
||||
->set('last_update', $query->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
|
||||
|
|
|
@ -1011,6 +1011,8 @@ export default defineComponent({
|
|||
if (!data) return;
|
||||
|
||||
const head = this.heads[dayId];
|
||||
if (!head) return;
|
||||
|
||||
const day = head.day;
|
||||
this.loadedDays.add(dayId);
|
||||
this.sizedDays.add(dayId);
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<div class="map-matter">
|
||||
<div
|
||||
:class="{
|
||||
'map-matter': true,
|
||||
'anim-markers': animMarkers,
|
||||
}"
|
||||
>
|
||||
<LMap
|
||||
class="map"
|
||||
ref="map"
|
||||
|
@ -8,15 +13,16 @@
|
|||
:minZoom="2"
|
||||
@moveend="refresh"
|
||||
@zoomend="refresh"
|
||||
:options="mapOptions"
|
||||
>
|
||||
<LTileLayer :url="tileurl" :attribution="attribution" />
|
||||
<LTileLayer :url="tileurl" :attribution="attribution" :noWrap="true" />
|
||||
<LMarker
|
||||
v-for="cluster in clusters"
|
||||
:key="cluster.id"
|
||||
:lat-lng="cluster.center"
|
||||
@click="zoomTo(cluster)"
|
||||
>
|
||||
<LIcon :icon-anchor="[24, 24]">
|
||||
<LIcon :icon-anchor="[24, 24]" :className="clusterIconClass(cluster)">
|
||||
<div class="preview">
|
||||
<div class="count" v-if="cluster.count > 1">
|
||||
{{ cluster.count }}
|
||||
|
@ -38,6 +44,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from "vue2-leaflet";
|
||||
import { latLngBounds } from "leaflet";
|
||||
import { IPhoto } from "../../types";
|
||||
|
||||
import { API } from "../../services/API";
|
||||
|
@ -53,11 +60,15 @@ const OSM_ATTRIBUTION =
|
|||
const STAMEN_URL = `https://stamen-tiles-{s}.a.ssl.fastly.net/terrain-background/{z}/{x}/{y}{r}.png`;
|
||||
const STAMEN_ATTRIBUTION = `Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.`;
|
||||
|
||||
// CSS transition time for zooming in/out cluster animation
|
||||
const CLUSTER_TRANSITION_TIME = 300;
|
||||
|
||||
type IMarkerCluster = {
|
||||
id?: number;
|
||||
center: [number, number];
|
||||
count: number;
|
||||
preview?: IPhoto;
|
||||
dummy?: boolean;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
|
@ -72,7 +83,13 @@ export default defineComponent({
|
|||
|
||||
data: () => ({
|
||||
zoom: 2,
|
||||
mapOptions: {
|
||||
maxBounds: latLngBounds([-90, -180], [90, 180]),
|
||||
maxBoundsViscosity: 0.9,
|
||||
},
|
||||
clusters: [] as IMarkerCluster[],
|
||||
animMarkers: false,
|
||||
isDark: false,
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
|
@ -83,15 +100,22 @@ export default defineComponent({
|
|||
|
||||
// Initialize
|
||||
this.refresh();
|
||||
|
||||
// If currently dark mode, set isDark
|
||||
const pane = document.querySelector(".leaflet-tile-pane");
|
||||
this.isDark =
|
||||
!pane || window.getComputedStyle(pane)?.["filter"]?.includes("invert");
|
||||
},
|
||||
|
||||
computed: {
|
||||
tileurl() {
|
||||
return this.zoom >= 5 ? OSM_TILE_URL : STAMEN_URL;
|
||||
return this.zoom >= 5 || this.isDark ? OSM_TILE_URL : STAMEN_URL;
|
||||
},
|
||||
|
||||
attribution() {
|
||||
return this.zoom >= 5 ? OSM_ATTRIBUTION : STAMEN_ATTRIBUTION;
|
||||
return this.zoom >= 5 || this.isDark
|
||||
? OSM_ATTRIBUTION
|
||||
: STAMEN_ATTRIBUTION;
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -101,36 +125,70 @@ export default defineComponent({
|
|||
|
||||
// Get boundaries of the map
|
||||
const boundary = map.mapObject.getBounds();
|
||||
const minLat = boundary.getSouth();
|
||||
const maxLat = boundary.getNorth();
|
||||
const minLon = boundary.getWest();
|
||||
const maxLon = boundary.getEast();
|
||||
let minLat = boundary.getSouth();
|
||||
let maxLat = boundary.getNorth();
|
||||
let minLon = boundary.getWest();
|
||||
let maxLon = boundary.getEast();
|
||||
|
||||
// Set query parameters to route if required
|
||||
const s = (x: number) => x.toFixed(6);
|
||||
const bounds = `${s(minLat)},${s(maxLat)},${s(minLon)},${s(maxLon)}`;
|
||||
this.zoom = Math.round(map.mapObject.getZoom());
|
||||
const zoom = this.zoom.toString();
|
||||
if (this.$route.query.b === bounds && this.$route.query.z === zoom) {
|
||||
const bounds = () =>
|
||||
`${s(minLat)},${s(maxLat)},${s(minLon)},${s(maxLon)}`;
|
||||
|
||||
// Zoom level
|
||||
const oldZoom = this.zoom;
|
||||
const newZoom = Math.round(map.mapObject.getZoom());
|
||||
const zoomStr = newZoom.toString();
|
||||
this.zoom = newZoom;
|
||||
|
||||
// Check if we already have the data
|
||||
if (this.$route.query.b === bounds() && this.$route.query.z === zoomStr) {
|
||||
return;
|
||||
}
|
||||
this.$router.replace({ query: { b: bounds, z: zoom } });
|
||||
this.$router.replace({
|
||||
query: {
|
||||
b: bounds(),
|
||||
z: zoomStr,
|
||||
},
|
||||
});
|
||||
|
||||
// Extend bounds by 25% beyond the map
|
||||
const latDiff = Math.abs(maxLat - minLat);
|
||||
const lonDiff = Math.abs(maxLon - minLon);
|
||||
minLat -= latDiff * 0.25;
|
||||
maxLat += latDiff * 0.25;
|
||||
minLon -= lonDiff * 0.25;
|
||||
maxLon += lonDiff * 0.25;
|
||||
|
||||
// Show clusters correctly while draging the map
|
||||
const query = new URLSearchParams();
|
||||
query.set("bounds", bounds);
|
||||
query.set("zoom", zoom);
|
||||
query.set("bounds", bounds());
|
||||
query.set("zoom", zoomStr);
|
||||
|
||||
// Make API call
|
||||
const url = API.Q(API.MAP_CLUSTERS(), query);
|
||||
const res = await axios.get(url);
|
||||
this.clusters = res.data;
|
||||
|
||||
if (this.zoom > oldZoom) {
|
||||
this.setClustersZoomIn(res.data, oldZoom);
|
||||
} else if (this.zoom < oldZoom) {
|
||||
this.setClustersZoomOut(res.data);
|
||||
} else {
|
||||
this.clusters = res.data;
|
||||
}
|
||||
|
||||
// Animate markers
|
||||
this.animateMarkers();
|
||||
},
|
||||
|
||||
clusterPreviewUrl(cluster: IMarkerCluster) {
|
||||
return getPreviewUrl(cluster.preview, false, 256);
|
||||
},
|
||||
|
||||
clusterIconClass(cluster: IMarkerCluster) {
|
||||
return cluster.dummy ? "dummy" : "";
|
||||
},
|
||||
|
||||
zoomTo(cluster: IMarkerCluster) {
|
||||
// At high zoom levels, open the photo
|
||||
if (this.zoom >= 12 && cluster.preview) {
|
||||
|
@ -145,6 +203,99 @@ export default defineComponent({
|
|||
const zoom = map.mapObject.getZoom() + factor;
|
||||
map.mapObject.setView(cluster.center, zoom, { animate: true });
|
||||
},
|
||||
|
||||
getGridKey(center: [number, number], zoom: number) {
|
||||
// Calcluate grid length
|
||||
const clusterDensity = 1;
|
||||
const oldGridLen = 180.0 / (2 ** zoom * clusterDensity);
|
||||
|
||||
// Get map key
|
||||
const latGid = Math.floor(center[0] / oldGridLen);
|
||||
const lonGid = Math.floor(center[1] / oldGridLen);
|
||||
return `${latGid}-${lonGid}`;
|
||||
},
|
||||
|
||||
getGridMap(clusters: IMarkerCluster[], zoom: number) {
|
||||
const gridMap = new Map<string, IMarkerCluster>();
|
||||
for (const cluster of clusters) {
|
||||
const key = this.getGridKey(cluster.center, zoom);
|
||||
gridMap.set(key, cluster);
|
||||
}
|
||||
return gridMap;
|
||||
},
|
||||
|
||||
async setClustersZoomIn(clusters: IMarkerCluster[], oldZoom: number) {
|
||||
// Create GID-map for old clusters
|
||||
const oldClusters = this.getGridMap(this.clusters, oldZoom);
|
||||
|
||||
// Dummy clusters to animate markers
|
||||
const dummyClusters: IMarkerCluster[] = [];
|
||||
|
||||
// Iterate new clusters
|
||||
for (const cluster of clusters) {
|
||||
// Check if cluster already exists
|
||||
const key = this.getGridKey(cluster.center, oldZoom);
|
||||
const oldCluster = oldClusters.get(key);
|
||||
if (oldCluster) {
|
||||
// Copy cluster and set location to old cluster
|
||||
dummyClusters.push({
|
||||
...cluster,
|
||||
center: oldCluster.center,
|
||||
});
|
||||
} else {
|
||||
// Just show it
|
||||
dummyClusters.push(cluster);
|
||||
}
|
||||
}
|
||||
|
||||
// Set clusters
|
||||
this.clusters = dummyClusters;
|
||||
await this.$nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
this.clusters = clusters;
|
||||
},
|
||||
|
||||
async setClustersZoomOut(clusters: IMarkerCluster[]) {
|
||||
// Get GID-map for new clusters
|
||||
const newClustersGid = this.getGridMap(clusters, this.zoom);
|
||||
|
||||
// Get ID-map for new clusters
|
||||
const newClustersId = new Map<number, IMarkerCluster>();
|
||||
for (const cluster of clusters) {
|
||||
newClustersId.set(cluster.id, cluster);
|
||||
}
|
||||
|
||||
// Dummy clusters to animate markers
|
||||
const dummyClusters: IMarkerCluster[] = [...clusters];
|
||||
|
||||
// Iterate old clusters
|
||||
for (const oldCluster of this.clusters) {
|
||||
// Process only clusters that are not in the new clusters
|
||||
const newCluster = newClustersId.get(oldCluster.id);
|
||||
if (!newCluster) {
|
||||
// Get the new cluster at the same GID
|
||||
const key = this.getGridKey(oldCluster.center, this.zoom);
|
||||
const newCluster = newClustersGid.get(key);
|
||||
if (newCluster) {
|
||||
// No need to copy; it is gone anyway
|
||||
oldCluster.center = newCluster.center;
|
||||
oldCluster.dummy = true;
|
||||
dummyClusters.push(oldCluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set clusters
|
||||
this.clusters = dummyClusters;
|
||||
await new Promise((r) => setTimeout(r, CLUSTER_TRANSITION_TIME)); // wait for animation
|
||||
this.clusters = clusters;
|
||||
},
|
||||
|
||||
async animateMarkers() {
|
||||
this.animMarkers = true;
|
||||
await new Promise((r) => setTimeout(r, CLUSTER_TRANSITION_TIME)); // wait for animation
|
||||
this.animMarkers = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -160,6 +311,21 @@ export default defineComponent({
|
|||
width: 100%;
|
||||
margin: 0;
|
||||
z-index: 0;
|
||||
background-color: var(--color-background-dark);
|
||||
|
||||
:deep .leaflet-control-attribution {
|
||||
background-color: var(--color-background-dark);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
:deep .leaflet-bar a {
|
||||
background-color: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
|
||||
&.leaflet-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
|
@ -197,12 +363,17 @@ export default defineComponent({
|
|||
|
||||
<style lang="scss">
|
||||
.leaflet-marker-icon {
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
.anim-markers & {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
// Show leaflet marker on top on hover
|
||||
.leaflet-marker-icon:hover {
|
||||
z-index: 100000 !important;
|
||||
&.dummy {
|
||||
z-index: -100000 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
|
@ -219,13 +390,4 @@ $darkFilter: invert(1) grayscale(1) brightness(1.3) contrast(1.3);
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in New Issue