diff --git a/appinfo/routes.php b/appinfo/routes.php index 20c670a9..3463da69 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -77,6 +77,8 @@ return [ ['name' => 'Download#file', 'url' => '/api/download/{handle}', 'verb' => 'GET'], ['name' => 'Download#one', 'url' => '/api/stream/{fileid}', 'verb' => 'GET'], + ['name' => 'Location#clusters', 'url' => '/api/locations/clusters', 'verb' => 'GET'], + // Config API ['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], diff --git a/lib/Controller/ApiBase.php b/lib/Controller/ApiBase.php index 56cd16f7..a1a2d886 100644 --- a/lib/Controller/ApiBase.php +++ b/lib/Controller/ApiBase.php @@ -329,6 +329,89 @@ class ApiBase extends Controller return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID()); } + /** + * Get transformations depending on the request. + * + * @param bool $aggregateOnly Only apply transformations for aggregation (days call) + */ + protected function getTransformations(bool $aggregateOnly) + { + $transforms = []; + + // Add extra information, basename and mimetype + if (!$aggregateOnly && ($fields = $this->request->getParam('fields'))) { + $fields = explode(',', $fields); + $transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields]; + } + + // Filter for one album + if ($this->albumsIsEnabled()) { + if ($albumId = $this->request->getParam('album')) { + $transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId]; + } + } + + // Other transforms not allowed for public shares + if (null === $this->userSession->getUser()) { + return $transforms; + } + + // Filter only favorites + if ($this->request->getParam('fav')) { + $transforms[] = [$this->timelineQuery, 'transformFavoriteFilter']; + } + + // Filter only videos + if ($this->request->getParam('vid')) { + $transforms[] = [$this->timelineQuery, 'transformVideoFilter']; + } + + // Filter only for one face on Recognize + if (($recognize = $this->request->getParam('recognize')) && $this->recognizeIsEnabled()) { + $transforms[] = [$this->timelineQuery, 'transformPeopleRecognitionFilter', $recognize]; + + $faceRect = $this->request->getParam('facerect'); + if ($faceRect && !$aggregateOnly) { + $transforms[] = [$this->timelineQuery, 'transformPeopleRecognizeRect', $recognize]; + } + } + + // Filter only for one face on Face Recognition + if (($face = $this->request->getParam('facerecognition')) && $this->facerecognitionIsEnabled()) { + $currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1); + $transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionFilter', $currentModel, $face]; + + $faceRect = $this->request->getParam('facerect'); + if ($faceRect && !$aggregateOnly) { + $transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionRect', $face]; + } + } + + // Filter only for one tag + if ($this->tagsIsEnabled()) { + if ($tagName = $this->request->getParam('tag')) { + $transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName]; + } + } + + // Limit number of responses for day query + $limit = $this->request->getParam('limit'); + if ($limit) { + $transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit]; + } + + // Filter geological bounds + $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) { + $transforms[] = [$this->timelineQuery, 'transformBoundFilter', $minLat, $maxLat, $minLng, $maxLng]; + } + + return $transforms; + } + /** * Helper to get one file or null from a fiolder. */ diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 4ab75c57..ba596693 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -172,89 +172,6 @@ class DaysController extends ApiBase return $this->day($id); } - /** - * Get transformations depending on the request. - * - * @param bool $aggregateOnly Only apply transformations for aggregation (days call) - */ - private function getTransformations(bool $aggregateOnly) - { - $transforms = []; - - // Add extra information, basename and mimetype - if (!$aggregateOnly && ($fields = $this->request->getParam('fields'))) { - $fields = explode(',', $fields); - $transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields]; - } - - // Filter for one album - if ($this->albumsIsEnabled()) { - if ($albumId = $this->request->getParam('album')) { - $transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId]; - } - } - - // Other transforms not allowed for public shares - if (null === $this->userSession->getUser()) { - return $transforms; - } - - // Filter only favorites - if ($this->request->getParam('fav')) { - $transforms[] = [$this->timelineQuery, 'transformFavoriteFilter']; - } - - // Filter only videos - if ($this->request->getParam('vid')) { - $transforms[] = [$this->timelineQuery, 'transformVideoFilter']; - } - - // Filter only for one face on Recognize - if (($recognize = $this->request->getParam('recognize')) && $this->recognizeIsEnabled()) { - $transforms[] = [$this->timelineQuery, 'transformPeopleRecognitionFilter', $recognize]; - - $faceRect = $this->request->getParam('facerect'); - if ($faceRect && !$aggregateOnly) { - $transforms[] = [$this->timelineQuery, 'transformPeopleRecognizeRect', $recognize]; - } - } - - // Filter only for one face on Face Recognition - if (($face = $this->request->getParam('facerecognition')) && $this->facerecognitionIsEnabled()) { - $currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1); - $transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionFilter', $currentModel, $face]; - - $faceRect = $this->request->getParam('facerect'); - if ($faceRect && !$aggregateOnly) { - $transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionRect', $face]; - } - } - - // Filter only for one tag - if ($this->tagsIsEnabled()) { - if ($tagName = $this->request->getParam('tag')) { - $transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName]; - } - } - - // Limit number of responses for day query - $limit = $this->request->getParam('limit'); - if ($limit) { - $transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit]; - } - - // Filter geological bounds - $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) { - $transforms[] = [$this->timelineQuery, 'transformBoundFilter', $minLat, $maxLat, $minLng, $maxLng]; - } - - return $transforms; - } - /** * Preload a few "day" at the start of "days" response. * diff --git a/lib/Controller/LocationController.php b/lib/Controller/LocationController.php new file mode 100644 index 00000000..8c9f0d81 --- /dev/null +++ b/lib/Controller/LocationController.php @@ -0,0 +1,91 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Memories\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; + +class LocationController extends ApiBase +{ + /** + * @NoAdminRequired + * + * @PublicPage + * + * @NoCSRFRequired + */ + public function clusters(): JSONResponse + { + // Get the folder to show + try { + $uid = $this->getUID(); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_PRECONDITION_FAILED); + } + + // Get the folder to show + $root = null; + + try { + $root = $this->getRequestRoot(); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } + + // Just check bound parameters but not use 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); + } + + // Zoom level is used to determine the size of boxes + $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); + + try { + $clusters = $this->timelineQuery->getMapClusters( + $boxSize, + $root, + $uid, + $this->isRecursive(), + $this->isArchive(), + $this->getTransformations(true), + ); + + return new JSONResponse($clusters); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index ab464089..ef0a0509 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -195,6 +195,48 @@ trait TimelineQueryDays return $this->processDay($rows, $uid, $root); } + public function getMapClusters( + float $boxSize, + TimelineRoot &$root, + string $uid, + bool $recursive, + bool $archive, + array $queryTransforms = [] + ): array { + $query = $this->connection->getQueryBuilder(); + + // Get the average location of each cluster + $avgLat = $query->createFunction('AVG(latitude) AS avgLat'); + $avgLng = $query->createFunction('AVG(longitude) AS avgLng'); + $count = $query->createFunction('COUNT(*) AS count'); + $query->select($avgLat, $avgLng, $count) + ->from('memories', 'm') + ; + + // JOIN with filecache for existing files + $query = $this->joinFilecache($query, $root, $recursive, $archive); + + // Group by cluster + $groupFunction = $query->createFunction('latitude DIV '.$boxSize.', longitude DIV '.$boxSize); + $query->groupBy($groupFunction); + + // Apply all transformations (including map bounds) + $this->applyAllTransforms($queryTransforms, $query, $uid); + + $cursor = $this->executeQueryWithCTEs($query); + $res = $cursor->fetchAll(); + $cursor->closeCursor(); + + $clusters = []; + foreach ($res as $cluster) { + $clusters[] = + ['center' => [(float) $cluster['avgLat'], (float) $cluster['avgLng']], 'count' => (float) $cluster['count']] + ; + } + + return $clusters; + } + /** * Process the days response. * diff --git a/src/components/top-matter/LocationTopMatter.vue b/src/components/top-matter/LocationTopMatter.vue index 133bf9c5..1c3c505e 100644 --- a/src/components/top-matter/LocationTopMatter.vue +++ b/src/components/top-matter/LocationTopMatter.vue @@ -4,11 +4,19 @@ style="height: 100%; width: 100%; margin-right: 3.5em; z-index: 0" :zoom="zoom" ref="map" - @moveend="getBoundary" - @zoomend="getBoundary" + @moveend="updateMapAndTimeline" + @zoomend="updateMapAndTimeline" > - + + + + + @@ -18,9 +26,11 @@ import { defineComponent, PropType } from "vue"; import { LMap, LTileLayer, LMarker, LPopup } from "vue2-leaflet"; import Vue2LeafletMarkerCluster from "vue2-leaflet-markercluster"; import "leaflet/dist/leaflet.css"; -import { IRow, IPhoto } from "../../types"; +import { IPhoto, MarkerClusters } from "../../types"; import { Icon } from "leaflet"; +import axios from "axios"; +import { API } from "../../services/API"; type D = Icon.Default & { _getIconUrl?: string; @@ -50,6 +60,7 @@ export default defineComponent({ attribution: '© OpenStreetMap contributors', zoom: 1, + clusters: [] as MarkerClusters[], }), watch: { @@ -60,6 +71,7 @@ export default defineComponent({ mounted() { this.createMatter(); + this.updateMapAndTimeline(); }, computed: { @@ -74,15 +86,29 @@ export default defineComponent({ this.name = this.$route.params.name || ""; }, - getBoundary() { + async updateMapAndTimeline() { let map = this.$refs.map as LMap; + //let markerCluster = this.$refs.markerCluster as Vue2LeafletMarkerCluster; let boundary = map.mapObject.getBounds(); + let zoomLevel = map.mapObject.getZoom().toString(); + this.$parent.$emit("updateBoundary", { minLat: boundary.getSouth(), maxLat: boundary.getNorth(), minLng: boundary.getWest(), maxLng: boundary.getEast(), }); + + 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()); + query.set("zoom", zoomLevel); + + const url = API.Q(API.CLUSTERS(), query); + const res = await axios.get(url); + this.clusters = res.data; }, }, }); diff --git a/src/services/API.ts b/src/services/API.ts index fdee791b..3fbdf709 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -115,4 +115,8 @@ export class API { static CONFIG(setting: string) { return gen(`${BASE}/config/{setting}`, { setting }); } + + static CLUSTERS() { + return tok(gen(`${BASE}/locations/clusters`)); + } } diff --git a/src/types.ts b/src/types.ts index 1a95a243..343c6378 100644 --- a/src/types.ts +++ b/src/types.ts @@ -243,3 +243,8 @@ export type MapBoundary = { minLng: number; maxLng: number; } + +export type MarkerClusters = { + center: [number, number]; + count: number; +}