diff --git a/appinfo/routes.php b/appinfo/routes.php index 89b1f481..ea754ed7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -67,6 +67,7 @@ return [ ['name' => 'Places#preview', 'url' => '/api/places/preview/{id}', 'verb' => 'GET'], ['name' => 'Map#clusters', 'url' => '/api/map/clusters', 'verb' => 'GET'], + ['name' => 'Map#clusterPreview', 'url' => '/api/map/clusters/preview/{id}', 'verb' => 'GET'], ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], diff --git a/lib/Controller/ApiBase.php b/lib/Controller/ApiBase.php index 865c2d5b..4a73669c 100644 --- a/lib/Controller/ApiBase.php +++ b/lib/Controller/ApiBase.php @@ -297,7 +297,7 @@ class ApiBase extends Controller /** * Given a list of file ids, return the first preview image possible. */ - protected function getPreviewFromImageList(array &$list) + protected function getPreviewFromImageList(array &$list, int $quality=512) { // Get preview manager $previewManager = \OC::$server->get(\OCP\IPreview::class); @@ -318,7 +318,7 @@ class ApiBase extends Controller // Get preview image try { - $preview = $previewManager->getPreview($files[0], 512, 512, false); + $preview = $previewManager->getPreview($files[0], $quality, $quality, false); $response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [ 'Content-Type' => $preview->getMimeType(), ]); diff --git a/lib/Controller/MapController.php b/lib/Controller/MapController.php index 3470c1b9..5b2e791b 100644 --- a/lib/Controller/MapController.php +++ b/lib/Controller/MapController.php @@ -60,7 +60,7 @@ class MapController extends ApiBase $clusters = $this->timelineQuery->getMapClusters($gridLen, $bounds, $root); // Merge clusters that are close together - $distanceThreshold = $gridLen / 3; + $distanceThreshold = $gridLen / 2; $clusters = $this->mergeClusters($clusters, $distanceThreshold); return new JSONResponse($clusters); @@ -69,6 +69,39 @@ class MapController extends ApiBase } } + /** + * @NoAdminRequired + * + * @NoCSRFRequired + * + * Get preview for a cluster + */ + public function clusterPreview(int $id) + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // If this isn't the timeline folder then things aren't going to work + $root = $this->getRequestRoot(); + if ($root->isEmpty()) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // Run actual query + $list = $this->timelineQuery->getMapClusterPreviews($id, $root); + if (null === $list || 0 === \count($list)) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + shuffle($list); + + // Get preview from image list + return $this->getPreviewFromImageList(array_map(static function (&$item) { + return (int) $item['fileid']; + }, $list), 256); + } + private function mergeClusters($clusters, $distanceThreshold): array { $valid = array_fill(0, \count($clusters), true); @@ -117,7 +150,11 @@ class MapController extends ApiBase ($cluster1Count * $cluster1Center[0] + $cluster2Count * $cluster2Center[0]) / ($cluster1Count + $cluster2Count), ($cluster1Count * $cluster1Center[1] + $cluster2Count * $cluster2Center[1]) / ($cluster1Count + $cluster2Count), ]; - $clusters[] = ['center' => $newCenter, 'count' => $cluster1Count + $cluster2Count]; + $clusters[] = [ + 'id' => $clusters[$index1]['id'], + 'center' => $newCenter, + 'count' => $cluster1Count + $cluster2Count, + ]; $valid[] = true; $valid[$index1] = $valid[$index2] = false; } diff --git a/lib/Db/TimelineQueryMap.php b/lib/Db/TimelineQueryMap.php index 167f2506..dd9f460c 100644 --- a/lib/Db/TimelineQueryMap.php +++ b/lib/Db/TimelineQueryMap.php @@ -11,7 +11,7 @@ trait TimelineQueryMap { protected IDBConnection $connection; - public function transformMapBoundsFilter(IQueryBuilder &$query, string $userId, string $bounds) + public function transformMapBoundsFilter(IQueryBuilder &$query, string $userId, string $bounds, $table = 'm') { $bounds = explode(',', $bounds); $bounds = array_map('floatval', $bounds); @@ -19,12 +19,14 @@ trait TimelineQueryMap return; } + $latCol = $table.'.lat'; + $lonCol = $table.'.lon'; $query->andWhere( $query->expr()->andX( - $query->expr()->gte('m.lat', $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)), - $query->expr()->lte('m.lat', $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)), - $query->expr()->gte('m.lon', $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)), - $query->expr()->lte('m.lon', $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR)) + $query->expr()->gte($latCol, $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)), + $query->expr()->lte($latCol, $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)), + $query->expr()->gte($lonCol, $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)), + $query->expr()->lte($lonCol, $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR)) ) ); } @@ -37,22 +39,31 @@ trait TimelineQueryMap $query = $this->connection->getQueryBuilder(); // Get the average location of each cluster - $avgLat = $query->createFunction('AVG(m.lat) AS avgLat'); - $avgLng = $query->createFunction('AVG(m.lon) AS avgLon'); + $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($avgLat, $avgLng, $count) - ->from('memories', 'm') + + $query->select($lat, $lon, $count) + ->from('memories_map_clusters', 'c') ; + if ($gridLen > 0.2) { + // Coarse grouping + $query->addGroupBy($query->createFunction("CAST(c.lat / {$gridLen} AS INT)")); + $query->addGroupBy($query->createFunction("CAST(c.lon / {$gridLen} AS INT)")); + } else { + // Fine grouping + $query->addSelect('c.id')->groupBy('c.id'); + } + + // JOIN with memories for files from the current user + $query->innerJoin('c', 'memories', 'm', $query->expr()->eq('c.id', 'm.map_cluster_id')); + // JOIN with filecache for existing files $query = $this->joinFilecache($query, $root, true, false); - // Group by cluster - $query->addGroupBy($query->createFunction("m.lat DIV {$gridLen}")); - $query->addGroupBy($query->createFunction("m.lon DIV {$gridLen}")); - - // Apply all transformations (including map bounds) - $this->transformMapBoundsFilter($query, '', $bounds); + // Bound the query to the map bounds + $this->transformMapBoundsFilter($query, '', $bounds, 'c'); // Execute query $cursor = $this->executeQueryWithCTEs($query); @@ -61,17 +72,47 @@ trait TimelineQueryMap // Post-process results $clusters = []; - foreach ($res as $cluster) { - $clusters[] = - [ + foreach ($res as &$cluster) { + $c = [ 'center' => [ - (float) $cluster['avgLat'], - (float) $cluster['avgLon'], + (float) $cluster['lat'], + (float) $cluster['lon'], ], 'count' => (float) $cluster['count'], ]; + if (\array_key_exists('id', $cluster)) { + $c['id'] = (int) $cluster['id']; + } + $clusters[] = $c; } return $clusters; } + + public function getMapClusterPreviews(int $clusterId, TimelineRoot &$root) + { + $query = $this->connection->getQueryBuilder(); + + // SELECT all photos with this tag + $query->select('f.fileid', 'f.etag')->from('memories', 'm')->where( + $query->expr()->eq('m.map_cluster_id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)) + ); + + // WHERE these photos are in the user's requested folder recursively + $query = $this->joinFilecache($query, $root, true, false); + + // MAX 8 + $query->setMaxResults(8); + + // FETCH tag previews + $cursor = $this->executeQueryWithCTEs($query); + $ans = $cursor->fetchAll(); + + // Post-process + foreach ($ans as &$row) { + $row['fileid'] = (int) $row['fileid']; + } + + return $ans; + } } diff --git a/lib/Db/TimelineWrite.php b/lib/Db/TimelineWrite.php index 10cc011a..89edb592 100644 --- a/lib/Db/TimelineWrite.php +++ b/lib/Db/TimelineWrite.php @@ -18,6 +18,9 @@ const DELETE_TABLES = ['memories', 'memories_livephoto', 'memories_places']; class TimelineWrite { + use TimelineWriteMap; + use TimelineWriteOrphans; + use TimelineWritePlaces; protected IDBConnection $connection; protected IPreview $preview; protected LivePhoto $livePhoto; @@ -79,7 +82,7 @@ class TimelineWrite // Check if need to update $query = $this->connection->getQueryBuilder(); - $query->select('fileid', 'mtime') + $query->select('fileid', 'mtime', 'map_cluster_id') ->from('memories') ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) ; @@ -160,11 +163,13 @@ class TimelineWrite // Store location data $lat = null; $lon = null; + $mapCluster = $prevRow ? (int) $prevRow['map_cluster_id'] : -1; if (\array_key_exists('GPSLatitude', $exif) && \array_key_exists('GPSLongitude', $exif)) { try { $lat = (float) $exif['GPSLatitude']; $lon = (float) $exif['GPSLongitude']; - $this->updatePlacesData($file, $lat, $lon); + $mapCluster = $this->getMapCluster($fileId, $mapCluster, $lat, $lon); + $this->updatePlacesData($fileId, $lat, $lon); } catch (\Error $e) { $logger = \OC::$server->get(LoggerInterface::class); $logger->log(3, 'Error updating geo data: '.$e->getMessage(), ['app' => 'memories']); @@ -186,6 +191,7 @@ class TimelineWrite 'liveid' => $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR), 'lat' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR), 'lon' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR), + 'map_cluster_id' => $query->createNamedParameter($mapCluster, IQueryBuilder::PARAM_INT), ]; if ($prevRow) { @@ -239,103 +245,4 @@ class TimelineWrite $this->connection->executeStatement($p->getTruncateTableSQL('*PREFIX*'.$table, false)); } } - - /** - * Mark a file as not orphaned. - */ - public function unorphan(File &$file) - { - $query = $this->connection->getQueryBuilder(); - $query->update('memories') - ->set('orphan', $query->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) - ->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT))) - ; - $query->executeStatement(); - } - - /** - * Mark all files in the table as orphaned. - * - * @return int Number of rows affected - */ - public function orphanAll(): int - { - $query = $this->connection->getQueryBuilder(); - $query->update('memories') - ->set('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) - ; - - return $query->executeStatement(); - } - - /** - * Remove all entries that are orphans. - * - * @return int Number of rows affected - */ - public function removeOrphans(): int - { - $query = $this->connection->getQueryBuilder(); - $query->delete('memories') - ->where($query->expr()->eq('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) - ; - - return $query->executeStatement(); - } - - /** - * Add places data for a file. - */ - public function updatePlacesData(File &$file, float $lat, float $lon): void - { - // Get GIS type - $gisType = \OCA\Memories\Util::placesGISType(); - - // Construct WHERE clause depending on GIS type - $where = null; - if (1 === $gisType) { - $where = "ST_Contains(geometry, ST_GeomFromText('POINT({$lon} {$lat})'))"; - } elseif (2 === $gisType) { - $where = "POINT('{$lon},{$lat}') <@ geometry"; - } else { - return; - } - - // Make query to memories_planet table - $query = $this->connection->getQueryBuilder(); - $query->select($query->createFunction('DISTINCT(osm_id)')) - ->from('memories_planet_geometry') - ->where($query->createFunction($where)) - ; - - // Cancel out inner rings - $query->groupBy('poly_id', 'osm_id'); - $query->having($query->createFunction('SUM(type_id) > 0')); - - // memories_planet_geometry has no *PREFIX* - $sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL()); - - // Run query - $result = $this->connection->executeQuery($sql); - $rows = $result->fetchAll(); - - // Delete previous records - $query = $this->connection->getQueryBuilder(); - $query->delete('memories_places') - ->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT))) - ; - $query->executeStatement(); - - // Insert records - foreach ($rows as $row) { - $query = $this->connection->getQueryBuilder(); - $query->insert('memories_places') - ->values([ - 'fileid' => $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT), - 'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT), - ]) - ; - $query->executeStatement(); - } - } } diff --git a/lib/Db/TimelineWriteMap.php b/lib/Db/TimelineWriteMap.php new file mode 100644 index 00000000..93f04338 --- /dev/null +++ b/lib/Db/TimelineWriteMap.php @@ -0,0 +1,114 @@ +connection->getQueryBuilder(); + $query->select('id') + ->from('memories_map_clusters') + ->andWhere($query->expr()->gte('lat', $query->createNamedParameter($lat - 0.0003, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->lte('lat', $query->createNamedParameter($lat + 0.0003, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->gte('lon', $query->createNamedParameter($lon - 0.0003, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->lte('lon', $query->createNamedParameter($lon + 0.0003, IQueryBuilder::PARAM_STR))) + ; + + $result = $query->executeQuery(); + $rows = $result->fetchAll(); + + // Find cluster closest to the point + $minDist = 999999999; + $minId = -1; + foreach ($rows as $r) { + $clusterLat = (float) $r['lat']; + $clusterLon = (float) $r['lon']; + $dist = ($lat - $clusterLat) ** 2 + ($lon - $clusterLon) ** 2; + if ($dist < $minDist) { + $minDist = $dist; + $minId = $r['id']; + } + } + + // If no cluster found, create a new one + if ($minId <= 0) { + $this->removeFromCluster($prevCluster, $lat, $lon); + + return $this->createMapCluster($lat, $lon); + } + + // If the file was previously in the same cluster, return that + if ($prevCluster === $minId) { + return $minId; + } + + // 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); + + return $minId; + } + + protected function createMapCluster(float $lat, float $lon): int + { + $query = $this->connection->getQueryBuilder(); + $query->insert('memories_map_clusters') + ->values([ + '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), + ]) + ; + $query->executeStatement(); + + return (int) $query->getLastInsertId(); + } + + protected function removeFromCluster(int $clusterId, float $lat, float $lon): void + { + if ($clusterId <= 0) { + return; + } + + $query = $this->connection->getQueryBuilder(); + $query->update('memories_map_clusters') + ->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')) + ->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT))) + ; + $query->executeStatement(); + } + + protected function addToCluster(int $clusterId, float $lat, float $lon): void + { + if ($clusterId <= 0) { + return; + } + + $query = $this->connection->getQueryBuilder(); + $query->update('memories_map_clusters') + ->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')) + ->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT))) + ; + $query->executeStatement(); + } +} diff --git a/lib/Db/TimelineWriteOrphans.php b/lib/Db/TimelineWriteOrphans.php new file mode 100644 index 00000000..1d1980f0 --- /dev/null +++ b/lib/Db/TimelineWriteOrphans.php @@ -0,0 +1,57 @@ +connection->getQueryBuilder(); + $query->update('memories') + ->set('orphan', $query->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT))) + ; + $query->executeStatement(); + } + + /** + * Mark all files in the table as orphaned. + * + * @return int Number of rows affected + */ + public function orphanAll(): int + { + $query = $this->connection->getQueryBuilder(); + $query->update('memories') + ->set('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) + ; + + return $query->executeStatement(); + } + + /** + * Remove all entries that are orphans. + * + * @return int Number of rows affected + */ + public function removeOrphans(): int + { + $query = $this->connection->getQueryBuilder(); + $query->delete('memories') + ->where($query->expr()->eq('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ; + + return $query->executeStatement(); + } +} diff --git a/lib/Db/TimelineWritePlaces.php b/lib/Db/TimelineWritePlaces.php new file mode 100644 index 00000000..25c68358 --- /dev/null +++ b/lib/Db/TimelineWritePlaces.php @@ -0,0 +1,69 @@ +connection->getQueryBuilder(); + $query->select($query->createFunction('DISTINCT(osm_id)')) + ->from('memories_planet_geometry') + ->where($query->createFunction($where)) + ; + + // Cancel out inner rings + $query->groupBy('poly_id', 'osm_id'); + $query->having($query->createFunction('SUM(type_id) > 0')); + + // memories_planet_geometry has no *PREFIX* + $sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL()); + + // Run query + $result = $this->connection->executeQuery($sql); + $rows = $result->fetchAll(); + + // Delete previous records + $query = $this->connection->getQueryBuilder(); + $query->delete('memories_places') + ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) + ; + $query->executeStatement(); + + // Insert records + foreach ($rows as $row) { + $query = $this->connection->getQueryBuilder(); + $query->insert('memories_places') + ->values([ + 'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), + 'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT), + ]) + ; + $query->executeStatement(); + } + } +} diff --git a/src/components/top-matter/MapSplitMatter.vue b/src/components/top-matter/MapSplitMatter.vue index 530c8490..41cbac66 100644 --- a/src/components/top-matter/MapSplitMatter.vue +++ b/src/components/top-matter/MapSplitMatter.vue @@ -1,6 +1,6 @@ @@ -117,4 +128,42 @@ export default defineComponent({ margin: 0; z-index: 0; } + +.preview { + width: 48px; + height: 48px; + background-color: #fff; + border-radius: 5px; + position: relative; + transition: transform 0.2s; + + &:hover { + transform: scale(1.8); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 5px; + } + + .count { + position: absolute; + top: 0; + right: 0; + background-color: var(--color-primary-default); + color: var(--color-primary-text); + padding: 0 4px; + border-radius: 5px; + font-size: 0.8em; + } +} + + + diff --git a/src/services/API.ts b/src/services/API.ts index 40f8d9a0..c110d8df 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -126,4 +126,8 @@ export class API { static MAP_CLUSTERS() { return tok(gen(`${BASE}/map/clusters`)); } + + static MAP_CLUSTER_PREVIEW(id: number) { + return tok(gen(`${BASE}/map/clusters/preview/{id}`, { id })); + } }