diff --git a/appinfo/routes.php b/appinfo/routes.php index 8cb158ed..926c21d5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -21,6 +21,7 @@ return [ ['name' => 'Page#videos', 'url' => '/videos', 'verb' => 'GET'], ['name' => 'Page#archive', 'url' => '/archive', 'verb' => 'GET'], ['name' => 'Page#thisday', 'url' => '/thisday', 'verb' => 'GET'], + ['name' => 'Page#locations', 'url' => '/locations', 'verb' => 'GET'], // Routes with params w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'), @@ -80,6 +81,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 865c2d5b..79bc3497 100644 --- a/lib/Controller/ApiBase.php +++ b/lib/Controller/ApiBase.php @@ -379,6 +379,96 @@ class ApiBase extends Controller return \OCA\Memories\Util::placesGISType() > 0; } + /** + * 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]; + } + } + + // Filter only for one place + if ($this->placesIsEnabled()) { + if ($locationId = $this->request->getParam('place')) { + $transforms[] = [$this->timelineQuery, 'transformPlaceFilter', (int) $locationId]; + } + } + + // 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 206914f6..ba596693 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -172,87 +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]; - } - } - - // Filter only for one place - if ($this->placesIsEnabled()) { - if ($locationId = $this->request->getParam('place')) { - $transforms[] = [$this->timelineQuery, 'transformPlaceFilter', (int) $locationId]; - } - } - - // Limit number of responses for day query - $limit = $this->request->getParam('limit'); - if ($limit) { - $transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit]; - } - - 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/Controller/PageController.php b/lib/Controller/PageController.php index dff5c116..e2ec6b72 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -116,6 +116,7 @@ class PageController extends Controller // Allow OSM $policy->addAllowedFrameDomain('www.openstreetmap.org'); + $policy->addAllowedImageDomain('https://*.tile.openstreetmap.org'); return $policy; } @@ -236,4 +237,14 @@ class PageController extends Controller { return $this->main(); } + + /** + * @NoAdminRequired + * + * @NoCSRFRequired + */ + public function locations() + { + return $this->main(); + } } diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index 1e238863..6a8e166c 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -195,6 +195,47 @@ 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/lib/Db/TimelineQueryFilters.php b/lib/Db/TimelineQueryFilters.php index bd57180c..7cc8ea64 100644 --- a/lib/Db/TimelineQueryFilters.php +++ b/lib/Db/TimelineQueryFilters.php @@ -40,6 +40,18 @@ trait TimelineQueryFilters $query->setMaxResults($limit); } + public function transformBoundFilter(IQueryBuilder &$query, string $userId, string $minLat, string $maxLat, string $minLng, string $maxLng) + { + $query->andWhere( + $query->expr()->andX( + $query->expr()->gte('m.latitude', $query->createNamedParameter($minLat, IQueryBuilder::PARAM_STR)), + $query->expr()->lte('m.latitude', $query->createNamedParameter($maxLat, IQueryBuilder::PARAM_STR)), + $query->expr()->gte('m.longitude', $query->createNamedParameter($minLng, IQueryBuilder::PARAM_STR)), + $query->expr()->lte('m.longitude', $query->createNamedParameter($maxLng, IQueryBuilder::PARAM_STR)) + ) + ); + } + private function applyAllTransforms(array $transforms, IQueryBuilder &$query, string $uid): void { foreach ($transforms as &$transform) { diff --git a/package-lock.json b/package-lock.json index 15a62c01..f52a1777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "camelcase": "^7.0.1", "filerobot-image-editor": "^4.3.7", "justified-layout": "^4.1.0", + "leaflet": "^1.9.3", "moment": "^2.29.4", "path-posix": "^1.0.0", "photoswipe": "^5.3.4", @@ -27,6 +28,8 @@ "vue-material-design-icons": "^5.1.2", "vue-router": "^3.6.5", "vue-virtual-scroller": "1.1.2", + "vue2-leaflet": "^2.7.1", + "vue2-leaflet-markercluster": "^3.1.0", "webdav": "^4.11.2" }, "devDependencies": { @@ -2297,6 +2300,12 @@ "@types/range-parser": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "peer": true + }, "node_modules/@types/http-proxy": { "version": "1.17.9", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", @@ -2319,6 +2328,15 @@ "dev": true, "peer": true }, + "node_modules/@types/leaflet": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz", + "integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==", + "peer": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -6166,9 +6184,9 @@ "peer": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "bin": { "json5": "lib/cli.js" @@ -6253,6 +6271,19 @@ "resolved": "https://registry.npmjs.org/layerr/-/layerr-0.1.2.tgz", "integrity": "sha512-ob5kTd9H3S4GOG2nVXyQhOu9O8nBgP555XxWPkJI0tR0JeRilfyTp8WtPdIJHLXBmHMSdEq5+KMxiYABeScsIQ==" }, + "node_modules/leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6287,9 +6318,9 @@ } }, "node_modules/loader-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.3.tgz", - "integrity": "sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", "dev": true, "peer": true, "dependencies": { @@ -9518,9 +9549,9 @@ } }, "node_modules/vue-loader/node_modules/json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "peer": true, "dependencies": { @@ -9531,9 +9562,9 @@ } }, "node_modules/vue-loader/node_modules/loader-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", - "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", "dev": true, "peer": true, "dependencies": { @@ -9600,9 +9631,9 @@ } }, "node_modules/vue-style-loader/node_modules/json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "peer": true, "dependencies": { @@ -9613,9 +9644,9 @@ } }, "node_modules/vue-style-loader/node_modules/loader-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", - "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", "dev": true, "peer": true, "dependencies": { @@ -9677,6 +9708,27 @@ "vue": "^2.5.0" } }, + "node_modules/vue2-leaflet": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.7.1.tgz", + "integrity": "sha512-K7HOlzRhjt3Z7+IvTqEavIBRbmCwSZSCVUlz9u4Rc+3xGCLsHKz4TAL4diAmfHElCQdPPVdZdJk8wPUt2fu6WQ==", + "peerDependencies": { + "@types/leaflet": "^1.5.7", + "leaflet": "^1.3.4", + "vue": "^2.5.17" + } + }, + "node_modules/vue2-leaflet-markercluster": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vue2-leaflet-markercluster/-/vue2-leaflet-markercluster-3.1.0.tgz", + "integrity": "sha512-v94tns6/PmBlFaPY2XvCUL69yUwRO43qYAP7T9hLmPDdu3Vgl15SDcC1qara8M56aNzWMPAcfP/uy4toUE5wcA==", + "dependencies": { + "leaflet.markercluster": "^1.4.1" + }, + "peerDependencies": { + "leaflet": "^1.3.4" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -12124,6 +12176,12 @@ "@types/range-parser": "*" } }, + "@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "peer": true + }, "@types/http-proxy": { "version": "1.17.9", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", @@ -12146,6 +12204,15 @@ "dev": true, "peer": true }, + "@types/leaflet": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz", + "integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==", + "peer": true, + "requires": { + "@types/geojson": "*" + } + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -15203,9 +15270,9 @@ "peer": true }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, "jsonfile": { @@ -15259,6 +15326,17 @@ "resolved": "https://registry.npmjs.org/layerr/-/layerr-0.1.2.tgz", "integrity": "sha512-ob5kTd9H3S4GOG2nVXyQhOu9O8nBgP555XxWPkJI0tR0JeRilfyTp8WtPdIJHLXBmHMSdEq5+KMxiYABeScsIQ==" }, + "leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" + }, + "leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "requires": {} + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -15285,9 +15363,9 @@ "peer": true }, "loader-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.3.tgz", - "integrity": "sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", "dev": true, "peer": true, "requires": { @@ -17760,9 +17838,9 @@ }, "dependencies": { "json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "peer": true, "requires": { @@ -17770,9 +17848,9 @@ } }, "loader-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", - "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", "dev": true, "peer": true, "requires": { @@ -17829,9 +17907,9 @@ }, "dependencies": { "json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "peer": true, "requires": { @@ -17839,9 +17917,9 @@ } }, "loader-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", - "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", "dev": true, "peer": true, "requires": { @@ -17896,6 +17974,20 @@ "date-format-parse": "^0.2.7" } }, + "vue2-leaflet": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.7.1.tgz", + "integrity": "sha512-K7HOlzRhjt3Z7+IvTqEavIBRbmCwSZSCVUlz9u4Rc+3xGCLsHKz4TAL4diAmfHElCQdPPVdZdJk8wPUt2fu6WQ==", + "requires": {} + }, + "vue2-leaflet-markercluster": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vue2-leaflet-markercluster/-/vue2-leaflet-markercluster-3.1.0.tgz", + "integrity": "sha512-v94tns6/PmBlFaPY2XvCUL69yUwRO43qYAP7T9hLmPDdu3Vgl15SDcC1qara8M56aNzWMPAcfP/uy4toUE5wcA==", + "requires": { + "leaflet.markercluster": "^1.4.1" + } + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index b512a87f..31705526 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "camelcase": "^7.0.1", "filerobot-image-editor": "^4.3.7", "justified-layout": "^4.1.0", + "leaflet": "^1.9.3", "moment": "^2.29.4", "path-posix": "^1.0.0", "photoswipe": "^5.3.4", @@ -47,6 +48,8 @@ "vue-material-design-icons": "^5.1.2", "vue-router": "^3.6.5", "vue-virtual-scroller": "1.1.2", + "vue2-leaflet": "^2.7.1", + "vue2-leaflet-markercluster": "^3.1.0", "webdav": "^4.11.2" }, "browserslist": [ diff --git a/src/App.vue b/src/App.vue index d652a22d..c1092d35 100644 --- a/src/App.vue +++ b/src/App.vue @@ -266,6 +266,11 @@ export default defineComponent({ title: t("memories", "Maps"), if: this.config_mapsEnabled, }, + { + name: "locations", + icon: MapIcon, + title: t("memories", "Locations"), + }, ]; }, diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 3f979da6..1ee38ad6 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -5,7 +5,7 @@ :class="{ 'icon-loading': loading > 0 }" > - + & any, /** Scroller manager component */ scrollerManager: null as InstanceType & any, + + /** The boundary of the map */ + mapBoundary: { + minLat: -90, + maxLat: 90, + minLng: -180, + maxLng: 180, + } as MapBoundary, }), mounted() { @@ -690,6 +706,14 @@ export default defineComponent({ query.set("reverse", "1"); } + // Geological Bounds + if (this.$route.name === "locations") { + query.set("minLat", "" + this.mapBoundary.minLat); + query.set("maxLat", "" + this.mapBoundary.maxLat); + query.set("minLng", "" + this.mapBoundary.minLng); + query.set("maxLng", "" + this.mapBoundary.maxLng); + } + return query; }, @@ -1290,6 +1314,11 @@ export default defineComponent({ this.processDay(day.dayid, newDetail); } }, + + async updateBoundary(mapBoundary: MapBoundary) { + this.mapBoundary = mapBoundary; + await this.softRefresh(); + }, }, }); diff --git a/src/components/top-matter/LocationTopMatter.vue b/src/components/top-matter/LocationTopMatter.vue new file mode 100644 index 00000000..1c3c505e --- /dev/null +++ b/src/components/top-matter/LocationTopMatter.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/components/top-matter/TopMatter.vue b/src/components/top-matter/TopMatter.vue index d6c32945..54362f6e 100644 --- a/src/components/top-matter/TopMatter.vue +++ b/src/components/top-matter/TopMatter.vue @@ -4,6 +4,7 @@ + @@ -14,6 +15,7 @@ import FolderTopMatter from "./FolderTopMatter.vue"; import TagTopMatter from "./TagTopMatter.vue"; import FaceTopMatter from "./FaceTopMatter.vue"; import AlbumTopMatter from "./AlbumTopMatter.vue"; +import LocationTopMatter from "./LocationTopMatter.vue"; import { TopMatterType } from "../../types"; @@ -24,6 +26,7 @@ export default defineComponent({ TagTopMatter, FaceTopMatter, AlbumTopMatter, + LocationTopMatter, }, data: () => ({ @@ -62,6 +65,8 @@ export default defineComponent({ return this.$route.params.name ? TopMatterType.TAG : TopMatterType.NONE; + case "locations": + return TopMatterType.LOCATION; default: return TopMatterType.NONE; } diff --git a/src/router.ts b/src/router.ts index 5dd80e02..f8c530c3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -138,5 +138,14 @@ export default new Router({ rootTitle: t("memories", "Shared Album"), }), }, + + { + path: "/locations", + component: Timeline, + name: "locations", + props: (route) => ({ + rootTitle: t("memories", "Locations"), + }) + }, ], }); diff --git a/src/services/API.ts b/src/services/API.ts index e16e1e82..32d53dc3 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -122,4 +122,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 01775c09..d00261c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -217,6 +217,7 @@ export enum TopMatterType { TAG = 2, FACE = 3, ALBUM = 4, + LOCATION = 5, } export type TopMatterFolder = TopMatter & { type: TopMatterType.FOLDER; @@ -238,3 +239,15 @@ export type ISelectionAction = { /** Allow for public routes (default false) */ allowPublic?: boolean; }; + +export type MapBoundary = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +} + +export type MarkerClusters = { + center: [number, number]; + count: number; +}