Skip to content

Commit 771816f

Browse files
feat(web): map timeline sidepanel (immich-app#26532)
* feat(web): map timeline panel * update openapi * remove #key * add index on lat/lng
1 parent e25ec4e commit 771816f

File tree

18 files changed

+540
-99
lines changed

18 files changed

+540
-99
lines changed

mobile/openapi/lib/api/timeline_api.dart

Lines changed: 24 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

open-api/immich-openapi-specs.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13492,6 +13492,16 @@
1349213492
"type": "string"
1349313493
}
1349413494
},
13495+
{
13496+
"name": "bbox",
13497+
"required": false,
13498+
"in": "query",
13499+
"description": "Bounding box coordinates as west,south,east,north (WGS84)",
13500+
"schema": {
13501+
"example": "11.075683,49.416711,11.117589,49.454875",
13502+
"type": "string"
13503+
}
13504+
},
1349513505
{
1349613506
"name": "isFavorite",
1349713507
"required": false,
@@ -13668,6 +13678,16 @@
1366813678
"type": "string"
1366913679
}
1367013680
},
13681+
{
13682+
"name": "bbox",
13683+
"required": false,
13684+
"in": "query",
13685+
"description": "Bounding box coordinates as west,south,east,north (WGS84)",
13686+
"schema": {
13687+
"example": "11.075683,49.416711,11.117589,49.454875",
13688+
"type": "string"
13689+
}
13690+
},
1367113691
{
1367213692
"name": "isFavorite",
1367313693
"required": false,

open-api/typescript-sdk/src/fetch-client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6421,8 +6421,9 @@ export function tagAssets({ id, bulkIdsDto }: {
64216421
/**
64226422
* Get time bucket
64236423
*/
6424-
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
6424+
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
64256425
albumId?: string;
6426+
bbox?: string;
64266427
isFavorite?: boolean;
64276428
isTrashed?: boolean;
64286429
key?: string;
@@ -6442,6 +6443,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
64426443
data: TimeBucketAssetResponseDto;
64436444
}>(`/timeline/bucket${QS.query(QS.explode({
64446445
albumId,
6446+
bbox,
64456447
isFavorite,
64466448
isTrashed,
64476449
key,
@@ -6462,8 +6464,9 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
64626464
/**
64636465
* Get time buckets
64646466
*/
6465-
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
6467+
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
64666468
albumId?: string;
6469+
bbox?: string;
64676470
isFavorite?: boolean;
64686471
isTrashed?: boolean;
64696472
key?: string;
@@ -6482,6 +6485,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
64826485
data: TimeBucketsResponseDto[];
64836486
}>(`/timeline/buckets${QS.query(QS.explode({
64846487
albumId,
6488+
bbox,
64856489
isFavorite,
64866490
isTrashed,
64876491
key,

server/src/dtos/bbox.dto.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsLatitude, IsLongitude } from 'class-validator';
3+
import { IsGreaterThanOrEqualTo } from 'src/validation';
4+
5+
export class BBoxDto {
6+
@ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' })
7+
@IsLongitude()
8+
west!: number;
9+
10+
@ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' })
11+
@IsLatitude()
12+
south!: number;
13+
14+
@ApiProperty({
15+
format: 'double',
16+
description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
17+
})
18+
@IsLongitude()
19+
east!: number;
20+
21+
@ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' })
22+
@IsLatitude()
23+
@IsGreaterThanOrEqualTo('south')
24+
north!: number;
25+
}

server/src/dtos/time-bucket.dto.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
32
import { IsString } from 'class-validator';
3+
import type { BBoxDto } from 'src/dtos/bbox.dto';
44
import { AssetOrder, AssetVisibility } from 'src/enum';
5+
import { ValidateBBox } from 'src/utils/bbox';
56
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
67

78
export class TimeBucketDto {
@@ -59,6 +60,9 @@ export class TimeBucketDto {
5960
description: 'Include location data in the response',
6061
})
6162
withCoordinates?: boolean;
63+
64+
@ValidateBBox({ optional: true })
65+
bbox?: BBoxDto;
6266
}
6367

6468
export class TimeBucketAssetDto extends TimeBucketDto {

server/src/repositories/asset.repository.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { Injectable } from '@nestjs/common';
2-
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
2+
import {
3+
ExpressionBuilder,
4+
Insertable,
5+
Kysely,
6+
NotNull,
7+
Selectable,
8+
SelectQueryBuilder,
9+
sql,
10+
Updateable,
11+
UpdateResult,
12+
} from 'kysely';
313
import { jsonArrayFrom } from 'kysely/helpers/postgres';
414
import { isEmpty, isUndefined, omitBy } from 'lodash';
515
import { InjectKysely } from 'nestjs-kysely';
@@ -36,6 +46,13 @@ import { globToSqlPattern } from 'src/utils/misc';
3646

3747
export type AssetStats = Record<AssetType, number>;
3848

49+
export interface BoundingBox {
50+
west: number;
51+
south: number;
52+
east: number;
53+
north: number;
54+
}
55+
3956
interface AssetStatsOptions {
4057
isFavorite?: boolean;
4158
isTrashed?: boolean;
@@ -64,6 +81,7 @@ interface AssetBuilderOptions {
6481
assetType?: AssetType;
6582
visibility?: AssetVisibility;
6683
withCoordinates?: boolean;
84+
bbox?: BoundingBox;
6785
}
6886

6987
export interface TimeBucketOptions extends AssetBuilderOptions {
@@ -120,6 +138,34 @@ interface GetByIdsRelations {
120138
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
121139
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
122140

141+
const getBoundingCircle = (bbox: BoundingBox) => {
142+
const { west, south, east, north } = bbox;
143+
const eastUnwrapped = west <= east ? east : east + 360;
144+
const centerLongitude = (((west + eastUnwrapped) / 2 + 540) % 360) - 180;
145+
const centerLatitude = (south + north) / 2;
146+
const radius = sql<number>`greatest(
147+
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${west})),
148+
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${east})),
149+
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${west})),
150+
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${east}))
151+
)`;
152+
153+
return { centerLatitude, centerLongitude, radius };
154+
};
155+
156+
const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T>, bbox: BoundingBox) => {
157+
const { west, south, east, north } = bbox;
158+
const withLatitude = qb.where('asset_exif.latitude', '>=', south).where('asset_exif.latitude', '<=', north);
159+
160+
if (west <= east) {
161+
return withLatitude.where('asset_exif.longitude', '>=', west).where('asset_exif.longitude', '<=', east);
162+
}
163+
164+
return withLatitude.where((eb) =>
165+
eb.or([eb('asset_exif.longitude', '>=', west), eb('asset_exif.longitude', '<=', east)]),
166+
);
167+
};
168+
123169
@Injectable()
124170
export class AssetRepository {
125171
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -651,6 +697,20 @@ export class AssetRepository {
651697
.select(truncatedDate<Date>().as('timeBucket'))
652698
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
653699
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
700+
.$if(!!options.bbox, (qb) => {
701+
const bbox = options.bbox!;
702+
const circle = getBoundingCircle(bbox);
703+
704+
const withBoundingCircle = qb
705+
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
706+
.where(
707+
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
708+
'@>',
709+
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
710+
);
711+
712+
return withBoundingBox(withBoundingCircle, bbox);
713+
})
654714
.$if(options.visibility === undefined, withDefaultVisibility)
655715
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
656716
.$if(!!options.albumId, (qb) =>
@@ -725,6 +785,18 @@ export class AssetRepository {
725785
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
726786
.$if(options.visibility == undefined, withDefaultVisibility)
727787
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
788+
.$if(!!options.bbox, (qb) => {
789+
const bbox = options.bbox!;
790+
const circle = getBoundingCircle(bbox);
791+
792+
const withBoundingCircle = qb.where(
793+
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
794+
'@>',
795+
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
796+
);
797+
798+
return withBoundingBox(withBoundingCircle, bbox);
799+
})
728800
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, ''))
729801
.$if(!!options.albumId, (qb) =>
730802
qb.where((eb) =>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Kysely, sql } from 'kysely';
2+
3+
export async function up(db: Kysely<any>): Promise<void> {
4+
await sql`CREATE INDEX "IDX_asset_exif_gist_earthcoord" ON "asset_exif" USING gist (ll_to_earth_public(latitude, longitude));`.execute(db);
5+
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_asset_exif_gist_earthcoord', '{"type":"index","name":"IDX_asset_exif_gist_earthcoord","sql":"CREATE INDEX \\"IDX_asset_exif_gist_earthcoord\\" ON \\"asset_exif\\" USING gist (ll_to_earth_public(latitude, longitude));"}'::jsonb);`.execute(db);
6+
}
7+
8+
export async function down(db: Kysely<any>): Promise<void> {
9+
await sql`DROP INDEX "IDX_asset_exif_gist_earthcoord";`.execute(db);
10+
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_asset_exif_gist_earthcoord';`.execute(db);
11+
}

server/src/schema/tables/asset-exif.table.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
1+
import {
2+
Column,
3+
ForeignKeyColumn,
4+
Generated,
5+
Index,
6+
Int8,
7+
Table,
8+
Timestamp,
9+
UpdateDateColumn,
10+
} from '@immich/sql-tools';
211
import { LockableProperty } from 'src/database';
312
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
413
import { AssetTable } from 'src/schema/tables/asset.table';
514

615
@Table('asset_exif')
16+
@Index({
17+
name: 'IDX_asset_exif_gist_earthcoord',
18+
using: 'gist',
19+
expression: 'll_to_earth_public(latitude, longitude)',
20+
})
721
@UpdatedAtTrigger('asset_exif_updatedAt')
822
export class AssetExifTable {
923
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })

server/src/services/timeline.service.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,24 @@ describe(TimelineService.name, () => {
2323
userIds: [authStub.admin.user.id],
2424
});
2525
});
26+
27+
it('should pass bbox options to repository when all bbox fields are provided', async () => {
28+
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
29+
30+
await sut.getTimeBuckets(authStub.admin, {
31+
bbox: {
32+
west: -70,
33+
south: -30,
34+
east: 120,
35+
north: 55,
36+
},
37+
});
38+
39+
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
40+
userIds: [authStub.admin.user.id],
41+
bbox: { west: -70, south: -30, east: 120, north: 55 },
42+
});
43+
});
2644
});
2745

2846
describe('getTimeBucket', () => {

0 commit comments

Comments
 (0)