From 771816f6014310ca943956c50c94aa74d40ddb11 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:03:23 +0100 Subject: [PATCH] feat(web): map timeline sidepanel (#26532) * feat(web): map timeline panel * update openapi * remove #key * add index on lat/lng --- mobile/openapi/lib/api/timeline_api.dart | 30 ++- open-api/immich-openapi-specs.json | 20 ++ open-api/typescript-sdk/src/fetch-client.ts | 8 +- server/src/dtos/bbox.dto.ts | 25 +++ server/src/dtos/time-bucket.dto.ts | 6 +- server/src/repositories/asset.repository.ts | 74 ++++++- ...772121424533-AddAssetExifGistEarthcoord.ts | 11 + server/src/schema/tables/asset-exif.table.ts | 16 +- server/src/services/timeline.service.spec.ts | 18 ++ server/src/utils/bbox.ts | 32 +++ server/src/validation.ts | 22 ++ .../map/MapTimelinePanel.svelte | 189 ++++++++++++++++++ .../shared-components/map/map.svelte | 28 ++- .../components/shared-components/map/types.ts | 6 + .../timeline-manager/month-group.svelte.ts | 5 + .../timeline-manager.svelte.ts | 17 +- .../lib/managers/timeline-manager/types.ts | 1 + .../[[assetId=id]]/+page.svelte | 131 +++++------- 18 files changed, 540 insertions(+), 99 deletions(-) create mode 100644 server/src/dtos/bbox.dto.ts create mode 100644 server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts create mode 100644 server/src/utils/bbox.ts create mode 100644 web/src/lib/components/shared-components/map/MapTimelinePanel.svelte create mode 100644 web/src/lib/components/shared-components/map/types.ts diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 2afcea20ff..f82c362ff7 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -30,6 +30,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -63,7 +66,7 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -77,6 +80,9 @@ class TimelineApi { if (albumId != null) { queryParams.addAll(_queryParams('', 'albumId', albumId)); } + if (bbox != null) { + queryParams.addAll(_queryParams('', 'bbox', bbox)); + } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } @@ -141,6 +147,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -174,8 +183,8 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -200,6 +209,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -233,7 +245,7 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -247,6 +259,9 @@ class TimelineApi { if (albumId != null) { queryParams.addAll(_queryParams('', 'albumId', albumId)); } + if (bbox != null) { + queryParams.addAll(_queryParams('', 'bbox', bbox)); + } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } @@ -307,6 +322,9 @@ class TimelineApi { /// * [String] albumId: /// Filter assets belonging to a specific album /// + /// * [String] bbox: + /// Bounding box coordinates as west,south,east,north (WGS84) + /// /// * [bool] isFavorite: /// Filter by favorite status (true for favorites only, false for non-favorites only) /// @@ -340,8 +358,8 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fecd8933a8..38e1fe8e01 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13492,6 +13492,16 @@ "type": "string" } }, + { + "name": "bbox", + "required": false, + "in": "query", + "description": "Bounding box coordinates as west,south,east,north (WGS84)", + "schema": { + "example": "11.075683,49.416711,11.117589,49.454875", + "type": "string" + } + }, { "name": "isFavorite", "required": false, @@ -13668,6 +13678,16 @@ "type": "string" } }, + { + "name": "bbox", + "required": false, + "in": "query", + "description": "Bounding box coordinates as west,south,east,north (WGS84)", + "schema": { + "example": "11.075683,49.416711,11.117589,49.454875", + "type": "string" + } + }, { "name": "isFavorite", "required": false, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fd07ce01a7..1ae12cd091 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -6421,8 +6421,9 @@ export function tagAssets({ id, bulkIdsDto }: { /** * Get time bucket */ -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; + bbox?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; @@ -6442,6 +6443,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers data: TimeBucketAssetResponseDto; }>(`/timeline/bucket${QS.query(QS.explode({ albumId, + bbox, isFavorite, isTrashed, key, @@ -6462,8 +6464,9 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers /** * Get time buckets */ -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; + bbox?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; @@ -6482,6 +6485,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per data: TimeBucketsResponseDto[]; }>(`/timeline/buckets${QS.query(QS.explode({ albumId, + bbox, isFavorite, isTrashed, key, diff --git a/server/src/dtos/bbox.dto.ts b/server/src/dtos/bbox.dto.ts new file mode 100644 index 0000000000..1afe9f53ba --- /dev/null +++ b/server/src/dtos/bbox.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsLatitude, IsLongitude } from 'class-validator'; +import { IsGreaterThanOrEqualTo } from 'src/validation'; + +export class BBoxDto { + @ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' }) + @IsLongitude() + west!: number; + + @ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' }) + @IsLatitude() + south!: number; + + @ApiProperty({ + format: 'double', + description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.', + }) + @IsLongitude() + east!: number; + + @ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' }) + @IsLatitude() + @IsGreaterThanOrEqualTo('south') + north!: number; +} diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index dfd474d885..9ea9dc49ae 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; - import { IsString } from 'class-validator'; +import type { BBoxDto } from 'src/dtos/bbox.dto'; import { AssetOrder, AssetVisibility } from 'src/enum'; +import { ValidateBBox } from 'src/utils/bbox'; import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @@ -59,6 +60,9 @@ export class TimeBucketDto { description: 'Include location data in the response', }) withCoordinates?: boolean; + + @ValidateBBox({ optional: true }) + bbox?: BBoxDto; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b58d852707..842b917670 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; +import { + ExpressionBuilder, + Insertable, + Kysely, + NotNull, + Selectable, + SelectQueryBuilder, + sql, + Updateable, + UpdateResult, +} from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; @@ -36,6 +46,13 @@ import { globToSqlPattern } from 'src/utils/misc'; export type AssetStats = Record; +export interface BoundingBox { + west: number; + south: number; + east: number; + north: number; +} + interface AssetStatsOptions { isFavorite?: boolean; isTrashed?: boolean; @@ -64,6 +81,7 @@ interface AssetBuilderOptions { assetType?: AssetType; visibility?: AssetVisibility; withCoordinates?: boolean; + bbox?: BoundingBox; } export interface TimeBucketOptions extends AssetBuilderOptions { @@ -120,6 +138,34 @@ interface GetByIdsRelations { const distinctLocked = (eb: ExpressionBuilder, columns: T) => sql`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`; +const getBoundingCircle = (bbox: BoundingBox) => { + const { west, south, east, north } = bbox; + const eastUnwrapped = west <= east ? east : east + 360; + const centerLongitude = (((west + eastUnwrapped) / 2 + 540) % 360) - 180; + const centerLatitude = (south + north) / 2; + const radius = sql`greatest( + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${west})), + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${east})), + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${west})), + earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${east})) + )`; + + return { centerLatitude, centerLongitude, radius }; +}; + +const withBoundingBox = (qb: SelectQueryBuilder, bbox: BoundingBox) => { + const { west, south, east, north } = bbox; + const withLatitude = qb.where('asset_exif.latitude', '>=', south).where('asset_exif.latitude', '<=', north); + + if (west <= east) { + return withLatitude.where('asset_exif.longitude', '>=', west).where('asset_exif.longitude', '<=', east); + } + + return withLatitude.where((eb) => + eb.or([eb('asset_exif.longitude', '>=', west), eb('asset_exif.longitude', '<=', east)]), + ); +}; + @Injectable() export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -651,6 +697,20 @@ export class AssetRepository { .select(truncatedDate().as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .$if(!!options.bbox, (qb) => { + const bbox = options.bbox!; + const circle = getBoundingCircle(bbox); + + const withBoundingCircle = qb + .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .where( + sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`, + '@>', + sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`, + ); + + return withBoundingBox(withBoundingCircle, bbox); + }) .$if(options.visibility === undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) .$if(!!options.albumId, (qb) => @@ -725,6 +785,18 @@ export class AssetRepository { .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility == undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) + .$if(!!options.bbox, (qb) => { + const bbox = options.bbox!; + const circle = getBoundingCircle(bbox); + + const withBoundingCircle = qb.where( + sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`, + '@>', + sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`, + ); + + return withBoundingBox(withBoundingCircle, bbox); + }) .where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, '')) .$if(!!options.albumId, (qb) => qb.where((eb) => diff --git a/server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts b/server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts new file mode 100644 index 0000000000..f86529142d --- /dev/null +++ b/server/src/schema/migrations/1772121424533-AddAssetExifGistEarthcoord.ts @@ -0,0 +1,11 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX "IDX_asset_exif_gist_earthcoord" ON "asset_exif" USING gist (ll_to_earth_public(latitude, longitude));`.execute(db); + 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); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_asset_exif_gist_earthcoord";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_asset_exif_gist_earthcoord';`.execute(db); +} diff --git a/server/src/schema/tables/asset-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index 1ae8f731a9..ae47ecfb10 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -1,9 +1,23 @@ -import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools'; +import { + Column, + ForeignKeyColumn, + Generated, + Index, + Int8, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; import { LockableProperty } from 'src/database'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_exif') +@Index({ + name: 'IDX_asset_exif_gist_earthcoord', + using: 'gist', + expression: 'll_to_earth_public(latitude, longitude)', +}) @UpdatedAtTrigger('asset_exif_updatedAt') export class AssetExifTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 3301e61318..4f447f6c3d 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -23,6 +23,24 @@ describe(TimelineService.name, () => { userIds: [authStub.admin.user.id], }); }); + + it('should pass bbox options to repository when all bbox fields are provided', async () => { + mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + + await sut.getTimeBuckets(authStub.admin, { + bbox: { + west: -70, + south: -30, + east: 120, + north: 55, + }, + }); + + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ + userIds: [authStub.admin.user.id], + bbox: { west: -70, south: -30, east: 120, north: 55 }, + }); + }); }); describe('getTimeBucket', () => { diff --git a/server/src/utils/bbox.ts b/server/src/utils/bbox.ts new file mode 100644 index 0000000000..ad02e8355e --- /dev/null +++ b/server/src/utils/bbox.ts @@ -0,0 +1,32 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiPropertyOptions } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsNotEmpty, ValidateNested } from 'class-validator'; +import { Property } from 'src/decorators'; +import { BBoxDto } from 'src/dtos/bbox.dto'; +import { Optional } from 'src/validation'; + +type BBoxOptions = { optional?: boolean }; +export const ValidateBBox = (options: BBoxOptions & ApiPropertyOptions = {}) => { + const { optional, ...apiPropertyOptions } = options; + + return applyDecorators( + Transform(({ value }) => { + if (typeof value !== 'string') { + return value; + } + + const [west, south, east, north] = value.split(',', 4).map(Number); + return Object.assign(new BBoxDto(), { west, south, east, north }); + }), + Type(() => BBoxDto), + ValidateNested(), + Property({ + type: 'string', + description: 'Bounding box coordinates as west,south,east,north (WGS84)', + example: '11.075683,49.416711,11.117589,49.454875', + ...apiPropertyOptions, + }), + optional ? Optional({}) : IsNotEmpty(), + ); +}; diff --git a/server/src/validation.ts b/server/src/validation.ts index ce7ceb602f..b959de94b1 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -427,3 +427,25 @@ export function IsIPRange(options: IsIPRangeOptions, validationOptions?: Validat validationOptions, ); } + +@ValidatorConstraint({ name: 'isGreaterThanOrEqualTo' }) +export class IsGreaterThanOrEqualToConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments) { + const relatedPropertyName = args.constraints?.[0] as string; + const relatedValue = (args.object as Record)[relatedPropertyName]; + if (!Number.isFinite(value) || !Number.isFinite(relatedValue)) { + return true; + } + + return Number(value) >= Number(relatedValue); + } + + defaultMessage(args: ValidationArguments) { + const relatedPropertyName = args.constraints?.[0] as string; + return `${args.property} must be greater than or equal to ${relatedPropertyName}`; + } +} + +export const IsGreaterThanOrEqualTo = (property: string, validationOptions?: ValidationOptions) => { + return Validate(IsGreaterThanOrEqualToConstraint, [property], validationOptions); +}; diff --git a/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte b/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte new file mode 100644 index 0000000000..b4f0dfb74d --- /dev/null +++ b/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte @@ -0,0 +1,189 @@ + + + + +{#if assetInteraction.selectionActive} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + + + + assetInteraction.clearMultiselect()} + > + + + + + {#if isAllUserOwned} + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} + /> + + + + {#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected} + updateStackedAssetInTimeline(timelineManager, result)} + onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)} + /> + {/if} + {#if isLinkActionAvailable} + + {/if} + + + + timelineManager.update(ids, (asset) => (asset.visibility = visibility))} + /> + {#if $preferences.tags.enabled} + + {/if} + timelineManager.removeAssets(assetIds)} + onUndoDelete={(assets) => timelineManager.upsertAssets(assets)} + /> + +
+ + + +
+ {:else} + + {/if} +
+
+{/if} diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 0b19306d6e..a73d6b040b 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -49,6 +49,7 @@ Popup, ScaleControl, } from 'svelte-maplibre'; + import type { SelectionBBox } from './types'; interface Props { mapMarkers?: MapMarkerResponseDto[]; @@ -61,6 +62,7 @@ useLocationPin?: boolean; onOpenInMapView?: (() => Promise | void) | undefined; onSelect?: (assetIds: string[]) => void; + onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void; onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void; popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>; rounded?: boolean; @@ -79,6 +81,7 @@ useLocationPin = false, onOpenInMapView = undefined, onSelect = () => {}, + onClusterSelect, onClickPoint = () => {}, popup, rounded = false, @@ -131,9 +134,30 @@ return; } - const mapSource = map?.getSource('geojson') as GeoJSONSource; + const mapSource = map.getSource('geojson') as GeoJSONSource; const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0); - const ids = leaves.map((leaf) => leaf.properties?.id); + const ids = leaves.map((leaf) => leaf.properties?.id as string); + + if (onClusterSelect && ids.length > 1) { + const [firstLongitude, firstLatitude] = (leaves[0].geometry as Point).coordinates; + let west = firstLongitude; + let south = firstLatitude; + let east = firstLongitude; + let north = firstLatitude; + + for (const leaf of leaves.slice(1)) { + const [longitude, latitude] = (leaf.geometry as Point).coordinates; + west = Math.min(west, longitude); + south = Math.min(south, latitude); + east = Math.max(east, longitude); + north = Math.max(north, latitude); + } + + const bbox = { west, south, east, north }; + onClusterSelect(ids, bbox); + return; + } + onSelect(ids); } diff --git a/web/src/lib/components/shared-components/map/types.ts b/web/src/lib/components/shared-components/map/types.ts new file mode 100644 index 0000000000..9a2f5d534a --- /dev/null +++ b/web/src/lib/components/shared-components/map/types.ts @@ -0,0 +1,6 @@ +export type SelectionBBox = { + west: number; + south: number; + east: number; + north: number; +}; diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 3926055cca..3b3860eb9c 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -196,6 +196,11 @@ export class MonthGroup { timelineAsset.latitude = bucketAssets.latitude?.[i]; timelineAsset.longitude = bucketAssets.longitude?.[i]; } + + if (this.timelineManager.isExcluded(timelineAsset)) { + continue; + } + this.addTimelineAsset(timelineAsset, addContext); } if (preSorted) { diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 24365f41e5..019290a5c9 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -258,10 +258,16 @@ export class TimelineManager extends VirtualScrollManager { if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) { return; } - await this.initTask.reset(); - await this.#init(options); - this.updateViewportGeometry(false); - this.#createScrubberMonths(); + + this.suspendTransitions = true; + try { + await this.initTask.reset(); + await this.#init(options); + this.updateViewportGeometry(false); + this.#createScrubberMonths(); + } finally { + this.suspendTransitions = false; + } } async #init(options: TimelineManagerOptions) { @@ -589,7 +595,8 @@ export class TimelineManager extends VirtualScrollManager { return ( isMismatched(this.#options.visibility, asset.visibility) || isMismatched(this.#options.isFavorite, asset.isFavorite) || - isMismatched(this.#options.isTrashed, asset.isTrashed) + isMismatched(this.#options.isTrashed, asset.isTrashed) || + (this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id)) ); } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 35d7178f97..d528bfbdff 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -8,6 +8,7 @@ export type AssetApiGetTimeBucketsRequest = Parameters & { timelineAlbumId?: string; deferInit?: boolean; + assetFilter?: Set; }; export type AssetDescriptor = { id: string }; diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 011f08b787..69d18d1bd5 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,19 +1,18 @@ {#if featureFlagsManager.value.map} -
- {#await import('$lib/components/shared-components/map/map.svelte')} - {#await delay(timeToLoadTheMap) then} - -
- -
+
+
+ {#await import('$lib/components/shared-components/map/map.svelte')} + {#await delay(timeToLoadTheMap) then} + +
+ +
+ {/await} + {:then { default: Map }} + {/await} - {:then { default: Map }} - - {/await} +
+ + {#if isTimelinePanelVisible && selectedClusterBBox} +
+ +
+ {/if}
- {#if $showAssetViewer && assetCursor.current} + {#if $showAssetViewer} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} - onRandom={navigateRandom} + cursor={{ current: $viewingAsset }} + showNavigation={false} onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));