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 @@
-
-
-
+
-
-
-
+
+
+
{{ cluster.count }}
+
+
+
+
+
@@ -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 }));
+ }
}