Merge branch 'master' into stable24

old_stable24
Varun Patil 2023-02-10 12:27:06 -08:00
commit f52d0d83aa
6 changed files with 273 additions and 54 deletions

View File

@ -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);

View File

@ -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'));

View File

@ -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']);
}
}

View File

@ -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))

View File

@ -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);

View File

@ -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>