feat(web): map timeline sidepanel (#26532)

* feat(web): map timeline panel

* update openapi

* remove #key

* add index on lat/lng
This commit is contained in:
Michel Heusschen
2026-02-26 18:03:23 +01:00
committed by GitHub
parent e25ec4ec17
commit 771816f601
18 changed files with 540 additions and 99 deletions
@@ -1,19 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MapTimelinePanel from '$lib/components/shared-components/map/MapTimelinePanel.svelte';
import type { SelectionBBox } from '$lib/components/shared-components/map/types';
import { timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, untrack } from 'svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
interface Props {
@@ -24,7 +23,15 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let selectedClusterIds = $state.raw(new Set<string>());
let selectedClusterBBox = $state.raw<SelectionBBox>();
let isTimelinePanelVisible = $state(false);
function closeTimelinePanel() {
isTimelinePanelVisible = false;
selectedClusterBBox = undefined;
selectedClusterIds = new Set();
}
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
@@ -35,96 +42,58 @@
}
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
await setAssetId(assetIds[0]);
closeTimelinePanel();
}
async function navigateRandom() {
if (viewingAssets.length <= 0) {
return undefined;
}
const index = Math.floor(Math.random() * viewingAssets.length);
const asset = await setAssetId(viewingAssets[index]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset;
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
selectedClusterIds = new Set(assetIds);
selectedClusterBBox = bbox;
isTimelinePanelVisible = true;
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor < viewingAssets.length - 1) {
const id = viewingAssets[cursor + 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor <= 0) {
return;
}
const id = viewingAssets[cursor - 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
{#if featureFlagsManager.value.map}
<UserPageLayout title={data.meta.title}>
<div class="isolate h-full w-full">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
<div class="isolate flex h-full w-full flex-col sm:flex-row">
<div
class={[
'min-h-0',
isTimelinePanelVisible ? 'h-1/2 w-full pb-2 sm:h-full sm:w-2/3 sm:pe-2 sm:pb-0' : 'h-full w-full',
]}
>
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map hash onSelect={onViewAssets} {onClusterSelect} />
{/await}
{:then { default: Map }}
<Map hash onSelect={onViewAssets} />
{/await}
</div>
{#if isTimelinePanelVisible && selectedClusterBBox}
<div class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0">
<MapTimelinePanel
bbox={selectedClusterBBox}
{selectedClusterIds}
assetCount={selectedClusterIds.size}
onClose={closeTimelinePanel}
/>
</div>
{/if}
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer && assetCursor.current}
{#if $showAssetViewer}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={assetCursor}
showNavigation={viewingAssets.length > 1}
onRandom={navigateRandom}
cursor={{ current: $viewingAsset }}
showNavigation={false}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));