pull/28676/merge
Miguel Raposo 2026-06-03 14:43:09 +01:00 committed by GitHub
commit d3ca5e476e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1861 additions and 286 deletions

View File

@ -0,0 +1,32 @@
import type { MapMarkerResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { TimelineData } from 'src/ui/generators/timeline';
export const setupMapMockApiRoutes = async (context: BrowserContext, timelineData: TimelineData) => {
await context.route('**/api/map/markers', async (route) => {
const markers: MapMarkerResponseDto[] = [];
for (const bucket of timelineData.buckets.values()) {
for (const asset of bucket) {
// Only include assets with GPS coordinates
if (asset.latitude !== null && asset.longitude !== null) {
markers.push({
id: asset.id,
lat: asset.latitude,
lon: asset.longitude,
city: asset.city,
state: null,
country: asset.country,
});
}
}
}
markers.sort((a, b) => a.id.localeCompare(b.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
json: markers,
});
});
};

View File

@ -0,0 +1,166 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import { createDefaultTimelineConfig, generateTimelineData, TimelineData } from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupMapMockApiRoutes } from 'src/ui/mock-network/map-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { utils } from 'src/utils';
import { mapUtils } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Map - Cluster Auto-Zoom', () => {
let adminUserId: string;
let mapTestData: TimelineData;
const testContext = new TimelineTestContext();
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
// Generate timeline data with GPS coordinates
mapTestData = generateTimelineData({
...createDefaultTimelineConfig(),
ownerId: adminUserId,
});
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupMapMockApiRoutes(context, mapTestData);
await setupTimelineMockApiRoutes(
context,
mapTestData,
{ albumAdditions: [], assetDeletions: [], assetArchivals: [], assetFavorites: [] },
testContext,
);
});
test('/map page loads with cluster markers', async ({ page }) => {
await mapUtils.navigateToMap(page);
await mapUtils.expectMapVisible(page);
await mapUtils.expectClustersVisible(page);
});
test('cluster shows point count', async ({ page }) => {
await mapUtils.navigateToMap(page);
const firstCluster = mapUtils.getFirstCluster(page);
await expect(firstCluster).toBeVisible();
const count = await mapUtils.getClusterCount(page, firstCluster);
expect(count).toBeGreaterThan(0);
});
test('clicking cluster triggers map interaction', async ({ page }) => {
await mapUtils.navigateToMap(page);
const firstCluster = mapUtils.getFirstCluster(page);
await expect(firstCluster).toBeVisible();
// Click cluster
await mapUtils.clickCluster(page, firstCluster);
await mapUtils.expectMapVisible(page);
});
test('cluster count is positive number', async ({ page }) => {
await mapUtils.navigateToMap(page);
const clusters = mapUtils.getClusters(page);
const clusterCount = await clusters.count();
if (clusterCount > 0) {
for (let i = 0; i < clusterCount; i++) {
const cluster = clusters.nth(i);
const count = await mapUtils.getClusterCount(page, cluster);
expect(count).toBeGreaterThan(0);
}
}
});
test('map has zoom controls visible and functional', async ({ page }) => {
await mapUtils.navigateToMap(page);
await mapUtils.expectMapControlsVisible(page);
await mapUtils.zoomIn(page);
await mapUtils.expectMapVisible(page);
await mapUtils.zoomOut(page);
await mapUtils.expectMapVisible(page);
});
test('map has settings button', async ({ page }) => {
await mapUtils.navigateToMap(page);
await expect(mapUtils.getSettingsButton(page)).toBeVisible();
});
test('map fetches and displays markers from API', async ({ page }) => {
const markersPromise = mapUtils.waitForMarkersAPI(page);
await mapUtils.navigateToMap(page);
const response = await markersPromise;
const data = await response.json();
expect(Array.isArray(data)).toBe(true);
if (data.length > 0) {
const marker = data[0];
expect(marker).toHaveProperty('id');
expect(marker).toHaveProperty('lat');
expect(marker).toHaveProperty('lon');
}
});
test('map renders without console errors', async ({ page }) => {
await mapUtils.navigateToMap(page);
const errors = await mapUtils.captureConsoleErrors(page, async () => {
await mapUtils.expectMapVisible(page);
await page.waitForTimeout(500);
});
expect(errors).toHaveLength(0);
});
test('mobile viewport displays all controls', async ({ page }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await mapUtils.navigateToMap(page);
await mapUtils.expectMapVisible(page);
// Timeline toggle button should be visible on mobile
await expect(mapUtils.getTimelineButton(page)).toBeVisible();
});
test('multiple clusters can be clicked sequentially', async ({ page }) => {
await mapUtils.navigateToMap(page);
const clusterCount = await mapUtils.getClusters(page).count();
const clickCount = Math.min(2, clusterCount);
for (let i = 0; i < clickCount; i++) {
const cluster = mapUtils.getFirstCluster(page);
await mapUtils.clickCluster(page, cluster);
await mapUtils.expectMapVisible(page);
}
});
test('cluster button text contains numeric count', async ({ page }) => {
await mapUtils.navigateToMap(page);
const clusters = mapUtils.getClusters(page);
const clusterCount = await clusters.count();
if (clusterCount > 0) {
const firstCluster = clusters.first();
const text = await firstCluster.textContent();
expect(text).toMatch(/\d+/);
}
});
});

View File

@ -0,0 +1,141 @@
import { ConsoleMessage, expect, Locator, Page } from '@playwright/test';
/**
* Map testing utilities for e2e tests
*/
export const mapUtils = {
/**
* Get all visible cluster on the map
*/
getClusters(page: Page) {
return page.locator('[class*="rounded-full"][class*="bg-immich-primary"]').filter({ hasText: /\d+/ });
},
/**
* Get the first visible cluster button
*/
getFirstCluster(page: Page) {
return this.getClusters(page).first();
},
/**
* Get asset count of a cluster
*/
async getClusterCount(page: Page, clusterElement?: Locator) {
const element = clusterElement || this.getFirstCluster(page);
await expect(element).toBeVisible();
await expect(element).toHaveText(/\d+/);
const text = await element.textContent();
return Number.parseInt((text ?? '').replaceAll(/[^\d]/g, ''), 10);
},
/**
* Click on a cluster
*/
async clickCluster(page: Page, clusterElement?: Locator, waitMs = 1500) {
const element = clusterElement || this.getFirstCluster(page);
await element.scrollIntoViewIfNeeded();
await element.click({ force: true });
await page.waitForTimeout(waitMs);
},
/**
* Verify map is visible and loaded
*/
async expectMapVisible(page: Page) {
const mapContainer = page.locator('.rounded-none.h-full');
await expect(mapContainer).toBeVisible();
},
/**
* Verify clusters exist on map
*/
async expectClustersVisible(page: Page, minCount = 1) {
const clusters = this.getClusters(page);
const count = await clusters.count();
expect(count).toBeGreaterThanOrEqual(minCount);
},
/**
* Get map control buttons
*/
getZoomInButton(page: Page) {
return page.getByLabel(/zoom in/i);
},
getZoomOutButton(page: Page) {
return page.getByLabel(/zoom out/i);
},
getSettingsButton(page: Page) {
return page.getByLabel(/map settings/i);
},
getTimelineButton(page: Page) {
return page.getByLabel(/timeline/i);
},
/**
* Verify all standard map controls are visible
*/
async expectMapControlsVisible(page: Page) {
await expect(this.getZoomInButton(page)).toBeVisible();
await expect(this.getZoomOutButton(page)).toBeVisible();
await expect(this.getSettingsButton(page)).toBeVisible();
},
/**
* Click zoom in button
*/
async zoomIn(page: Page) {
await this.getZoomInButton(page).click();
await page.waitForTimeout(500);
},
/**
* Click zoom out button
*/
async zoomOut(page: Page) {
await this.getZoomOutButton(page).click();
await page.waitForTimeout(500);
},
/**
* Wait for markers API to respond
*/
async waitForMarkersAPI(page: Page) {
return page.waitForResponse((response) => response.url().includes('/api/map/markers') && response.status() === 200);
},
/**
* Navigate to map and wait for load
*/
async navigateToMap(page: Page) {
await page.goto('/map');
await page.waitForLoadState('networkidle');
},
/**
* Check if map has any errors
*/
async captureConsoleErrors(page: Page, callback: () => Promise<void>) {
const errors: string[] = [];
const handler = (msg: ConsoleMessage) => {
if (msg.type() === 'error') {
const text = msg.text();
// Ignore expected MapLibre external styles 401 in ci/cd
if (text.includes('401 (Unauthorized)')) {
return;
}
errors.push(text);
}
};
page.on('console', handler);
await callback();
page.off('console', handler);
return errors;
},
};

View File

@ -1195,6 +1195,7 @@
"exif_bottom_sheet_no_description": "No description",
"exif_bottom_sheet_people": "PEOPLE",
"exif_bottom_sheet_person_add_person": "Add name",
"exit_fullscreen": "Exit fullscreen",
"exit_slideshow": "Exit Slideshow",
"expand": "Expand",
"expand_all": "Expand all",
@ -1259,9 +1260,11 @@
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}",
"full_path_or_folder": "Full path or folder",
"fullscreen": "Fullscreen",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General",
"geolocate": "Geolocate",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help",
"get_people_error": "Error getting people",
@ -2289,6 +2292,8 @@
"support_third_party_description": "Your Immich installation was packaged by a third-party. Issues you experience may be caused by that package, so please raise issues with them in the first instance using the links below.",
"supporter": "Supporter",
"swap_merge_direction": "Swap merge direction",
"switch_to_flat_map": "Switch to flat map",
"switch_to_globe_map": "Switch to globe map",
"sync": "Sync",
"sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
@ -2522,5 +2527,7 @@
"your_wifi_name": "Your Wi-Fi name",
"zero_to_clear_rating": "press 0 to clear asset rating",
"zoom_image": "Zoom Image",
"zoom_in": "Zoom in",
"zoom_out": "Zoom out",
"zoom_to_bounds": "Zoom to bounds"
}

View File

@ -1,8 +1,12 @@
<script lang="ts" module>
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url';
import { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
import maplibregl, { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
import MaplibreWorker from 'maplibre-gl/dist/maplibre-gl-csp-worker?worker';
import { Protocol } from 'pmtiles';
// @ts-expect-error maplibregl types do not include workerClass, but it is required for CSP
maplibregl.workerClass = MaplibreWorker;
let protocol = new Protocol();
void addProtocol('pmtiles', protocol.tile);
void setRTLTextPlugin(mapboxRtlUrl, true);
@ -13,59 +17,64 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { mapSettings, mapShowHeatmap } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
import { Icon, modalManager, Theme, themeManager } from '@immich/ui';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { isEqual, omit } from 'lodash-es';
import {
mdiCog,
mdiCrosshairsGps,
mdiFullscreen,
mdiFullscreenExit,
mdiImageMultiple,
mdiEarth,
mdiMap,
mdiMapMarker,
mdiMinus,
mdiPlus,
} from '@mdi/js';
import type { Feature, Point } from 'geojson';
import { debounce, isEqual } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import {
GlobeControl,
LngLat,
LngLatBounds,
Marker,
type ExpressionSpecification,
type GeoJSONSource,
type LngLatLike,
type ProjectionSpecification,
type Map,
type MapMouseEvent,
} from 'maplibre-gl';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import {
AttributionControl,
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
MarkerLayer,
NavigationControl,
Popup,
ScaleControl,
} from 'svelte-maplibre';
import { GeoJSON, HeatmapLayer, MapLibre, MarkerLayer, Popup } from 'svelte-maplibre';
import type { SelectionBBox } from './types';
import { autoZoomCluster } from './utils';
interface Props {
mapMarkers?: MapMarkerResponseDto[];
showSettings?: boolean;
zoom?: number | undefined;
center?: LngLatLike | undefined;
hash?: boolean;
simplified?: boolean;
clickable?: boolean;
useLocationPin?: boolean;
onOpenInMapView?: (() => Promise<void> | void) | undefined;
onSelect?: (assetIds: string[]) => void;
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
onBoundsChange?: (bbox: SelectionBBox) => void;
visibleAssetIds?: Set<string> | undefined;
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
rounded?: boolean;
showSimpleControls?: boolean;
autoFitBounds?: boolean;
isTimelineOpen?: boolean;
onToggleTimeline?: () => void;
sheetHeight?: number;
isDraggingSheet?: boolean;
showSimpleControls?: boolean;
}
let {
@ -73,26 +82,29 @@
showSettings = true,
zoom = undefined,
center = $bindable(undefined),
hash = false,
simplified = false,
clickable = false,
useLocationPin = false,
onOpenInMapView = undefined,
onSelect = () => {},
onClusterSelect,
onBoundsChange,
visibleAssetIds,
onClickPoint = () => {},
popup,
rounded = false,
showSimpleControls = true,
autoFitBounds = true,
isTimelineOpen = false,
onToggleTimeline,
sheetHeight = 50,
isDraggingSheet = false,
showSimpleControls = true,
}: Props = $props();
// Calculate initial bounds from markers once during initialization
const initialBounds = (() => {
if (!autoFitBounds || center || zoom !== undefined || !mapMarkers || mapMarkers.length === 0) {
return undefined;
}
const bounds = new LngLatBounds();
for (const marker of mapMarkers) {
bounds.extend([marker.lon, marker.lat]);
@ -100,26 +112,36 @@
return bounds;
})();
let map: Map | undefined = $state();
let map: Map | undefined = $state.raw();
let marker: Marker | null = null;
let abortController: AbortController;
let isFullscreen = $state(false);
let isGlobeView = $state(false);
const mapTheme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.Light);
let innerWidth = $state(1024);
let isMobile = $derived(innerWidth < 768);
let hideMapControls = $derived(isMobile && sheetHeight > 40);
const mapTheme = $derived(themeManager.value);
const styleUrl = $derived(
mapTheme === Theme.Dark ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
);
const mapProjection = $derived<ProjectionSpecification>({
type: isGlobeView ? 'globe' : 'mercator',
});
export function addClipMapMarker(lng: number, lat: number) {
if (map) {
if (marker) {
marker.remove();
}
center = { lng, lat };
marker = new Marker().setLngLat([lng, lat]).addTo(map);
}
}
void addClipMapMarker;
function handleAssetClick(assetId: string, map: Map | null) {
if (!map) {
return;
@ -127,47 +149,54 @@
onSelect([assetId]);
}
async function handleClusterClick(clusterId: number, map: Map | null) {
if (!map) {
async function handleClusterClick(clusterId: number, mapInstance: Map | null) {
if (!mapInstance) {
return;
}
const mapSource = mapInstance.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 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);
await autoZoomCluster({
map: mapInstance,
mapSource,
clusterId,
onSelect,
onClusterSelect,
});
}
const handleBoundsChange = debounce(() => {
if (!map || !onBoundsChange) {
return;
}
const bounds = map.getBounds();
if (!bounds) {
return;
}
let west = bounds.getWest();
let east = bounds.getEast();
let south = Math.max(-90, Math.min(90, bounds.getSouth()));
let north = Math.max(-90, Math.min(90, bounds.getNorth()));
if (east - west >= 360) {
west = -180;
east = 180;
} else {
west = bounds.getSouthWest().wrap().lng;
east = bounds.getNorthEast().wrap().lng;
}
onBoundsChange({ west, south, east, north });
}, 200);
function handleMapClick(event: MapMouseEvent) {
if (clickable) {
const { lng, lat } = event.lngLat;
onClickPoint({ lng, lat });
if (marker) {
marker.remove();
}
if (map) {
marker = new Marker().setLngLat([lng, lat]).addTo(map);
}
@ -175,7 +204,6 @@
}
type FeaturePoint = Feature<Point, { id: string; city: string | null; state: string | null; country: string | null }>;
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
return {
type: 'Feature',
@ -189,7 +217,7 @@
};
};
const asMarker = (feature: Feature<Geometry, GeoJsonProperties>): MapMarkerResponseDto => {
const asMarker = (feature: Feature): MapMarkerResponseDto => {
const featurePoint = feature as FeaturePoint;
const coords = LngLat.convert(featurePoint.geometry.coordinates as [number, number]);
return {
@ -204,20 +232,22 @@
function getFileCreatedDates() {
const { relativeDate, dateAfter, dateBefore } = $mapSettings;
if (relativeDate) {
const duration = Duration.fromISO(relativeDate);
return {
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
};
return { fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined };
}
return {
fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedBefore: dateBefore?.toUTC().toISO(),
};
}
const filter: ExpressionSpecification | undefined = $derived.by(() =>
visibleAssetIds
? (['in', ['get', 'id'], ['literal', Array.from(visibleAssetIds)]] as unknown as ExpressionSpecification)
: undefined,
);
async function loadMapMarkers() {
if (abortController) {
abortController.abort();
@ -236,28 +266,27 @@
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
},
{
signal: abortController.signal,
},
{ signal: abortController.signal },
);
}
const handleSettingsClick = async () => {
const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
if (settings) {
const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
$mapSettings = settings;
if (shouldUpdate) {
mapMarkers = await loadMapMarkers();
}
}
const handleSettingsClick = () => {
handlePromiseError(
modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } }).then(async (settings) => {
if (settings) {
const shouldUpdate = !isEqual(settings, $mapSettings);
$mapSettings = settings;
if (shouldUpdate) {
mapMarkers = await loadMapMarkers();
}
}
}),
);
};
afterNavigate(() => {
if (map) {
map.resize();
if (globalThis.location.hash) {
const hashChangeEvent = new HashChangeEvent('hashchange');
globalThis.dispatchEvent(hashChangeEvent);
@ -265,10 +294,12 @@
}
});
onMount(async () => {
if (!mapMarkers) {
mapMarkers = await loadMapMarkers();
}
onMount(() => {
void loadMapMarkers().then((markers) => {
if (!mapMarkers) {
mapMarkers = markers;
}
});
});
onDestroy(() => {
@ -279,23 +310,15 @@
map?.setStyle(styleUrl, {
transformStyle: (previousStyle, nextStyle) => {
if (previousStyle) {
// Preserves the custom map markers from the previous style when the theme is switched
// Required until https://github.com/dimfeld/svelte-maplibre/issues/146 is fixed
const customLayers = previousStyle.layers.filter((l) => l.type == 'fill' && l.source == 'geojson');
const customLayers = previousStyle.layers.filter((l) => l.type === 'fill' && l.source === 'geojson');
const layers = nextStyle.layers.concat(customLayers);
const sources = nextStyle.sources;
for (const [key, value] of Object.entries(previousStyle.sources || {})) {
if (key.startsWith('geojson')) {
sources[key] = value;
}
}
return {
...nextStyle,
sources,
layers,
};
return { ...nextStyle, sources, layers };
}
return nextStyle;
},
@ -306,20 +329,72 @@
if (!center || !zoom) {
return;
}
untrack(() => map?.jumpTo({ center, zoom }));
});
const onAssetsDelete = async () => {
mapMarkers = await loadMapMarkers();
$effect(() => {
const currentMap = map;
if (!currentMap) {
return;
}
if (isMobile && isTimelineOpen) {
const bottomPadding = (sheetHeight / 100) * innerHeight;
untrack(() => {
currentMap.setPadding({ top: 0, left: 0, right: 0, bottom: bottomPadding });
});
} else {
untrack(() => {
currentMap.setPadding({ top: 0, left: 0, right: 0, bottom: 0 });
});
}
});
const onAssetsDelete = () => {
handlePromiseError(
loadMapMarkers().then((markers) => {
mapMarkers = markers;
}),
);
};
const toggleFullscreen = () => {
if (!map) {
return;
}
const container = map.getContainer().parentElement;
if (!container) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
isFullscreen = false;
} else {
container.requestFullscreen().catch(() => {});
isFullscreen = true;
}
};
const handleLocate = () => {
navigator.geolocation.getCurrentPosition(
(pos) => map?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 15 }),
(err) => handlePromiseError(Promise.reject(err)),
{ enableHighAccuracy: true },
);
};
const toggleMapProjection = () => {
isGlobeView = !isGlobeView;
};
</script>
<svelte:window bind:innerWidth />
<OnEvents {onAssetsDelete} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
{hash}
hash={false}
style=""
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{zoom}
@ -328,46 +403,154 @@
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
attributionControl={false}
diffStyleUpdates={true}
projection={mapProjection}
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
event.on('moveend', handleBoundsChange);
event.on('zoomend', handleBoundsChange);
handleBoundsChange();
event.on('mouseenter', 'geojson-clusters', () => {
event.getCanvas().style.cursor = 'pointer';
});
event.on('mouseleave', 'geojson-clusters', () => {
event.getCanvas().style.cursor = '';
});
document.addEventListener('fullscreenchange', () => {
isFullscreen = !!document.fullscreenElement;
});
}}
bind:map
>
{#snippet children({ map }: { map: Map })}
{#if showSimpleControls}
<NavigationControl position="top-left" showCompass={!simplified} />
<div class="pointer-events-none absolute inset-0 z-10 p-4">
{#if showSimpleControls}
<div class="pointer-events-auto absolute top-4 right-4 flex flex-col gap-3">
{#if showSettings}
<button
type="button"
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={$t('map_settings')}
aria-label={$t('map_settings')}
onclick={handleSettingsClick}
>
<Icon icon={mdiCog} size="24" />
</button>
{/if}
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{#if !simplified}
<button
type="button"
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={isGlobeView ? $t('switch_to_flat_map') : $t('switch_to_globe_map')}
aria-label={isGlobeView ? $t('switch_to_flat_map') : $t('switch_to_globe_map')}
aria-pressed={isGlobeView}
onclick={toggleMapProjection}
>
<Icon icon={isGlobeView ? mdiMap : mdiEarth} size="24" />
</button>
<button
type="button"
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={isFullscreen ? $t('exit_fullscreen') : $t('fullscreen')}
aria-label={isFullscreen ? $t('exit_fullscreen') : $t('fullscreen')}
onclick={toggleFullscreen}
>
<Icon icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen} size="24" />
</button>
{/if}
{#if onOpenInMapView}
<button
type="button"
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={$t('open_in_map_view')}
aria-label={$t('open_in_map_view')}
onclick={() => void onOpenInMapView()}
>
<Icon icon={mdiMap} size="24" />
</button>
{/if}
{#if onToggleTimeline}
<button
type="button"
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={$t('timeline')}
aria-label={$t('timeline')}
onclick={() => onToggleTimeline?.()}
>
<Icon
icon={mdiImageMultiple}
size="24"
class={isTimelineOpen ? 'text-immich-primary dark:text-immich-primary' : ''}
/>
</button>
{/if}
</div>
<div
class="absolute right-4 flex flex-col gap-3 transition-all duration-300 ease-out"
class:opacity-0={hideMapControls}
class:pointer-events-none={hideMapControls}
class:pointer-events-auto={!hideMapControls}
class:transition-none={isDraggingSheet}
style:bottom={isTimelineOpen && isMobile ? `calc(${Math.min(sheetHeight, 40)}vh + 1rem)` : '2.5rem'}
>
{#if !simplified}
<button
type="button"
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={$t('geolocate')}
aria-label={$t('geolocate')}
onclick={handleLocate}
>
<Icon icon={mdiCrosshairsGps} size="24" />
</button>
{/if}
<div
class="flex flex-col overflow-hidden rounded-2xl border border-white/20 shadow-lg backdrop-blur-md dark:border-white/10"
>
<button
type="button"
class="flex size-11 items-center justify-center bg-white/70 text-black/80 transition-all hover:bg-white/90 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={$t('zoom_in')}
aria-label={$t('zoom_in')}
onclick={() => map.zoomIn()}
>
<Icon icon={mdiPlus} size="24" />
</button>
<div class="h-px w-full bg-black/10 dark:bg-white/10"></div>
<button
type="button"
class="flex size-11 items-center justify-center bg-white/70 text-black/80 transition-all hover:bg-white/90 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
title={$t('zoom_out')}
aria-label={$t('zoom_out')}
onclick={() => map.zoomOut()}
>
<Icon icon={mdiMinus} size="24" />
</button>
</div>
</div>
<div
class="absolute left-4 rounded-sm bg-white/70 px-2 py-0.5 text-[11px] font-medium text-black/80 shadow-sm backdrop-blur-md ease-out {isDraggingSheet
? 'transition-none'
: 'transition-all duration-300'} dark:bg-immich-dark-gray/70 dark:text-white/80"
class:opacity-0={hideMapControls}
class:pointer-events-none={hideMapControls}
class:pointer-events-auto={!hideMapControls}
style:bottom={isTimelineOpen && isMobile ? `calc(${Math.min(sheetHeight, 40)}vh + 0.5rem)` : '0.5rem'}
>
© OpenStreetMap
</div>
{/if}
{/if}
{#if showSettings}
<Control>
<ControlGroup>
<ControlButton onclick={handleSettingsClick}>
<Icon icon={mdiCog} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
</div>
<GeoJSON
data={{
@ -375,51 +558,78 @@
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
}}
id="geojson"
cluster={{ radius: 35, maxZoom: 18 }}
cluster={{ radius: 35, maxZoom: 17 }}
>
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="flex size-10 items-center justify-center rounded-full bg-immich-primary font-mono font-bold text-white opacity-90 shadow-lg transition-all duration-200 hover:bg-immich-dark-primary hover:text-immich-dark-bg"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
}}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
{#if $mapShowHeatmap}
<HeatmapLayer
id="asset-heatmap-layer"
paint={{
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0,
'rgba(255, 255, 255, 0)',
0.2,
'rgb(255, 235, 59)',
0.4,
'rgb(255, 152, 0)',
0.7,
'rgb(244, 67, 54)',
1,
'rgb(183, 28, 28)',
] as ExpressionSpecification,
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 0.8, 15, 2] as ExpressionSpecification,
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 20, 15, 40] as ExpressionSpecification,
'heatmap-opacity': 0.8,
}}
/>
{:else}
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="flex size-10 items-center justify-center rounded-full bg-immich-primary font-mono font-bold text-white opacity-90 shadow-lg transition-all duration-200 hover:bg-immich-dark-primary hover:text-immich-dark-bg"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
filter={filter as never}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
}}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
{/if}
</GeoJSON>
{/snippet}
</MapLibre>

View File

@ -0,0 +1,210 @@
import type { Feature, Point } from 'geojson';
import type { GeoJSONSource, Map } from 'maplibre-gl';
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* Integration tests for Map component cluster click behavior.
*/
describe('Map component - Cluster click integration', () => {
let mockMap: Partial<Map>;
let mockMapSource: Partial<GeoJSONSource>;
let onSelect: Mock;
let onClusterSelect: Mock;
const createMockLeaf = (id: string, lon: number, lat: number): Feature<Point> => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { id },
});
beforeEach(() => {
onSelect = vi.fn();
onClusterSelect = vi.fn();
mockMap = {
fitBounds: vi.fn(),
flyTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
getSource: vi.fn().mockReturnValue({
getClusterLeaves: vi.fn(),
getClusterExpansionZoom: vi.fn(),
}),
};
mockMapSource = {
getClusterLeaves: vi.fn(),
getClusterExpansionZoom: vi.fn(),
};
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Contract validation', () => {
it('should extract mapSource from map.getSource("geojson")', () => {
const getSourceMock = vi.fn().mockReturnValue(mockMapSource);
mockMap.getSource = getSourceMock;
// Simulating handleClusterClick function
const extractedSource = (mockMap as Map).getSource('geojson');
expect(getSourceMock).toHaveBeenCalledWith('geojson');
expect(extractedSource).toBe(mockMapSource);
});
it('should handle null map gracefully', () => {
const nullMap: Map | null = null;
expect(nullMap).toBeNull();
});
});
describe('Cluster click flow - Multiple locations', () => {
it('should trigger fitBounds for multi-location cluster', () => {
const leaves = [createMockLeaf('a1', 10, 20), createMockLeaf('a2', 30, 40)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
expect(leaves.length).toBeGreaterThan(1);
const coords = leaves.map((l) => (l.geometry as Point).coordinates);
expect(coords).toHaveLength(2);
// Simulate the callback that would be triggered
const bboxWest = Math.min(...coords.map((c) => c[0]));
const bboxSouth = Math.min(...coords.map((c) => c[1]));
const bboxEast = Math.max(...coords.map((c) => c[0]));
const bboxNorth = Math.max(...coords.map((c) => c[1]));
const bbox = { west: bboxWest, south: bboxSouth, east: bboxEast, north: bboxNorth };
expect(bbox).toEqual({ west: 10, south: 20, east: 30, north: 40 });
expect(bbox.west).not.toEqual(bbox.east);
expect(bbox.south).not.toEqual(bbox.north);
});
});
describe('Cluster click flow - Timeline panel integration', () => {
it('should pass correct data to onClusterSelect callback', () => {
const leaves = [createMockLeaf('uuid-1', 10, 20), createMockLeaf('uuid-2', 30, 40)];
const ids = leaves.map((l) => l.properties?.id as string);
const bbox = {
west: 10,
south: 20,
east: 30,
north: 40,
};
if (onClusterSelect) {
onClusterSelect(ids, bbox);
}
expect(onClusterSelect).toHaveBeenCalledWith(['uuid-1', 'uuid-2'], bbox);
});
it('should fallback to onSelect when onClusterSelect is not provided', () => {
const leaves = [createMockLeaf('asset1', 10, 20)];
const ids = leaves.map((l) => l.properties?.id as string);
onSelect(ids);
expect(onSelect).toHaveBeenCalledWith(['asset1']);
});
});
describe('Camera movement guarantees', () => {
it('should support fitBounds with proper options', () => {
const bounds: [[number, number], [number, number]] = [
[10, 20],
[30, 40],
];
const options = { padding: 100, speed: 1.5, maxZoom: 17 };
(mockMap.fitBounds as Mock)?.(bounds, options);
expect(mockMap.fitBounds).toHaveBeenCalledWith(bounds, options);
});
it('should support flyTo with proper options', () => {
const options = { center: [50, 60] as [number, number], zoom: 14, speed: 1.5 };
(mockMap.flyTo as Mock)?.(options);
expect(mockMap.flyTo).toHaveBeenCalledWith(options);
});
it('should read current zoom level from map', () => {
const zoom = (mockMap as Map).getZoom?.();
expect(zoom).toBe(10);
// Fallback calculation should work
const fallbackZoom = (zoom ?? 0) + 2;
expect(fallbackZoom).toBe(12);
});
});
describe('Error resilience', () => {
it('should handle empty cluster gracefully', async () => {
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue([]);
const leaves = await (mockMapSource as GeoJSONSource).getClusterLeaves(123, 10_000, 0);
expect(leaves.length).toBe(0);
// Component should return early without calling callbacks
if (leaves.length === 0) {
expect(onSelect).not.toHaveBeenCalled();
}
});
it('should handle expansion zoom lookup failure gracefully', async () => {
const leaves = [createMockLeaf('a1', 50, 60)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
(mockMapSource.getClusterExpansionZoom as Mock).mockRejectedValue(new Error('Cluster not found'));
const tries = [];
try {
const expansionZoom = await (mockMapSource as GeoJSONSource).getClusterExpansionZoom(456);
tries.push(expansionZoom);
} catch {
// Fallback path: use getZoom() + 2
const currentZoom = (mockMap as Map).getZoom?.() ?? 8;
tries.push(currentZoom + 2);
}
expect(tries[0]).toBe(12); // 10 + 2
});
it('should handle missing asset ids in cluster leaves', () => {
const leavesWithoutId: Feature<Point>[] = [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [10, 20] },
properties: {}, // no id
},
];
const ids = leavesWithoutId.map((l) => l.properties?.id as string);
expect(ids).toEqual([undefined]);
});
});
describe('Mobile sheet interaction context', () => {
it('should work with MapTimelinePanel click handler', () => {
const selectedIds = ['uuid-1', 'uuid-2'];
const selectedBbox = { west: 10, south: 20, east: 30, north: 40 };
// Panel would filter visible assets by this bbox
expect(selectedBbox).toHaveProperty('west');
expect(selectedBbox).toHaveProperty('south');
expect(selectedBbox).toHaveProperty('east');
expect(selectedBbox).toHaveProperty('north');
expect(selectedIds).toHaveLength(2);
});
it('should provide zoom animation context for mobile experience', () => {
const fitBoundsOptions = { padding: 100, speed: 1.5, maxZoom: 17 };
expect(fitBoundsOptions.speed).toBeLessThan(2);
expect(fitBoundsOptions.maxZoom).toBeLessThanOrEqual(17);
expect(fitBoundsOptions.padding).toBeGreaterThan(0);
});
});
});

View File

@ -0,0 +1,352 @@
import type { Feature, Point } from 'geojson';
import type { GeoJSONSource, Map } from 'maplibre-gl';
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { autoZoomCluster } from '../utils';
/**
* Unit tests for the autoZoomCluster function
*/
describe('autoZoomCluster', () => {
let mockMap: Partial<Map>;
let mockMapSource: Partial<GeoJSONSource>;
let onSelect: Mock;
let onClusterSelect: Mock;
const createMockLeaf = (id: string, lon: number, lat: number): Feature<Point> => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { id },
});
beforeEach(() => {
onSelect = vi.fn();
onClusterSelect = vi.fn();
mockMap = {
fitBounds: vi.fn(),
flyTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
};
mockMapSource = {
getClusterLeaves: vi.fn(),
getClusterExpansionZoom: vi.fn(),
};
});
describe('Empty clusters', () => {
it('should handle empty cluster leaves gracefully', async () => {
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue([]);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
onClusterSelect,
});
expect(mockMap.fitBounds).not.toHaveBeenCalled();
expect(mockMap.flyTo).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
expect(onClusterSelect).not.toHaveBeenCalled();
});
});
describe('Multiple locations (fitBounds)', () => {
it('should use fitBounds for cluster with 2 distinct locations', async () => {
const leaves = [createMockLeaf('asset1', 10, 20), createMockLeaf('asset2', 30, 40)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
});
expect(mockMap.fitBounds).toHaveBeenCalledWith(
[
[10, 20],
[30, 40],
],
{ padding: 100, speed: 1.5, maxZoom: 17 },
);
expect(mockMap.flyTo).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith(['asset1', 'asset2']);
});
it('should calculate correct bounding box with 3+ assets', async () => {
const leaves = [createMockLeaf('a1', 10, 20), createMockLeaf('a2', 5, 15), createMockLeaf('a3', 25, 35)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 789,
onSelect,
onClusterSelect,
});
expect(mockMap.fitBounds).toHaveBeenCalledWith(
[
[5, 15],
[25, 35],
],
{ padding: 100, speed: 1.5, maxZoom: 17 },
);
expect(onClusterSelect).toHaveBeenCalledWith(['a1', 'a2', 'a3'], {
west: 5,
south: 15,
east: 25,
north: 35,
});
});
it('should handle negative coordinates correctly in bounding box', async () => {
const leaves = [createMockLeaf('a1', -10, -20), createMockLeaf('a2', 10, 20)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 999,
onSelect,
onClusterSelect,
});
expect(mockMap.fitBounds).toHaveBeenCalledWith(
[
[-10, -20],
[10, 20],
],
{ padding: 100, speed: 1.5, maxZoom: 17 },
);
expect(onClusterSelect).toHaveBeenCalledWith(['a1', 'a2'], {
west: -10,
south: -20,
east: 10,
north: 20,
});
});
});
describe('Single location (flyTo)', () => {
it('should use flyTo for single asset', async () => {
const leaves = [createMockLeaf('asset1', 50, 60)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
(mockMapSource.getClusterExpansionZoom as Mock).mockResolvedValue(14);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 456,
onSelect,
});
expect(mockMap.flyTo).toHaveBeenCalledWith({
center: [50, 60],
zoom: 14,
speed: 1.5,
});
expect(mockMap.fitBounds).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith(['asset1']);
});
it('should use flyTo for multiple assets at exact same location', async () => {
const leaves = [createMockLeaf('a1', 50, 60), createMockLeaf('a2', 50, 60), createMockLeaf('a3', 50, 60)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
(mockMapSource.getClusterExpansionZoom as Mock).mockResolvedValue(15);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 456,
onSelect,
});
expect(mockMap.flyTo).toHaveBeenCalledWith({
center: [50, 60],
zoom: 15,
speed: 1.5,
});
expect(mockMap.fitBounds).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith(['a1', 'a2', 'a3']);
});
});
describe('Fallback zoom behavior', () => {
it('should fallback to map.getZoom() + 2 when expansionZoom is undefined', async () => {
const leaves = [createMockLeaf('asset1', 50, 60)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
(mockMapSource.getClusterExpansionZoom as Mock).mockResolvedValue(undefined);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 456,
onSelect,
});
expect(mockMap.flyTo).toHaveBeenCalledWith({
center: [50, 60],
zoom: 12,
speed: 1.5,
});
expect(onSelect).toHaveBeenCalledWith(['asset1']);
});
it('should fallback to map.getZoom() + 2 when expansionZoom throws error', async () => {
const leaves = [createMockLeaf('asset1', 50, 60)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
(mockMapSource.getClusterExpansionZoom as Mock).mockRejectedValue(new Error('Expansion zoom lookup failed'));
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 456,
onSelect,
});
expect(mockMap.flyTo).toHaveBeenCalledWith({
center: [50, 60],
zoom: 12,
speed: 1.5,
});
expect(onSelect).toHaveBeenCalledWith(['asset1']);
});
});
describe('Callback routing', () => {
it('should call onClusterSelect with bbox when provided', async () => {
const leaves = [createMockLeaf('a1', 10, 20), createMockLeaf('a2', 30, 40)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
onClusterSelect,
});
expect(onClusterSelect).toHaveBeenCalledWith(['a1', 'a2'], {
west: 10,
south: 20,
east: 30,
north: 40,
});
// onClusterSelect should be called, not onSelect
expect(onSelect).not.toHaveBeenCalled();
});
it('should call onSelect as fallback when onClusterSelect is not provided', async () => {
const leaves = [createMockLeaf('a1', 10, 20), createMockLeaf('a2', 30, 40)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
onClusterSelect: undefined,
});
expect(onSelect).toHaveBeenCalledWith(['a1', 'a2']);
expect(onClusterSelect).not.toHaveBeenCalled();
});
});
describe('Asset ID extraction', () => {
it('should correctly extract asset IDs from cluster leaves', async () => {
const leaves = [
createMockLeaf('uuid-1-2-3', 10, 20),
createMockLeaf('uuid-4-5-6', 30, 40),
createMockLeaf('uuid-7-8-9', 50, 60),
];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
});
expect(onSelect).toHaveBeenCalledWith(['uuid-1-2-3', 'uuid-4-5-6', 'uuid-7-8-9']);
});
it('should handle leaves without id property gracefully', async () => {
const leaves: Feature<Point>[] = [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [10, 20] },
properties: {},
} as unknown as Feature<Point>,
];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
});
// Should call with undefined id
expect(onSelect).toHaveBeenCalledWith([undefined]);
});
});
describe('Integration scenarios', () => {
it('should handle large cluster with many assets', async () => {
const leaves = Array.from({ length: 100 }, (_, i) =>
createMockLeaf(`asset-${i}`, Math.random() * 180 - 90, Math.random() * 360 - 180),
);
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
onClusterSelect,
});
expect(mockMap.fitBounds).toHaveBeenCalled();
expect(onClusterSelect).toHaveBeenCalled();
const [ids, bbox] = onClusterSelect.mock.calls[0];
expect(ids).toHaveLength(100);
expect(bbox).toHaveProperty('west');
expect(bbox).toHaveProperty('south');
expect(bbox).toHaveProperty('east');
expect(bbox).toHaveProperty('north');
});
it('should retrieve up to 10,000 cluster leaves', async () => {
const leaves = [createMockLeaf('asset1', 10, 20)];
(mockMapSource.getClusterLeaves as Mock).mockResolvedValue(leaves);
await autoZoomCluster({
map: mockMap as Map,
mapSource: mockMapSource as GeoJSONSource,
clusterId: 123,
onSelect,
});
// Verify getClusterLeaves was called with limit 10000
expect(mockMapSource.getClusterLeaves).toHaveBeenCalledWith(123, 10_000, 0);
});
});
});

View File

@ -0,0 +1,87 @@
import type { Point } from 'geojson';
import type { GeoJSONSource, Map } from 'maplibre-gl';
export interface SelectionBBox {
west: number;
south: number;
east: number;
north: number;
}
/**
* Auto-zoom cluster by calculating bounding box of all leaves.
*/
export async function autoZoomCluster({
map,
mapSource,
clusterId,
onClusterSelect,
onSelect,
}: {
map: Map;
mapSource: GeoJSONSource;
clusterId: number;
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
onSelect: (assetIds: string[]) => void;
}): Promise<void> {
const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0);
const ids = leaves.map((leaf) => leaf.properties?.id as string);
if (leaves.length === 0) {
return;
}
// Calculate the exact bounding box of all items in the cluster
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: SelectionBBox = { west, south, east, north };
// Auto-zoom logic
if (west !== east || south !== north) {
// Multiple distinct locations: fit bounds
map.fitBounds(
[
[west, south],
[east, north],
],
{ padding: 100, speed: 1.5, maxZoom: 17 },
);
} else {
// All assets in the same place: use expansion zoom or fallback
try {
const expansionZoom = await mapSource.getClusterExpansionZoom(clusterId);
map.flyTo({
center: [west, south],
zoom: expansionZoom ?? map.getZoom() + 2,
speed: 1.5,
});
} catch {
// Fallback if expansion zoom fails
map.flyTo({
center: [west, south],
zoom: map.getZoom() + 2,
speed: 1.5,
});
}
}
// Invoke callback
if (onClusterSelect) {
onClusterSelect(ids, bbox);
return;
}
onSelect(ids);
}

View File

@ -508,7 +508,7 @@
aria-valuemax={toScrollY(1)}
aria-valuemin={toScrollY(0)}
data-id="scrubber"
class="absolute inset-e-0 z-1 select-none hover:cursor-row-resize"
class="absolute inset-e-0 z-10 select-none hover:cursor-row-resize"
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width
@ -589,12 +589,16 @@
>
{#if !usingMobileDevice}
{#if segment.hasLabel}
<div class="absolute inset-e-5 bottom-0 font-mono text-[13px] dark:text-immich-dark-fg">
<div
class="absolute inset-e-6 bottom-0 z-10 origin-right -translate-y-1/2 scale-90 text-[11px] font-bold tracking-wider whitespace-nowrap text-gray-400 uppercase transition-all hover:scale-100 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
>
{segment.year}
</div>
{/if}
{#if segment.hasDot}
<div class="absolute inset-e-3 bottom-0 size-1 rounded-full bg-gray-300"></div>
<div
class="absolute inset-e-3 bottom-0 size-1.5 rounded-full bg-gray-200 transition-colors hover:bg-immich-primary dark:bg-gray-800 dark:hover:bg-immich-dark-primary"
></div>
{/if}
{/if}
</div>

View File

@ -17,7 +17,12 @@
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import type {
TimelineAsset,
TimelineManagerLayoutOptions,
TimelineManagerOptions,
ViewportTopMonth,
} from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
@ -36,6 +41,7 @@
enableRouting: boolean;
timelineManager?: TimelineManager;
options?: TimelineManagerOptions;
layoutOptions?: Partial<TimelineManagerLayoutOptions>;
assetInteraction: AssetMultiSelectManager;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
withStacked?: boolean;
@ -68,6 +74,7 @@
enableRouting,
timelineManager = $bindable(),
options,
layoutOptions: layoutOptionsOverride = {},
assetInteraction,
removeAction = null,
withStacked = false,
@ -89,7 +96,6 @@
$effect(() => options && void timelineManager.updateOptions(options));
let scrollableElement: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
let invisible = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
// Note: There may be multiple months visible within the viewport at any given time.
@ -114,7 +120,7 @@
rowHeight: 235,
headerHeight: 48,
};
timelineManager.setLayoutOptions(layoutOptions);
timelineManager.setLayoutOptions({ ...layoutOptions, ...layoutOptionsOverride });
});
$effect(() => {
@ -257,6 +263,12 @@
const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const handleAssetGridScroll = () => {
handleTimelineScroll();
timelineManager.updateSlidingWindow();
updateIsScrolling();
};
onMount(() => {
if (!enableRouting) {
invisible = false;
@ -622,14 +634,9 @@
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth}
bind:this={scrollableElement}
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
onscroll={handleAssetGridScroll}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible
style:height={timelineManager.totalViewerHeight + 'px'}
>
<section id="virtual-timeline" class:invisible style:height={timelineManager.totalViewerHeight + 'px'}>
<section
bind:clientHeight={timelineManager.topSectionHeight}
class:invisible

View File

@ -181,20 +181,20 @@ export class TimelineMonth {
const timelineAsset: TimelineAsset = {
city: bucketAssets.city?.[i] ?? null,
country: bucketAssets.country?.[i] ?? null,
duration: bucketAssets.duration[i],
duration: bucketAssets.duration?.[i] ?? null,
id: bucketAssets.id[i],
visibility: bucketAssets.visibility[i],
isFavorite: bucketAssets.isFavorite[i],
isImage: bucketAssets.isImage[i],
isTrashed: bucketAssets.isTrashed[i],
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
visibility: bucketAssets.visibility?.[i] ?? bucketAssets.visibility?.[0],
isFavorite: bucketAssets.isFavorite?.[i] ?? false,
isImage: bucketAssets.isImage?.[i] ?? true,
isTrashed: bucketAssets.isTrashed?.[i] ?? false,
isVideo: !(bucketAssets.isImage?.[i] ?? true),
livePhotoVideoId: bucketAssets.livePhotoVideoId?.[i] ?? null,
localDateTime,
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
createdAt: fromISODateTimeUTC(bucketAssets.createdAt?.[i] ?? bucketAssets.fileCreatedAt[i]).setZone('local'),
fileCreatedAt,
ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i],
ratio: bucketAssets.ratio[i],
ownerId: bucketAssets.ownerId?.[i] ?? '',
projectionType: bucketAssets.projectionType?.[i] ?? null,
ratio: bucketAssets.ratio?.[i] ?? 1,
stack: bucketAssets.stack?.[i]
? {
id: bucketAssets.stack[i]![0],
@ -202,7 +202,7 @@ export class TimelineMonth {
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
}
: null,
thumbhash: bucketAssets.thumbhash[i],
thumbhash: bucketAssets.thumbhash?.[i] ?? null,
people: null, // People are not included in the bucket assets
};

View File

@ -1,7 +1,8 @@
<script lang="ts">
import type { MapSettings } from '$lib/stores/preferences.store';
import { mapShowHeatmap, type MapSettings } from '$lib/stores/preferences.store';
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@ -11,32 +12,34 @@
};
let { settings: initialValues, onClose }: Props = $props();
let settings = $state(initialValues);
let settings = $state({ ...untrack(() => initialValues) });
let showHeatmap = $state($mapShowHeatmap);
let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
const onSubmit = () => {
$mapShowHeatmap = showHeatmap;
onClose(settings);
};
</script>
<FormModal title={$t('map_settings')} {onClose} {onSubmit} size="small">
<Stack gap={4}>
<Field label={$t('allow_dark_mode')}>
<Switch bind:checked={settings.allowDarkMode} />
</Field>
<Field label={$t('only_favorites')}>
<Field label={$t('map_settings_only_show_favorites')}>
<Switch bind:checked={settings.onlyFavorites} />
</Field>
<Field label={$t('include_archived')}>
<Field label={$t('map_settings_include_show_archived')}>
<Switch bind:checked={settings.includeArchived} />
</Field>
<Field label={$t('include_shared_partner_assets')}>
<Field label={$t('map_settings_include_show_partners')}>
<Switch bind:checked={settings.withPartners} />
</Field>
<Field label={$t('include_shared_albums')}>
<Switch bind:checked={settings.withSharedAlbums} />
</Field>
<Field label="Show Heatmap">
<Switch bind:checked={showHeatmap} />
</Field>
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">

View File

@ -21,7 +21,7 @@ export const lang = persisted<string>('lang', preferredLocale || defaultLang.cod
});
export interface MapSettings {
allowDarkMode: boolean;
theme: 'light' | 'dark' | 'system';
includeArchived: boolean;
onlyFavorites: boolean;
withPartners: boolean;
@ -31,8 +31,8 @@ export interface MapSettings {
dateBefore?: DateTime<true>;
}
const defaultMapSettings = {
allowDarkMode: true,
const defaultMapSettings: MapSettings = {
theme: 'system',
includeArchived: false,
onlyFavorites: false,
withPartners: false,
@ -40,15 +40,17 @@ const defaultMapSettings = {
relativeDate: '',
};
const persistedObject = <T>(key: string, defaults: T) =>
persisted<T>(key, defaults, {
serializer: {
parse: (text) => ({ ...defaults, ...JSON.parse(text ?? null) }),
stringify: JSON.stringify,
},
});
export const mapShowHeatmap = persisted<boolean>('map-show-heatmap', false, {});
export const mapSettings = persistedObject<MapSettings>('map-settings', defaultMapSettings);
export const mapSettings = persisted<MapSettings>('map-settings', defaultMapSettings, {
serializer: {
parse: (text) => {
const parsed = (JSON.parse(text ?? 'null') ?? {}) as Partial<MapSettings>;
return { ...defaultMapSettings, ...parsed };
},
stringify: JSON.stringify,
},
});
export interface AlbumViewSettings {
view: string;
@ -71,11 +73,6 @@ export interface PlacesViewSettings {
};
}
export interface SidebarSettings {
people: boolean;
sharing: boolean;
}
export enum SortOrder {
Asc = 'asc',
Desc = 'desc',

View File

@ -11,8 +11,11 @@
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { LoadingSpinner } from '@immich/ui';
import { Icon, LoadingSpinner } from '@immich/ui';
import { mdiDragHorizontal } from '@mdi/js';
import { onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import type { PageData } from './$types';
interface Props {
@ -22,12 +25,84 @@
let { data }: Props = $props();
let selectedClusterIds = $state.raw(new Set<string>());
let selectedClusterBBox = $state.raw<SelectionBBox>();
let currentMapBBox = $state.raw<SelectionBBox>();
let isTimelinePanelVisible = $state(false);
let visibleAssetIds = $state<Set<string>>();
// Desktop Sidebar State
const DEFAULT_PANEL_WIDTH = 384;
const DESKTOP_PANEL_MIN_WIDTH = 320;
const DESKTOP_PANEL_MAX_WIDTH = 700;
let timelinePanelWidth = $state(DEFAULT_PANEL_WIDTH);
// Mobile Bottom Sheet State
let sheetHeight = $state(50);
let isDraggingSheet = $state(false);
let innerWidth = $state(1024);
let isMobile = $derived(innerWidth < 768);
const clampedTimelinePanelWidth = $derived(
Math.min(Math.max(timelinePanelWidth, DESKTOP_PANEL_MIN_WIDTH), DESKTOP_PANEL_MAX_WIDTH),
);
const isSameBbox = (a: SelectionBBox | undefined, b: SelectionBBox) => {
if (!a) {
return false;
}
const epsilon = 0.000_01;
return (
Math.abs(a.west - b.west) <= epsilon &&
Math.abs(a.south - b.south) <= epsilon &&
Math.abs(a.east - b.east) <= epsilon &&
Math.abs(a.north - b.north) <= epsilon
);
};
let isResizingPanel = $state(false);
let resizeStartX = $state(0);
let resizeStartWidth = $state(0);
function handleResizeStart(event: PointerEvent) {
if (!isMobile) {
event.preventDefault();
isResizingPanel = true;
resizeStartX = event.clientX;
resizeStartWidth = timelinePanelWidth;
if (event.currentTarget instanceof HTMLElement) {
event.currentTarget.setPointerCapture(event.pointerId);
}
}
}
function handleResizeMove(event: PointerEvent) {
if (!isResizingPanel) {
return;
}
const deltaX = resizeStartX - event.clientX;
const newWidth = resizeStartWidth + deltaX;
timelinePanelWidth = Math.min(Math.max(newWidth, DESKTOP_PANEL_MIN_WIDTH), DESKTOP_PANEL_MAX_WIDTH);
}
function handleResizeEnd() {
isResizingPanel = false;
}
function closeTimelinePanel() {
isTimelinePanelVisible = false;
selectedClusterBBox = undefined;
selectedClusterIds = new Set();
visibleAssetIds = undefined;
sheetHeight = 50; // Reset for next open
}
function toggleTimeline() {
isTimelinePanelVisible = !isTimelinePanelVisible;
if (!isTimelinePanelVisible) {
closeTimelinePanel();
} else if (currentMapBBox) {
selectedClusterBBox = currentMapBBox;
selectedClusterIds = new Set();
}
}
onDestroy(() => {
@ -50,41 +125,89 @@
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}
function onBoundsChange(bbox: SelectionBBox) {
currentMapBBox = bbox;
if (isTimelinePanelVisible) {
if (!isSameBbox(selectedClusterBBox, bbox)) {
selectedClusterBBox = bbox;
}
if (selectedClusterIds.size > 0) {
selectedClusterIds = new Set();
}
}
}
</script>
<svelte:window bind:innerWidth onpointermove={handleResizeMove} onpointerup={handleResizeEnd} />
{#if featureFlagsManager.value.map}
<UserPageLayout title={data.meta.title}>
<div class="isolate flex size-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' : 'size-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 size-full items-center justify-center">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map hash onSelect={onViewAssets} {onClusterSelect} />
<div class="relative flex size-full overflow-hidden">
<div class="min-w-0 flex-1 transition-all duration-300 ease-in-out">
{#await Promise.all([import('$lib/components/shared-components/map/Map.svelte'), delay(timeToLoadTheMap)])}
<div class="flex size-full items-center justify-center">
<LoadingSpinner />
</div>
{:then [{ default: MapComponent }]}
<MapComponent
onSelect={onViewAssets}
{onClusterSelect}
{onBoundsChange}
{visibleAssetIds}
isTimelineOpen={isTimelinePanelVisible}
onToggleTimeline={toggleTimeline}
{sheetHeight}
{isDraggingSheet}
/>
{/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">
{#if isTimelinePanelVisible}
{#if !isMobile}
<button
type="button"
class="pointer-events-auto absolute inset-y-0 z-20 flex w-6 cursor-col-resize items-center justify-center text-immich-primary transition-colors hover:text-immich-dark-primary"
onpointerdown={handleResizeStart}
aria-label="Drag to resize timeline panel"
style:right={`${clampedTimelinePanelWidth}px`}
title="Drag to resize timeline panel"
>
<span
class="flex h-14 w-3 items-center justify-center rounded-full border border-gray-200 bg-white/90 shadow-md dark:border-immich-dark-gray dark:bg-immich-dark-bg"
>
<Icon icon={mdiDragHorizontal} size="16" />
</span>
</button>
{/if}
<aside
class="absolute inset-x-0 bottom-0 z-30 flex w-full shrink-0 flex-col sm:relative sm:h-full sm:w-(--timeline-panel-width) sm:max-w-175 sm:min-w-[320px] sm:overflow-y-auto sm:border-s sm:border-gray-200 sm:bg-white sm:dark:border-immich-dark-gray sm:dark:bg-immich-dark-bg"
style:--timeline-panel-width={isMobile ? '100%' : `${clampedTimelinePanelWidth}px`}
transition:fly={{
x: isMobile ? 0 : 400,
y: isMobile ? 800 : 0,
duration: 400,
easing: cubicOut,
}}
>
<MapTimelinePanel
bbox={selectedClusterBBox}
{selectedClusterIds}
panelWidth={isMobile ? innerWidth : clampedTimelinePanelWidth}
{isMobile}
assetCount={selectedClusterIds.size}
onClose={closeTimelinePanel}
onVisibleIdsChange={(ids) => (visibleAssetIds = ids)}
onSelect={onViewAssets}
bind:sheetHeight
bind:isDraggingSheet
/>
</div>
</aside>
{/if}
</div>
</UserPageLayout>
<Portal target="body">
{#if assetViewerManager.isViewing && !isTimelinePanelVisible}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}

View File

@ -1,3 +1,7 @@
<script lang="ts" module>
const patchedTimelineManagers = new WeakSet<TimelineManager>();
</script>
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
@ -20,9 +24,12 @@
import Portal from '$lib/elements/Portal.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { mapSettings } from '$lib/stores/preferences.store';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
@ -30,21 +37,119 @@
type OnUnlink,
} from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon, LoadingSpinner } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
import { ceil, floor } from 'lodash-es';
import { ceil, floor, clamp } from 'lodash-es';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
interface Props {
bbox: SelectionBBox;
bbox?: SelectionBBox;
selectedClusterIds: Set<string>;
panelWidth?: number;
assetCount: number;
onClose: () => void;
onVisibleIdsChange?: (ids: Set<string> | undefined) => void;
onSelect?: (assetIds: string[]) => void;
sheetHeight?: number;
isDraggingSheet?: boolean;
isMobile: boolean;
}
let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props();
let {
bbox,
selectedClusterIds,
panelWidth = 384,
assetCount,
onClose,
onVisibleIdsChange,
onSelect,
sheetHeight = $bindable(50),
isDraggingSheet = $bindable(false),
isMobile,
}: Props = $props();
let timelineManager = $state<TimelineManager>() as TimelineManager;
let timelineManager = $state<TimelineManager>();
let isFetching = $state(false);
let lastKnownAssetCount = $state(0);
$effect.pre(() => {
if (!isFetching) {
lastKnownAssetCount = selectedClusterIds.size > 0 ? assetCount : (timelineManager?.assetCount ?? assetCount);
}
});
// Mobile Swipe Logic
const SHEET_MIN_HEIGHT = 15;
const SHEET_COLLAPSED_HEIGHT = 30;
const SHEET_DEFAULT_HEIGHT = 50;
const SHEET_EXPANDED_HEIGHT = 65;
const SHEET_DISMISS_THRESHOLD = 20;
const SHEET_COLLAPSE_THRESHOLD = 40;
const SHEET_EXPAND_THRESHOLD = 58;
const SHEET_STEP = 10;
let startY = 0;
let startHeight = 0;
function handlePointerDown(e: PointerEvent) {
startY = e.clientY;
startHeight = sheetHeight;
isDraggingSheet = true;
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.setPointerCapture(e.pointerId);
}
}
function handlePointerMove(e: PointerEvent) {
if (!isDraggingSheet) {
return;
}
const deltaY = startY - e.clientY;
const deltaVh = (deltaY / globalThis.innerHeight) * 100;
sheetHeight = clamp(startHeight + deltaVh, SHEET_MIN_HEIGHT, SHEET_EXPANDED_HEIGHT);
}
function handlePointerUp() {
if (!isDraggingSheet) {
return;
}
isDraggingSheet = false;
// Snap states
if (sheetHeight < SHEET_DISMISS_THRESHOLD) {
onClose(); // Swipe to dismiss
} else if (sheetHeight < SHEET_COLLAPSE_THRESHOLD) {
sheetHeight = SHEET_COLLAPSED_HEIGHT; // Collapsed (shows top half of first row)
} else if (sheetHeight > SHEET_EXPAND_THRESHOLD) {
sheetHeight = SHEET_EXPANDED_HEIGHT; // Expanded (leaves room for header)
} else {
sheetHeight = SHEET_DEFAULT_HEIGHT; // Half screen
}
}
function handleSheetKeydown(event: KeyboardEvent) {
if (!isMobile) {
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
sheetHeight = clamp(sheetHeight + SHEET_STEP, SHEET_MIN_HEIGHT, SHEET_EXPANDED_HEIGHT);
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
sheetHeight = clamp(sheetHeight - SHEET_STEP, SHEET_MIN_HEIGHT, SHEET_EXPANDED_HEIGHT);
if (sheetHeight <= SHEET_DISMISS_THRESHOLD) {
onClose();
}
}
}
// Derived states
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
@ -53,22 +158,50 @@
selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.isVideo);
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
$effect(() => {
if (timelineManager && !patchedTimelineManagers.has(timelineManager)) {
const originalUpdateOptions = timelineManager.updateOptions.bind(timelineManager);
timelineManager.updateOptions = async (opts: Record<string, unknown>) => {
isFetching = true;
try {
await originalUpdateOptions(opts);
} finally {
isFetching = false;
}
};
patchedTimelineManagers.add(timelineManager);
}
});
$effect(() => {
if (timelineManager?.isInitialized && !isFetching) {
lastKnownAssetCount = selectedClusterIds.size > 0 ? assetCount : timelineManager.assetCount;
}
});
const displayedAssetCount = $derived(
isFetching
? lastKnownAssetCount
: selectedClusterIds.size > 0
? assetCount
: (timelineManager?.assetCount ?? lastKnownAssetCount),
);
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.upsertAssets([still]);
timelineManager!.removeAssets([motion.id]);
timelineManager!.upsertAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
timelineManager!.upsertAssets([motion]);
timelineManager!.upsertAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
timelineManager!.removeAssets(assetIds);
assetMultiSelectManager.clear();
};
@ -76,16 +209,66 @@
assetMultiSelectManager.clear();
};
const timelineBoundingBox = $derived(
`${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`,
);
const handleThumbnailClick = (asset: TimelineAsset) => {
onSelect?.([asset.id]);
};
const timelineOptions = $derived({
bbox: timelineBoundingBox,
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
assetFilter: selectedClusterIds,
const timelineBoundingBox = $derived.by(() => {
if (!bbox) {
return '';
}
return `${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`;
});
const timelineOptions = $derived.by(() => {
if (!timelineBoundingBox) {
return undefined;
}
const assetFilter = selectedClusterIds.size > 0 ? selectedClusterIds : undefined;
return {
bbox: timelineBoundingBox,
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
assetFilter,
};
});
const timelineLayoutOptions = $derived.by(() => {
const usableWidth = (panelWidth || 384) - 48;
const rowHeight = clamp(Math.round(usableWidth * 0.55), 180, 280);
const headerHeight = 36;
const gap = 4;
return { rowHeight, headerHeight, gap };
});
let visibleAssetIds = $derived.by(() => {
if (!timelineManager?.isInitialized || !timelineManager.months) {
return undefined;
}
const ids = new SvelteSet<string>();
const top = timelineManager.visibleWindow.top;
const bottom = timelineManager.visibleWindow.bottom;
for (const month of timelineManager.months) {
if (month.isInViewport) {
for (const day of month.timelineDays) {
const dayTop = month.top + day.top;
const dayBottom = dayTop + day.height;
if (isIntersecting(dayTop, dayBottom, top, bottom)) {
for (const asset of day.getAssets()) {
if (asset && asset.id) {
ids.add(asset.id);
}
}
}
}
}
}
return ids;
});
$effect(() => {
onVisibleIdsChange?.(visibleAssetIds);
});
$effect.pre(() => {
@ -94,28 +277,81 @@
});
</script>
<aside class="flex size-full flex-col overflow-hidden bg-immich-bg contain-content dark:bg-immich-dark-bg">
<div class="flex items-center justify-between border-b border-gray-200 pe-1 pb-1 dark:border-immich-dark-gray">
<div class="flex items-center gap-2">
<Icon icon={mdiImageMultiple} size="20" />
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
{$t('assets_count', { values: { count: assetCount } })}
</p>
<svelte:window onpointermove={handlePointerMove} onpointerup={handlePointerUp} />
<div
class="flex min-w-0 flex-col overflow-hidden bg-white shadow-[0_-8px_30px_rgba(0,0,0,0.12)] ease-out sm:shadow-none dark:bg-immich-dark-bg"
class:transition-all={!isDraggingSheet}
class:duration-300={!isDraggingSheet}
style:height={isMobile ? `${sheetHeight}vh` : '100%'}
style:border-top-left-radius={isMobile ? '24px' : '0'}
style:border-top-right-radius={isMobile ? '24px' : '0'}
>
{#if isMobile}
<div
class="flex cursor-grab touch-none justify-center pt-4 pb-2"
class:cursor-grabbing={isDraggingSheet}
onpointerdown={handlePointerDown}
onkeydown={handleSheetKeydown}
role="slider"
aria-orientation="vertical"
aria-label="Drag to resize"
aria-valuenow={sheetHeight}
aria-valuemin={SHEET_MIN_HEIGHT}
aria-valuemax={SHEET_EXPANDED_HEIGHT}
tabindex="0"
>
<div class="h-1.5 w-12 rounded-full bg-gray-300 dark:bg-gray-600"></div>
</div>
{/if}
<div class="flex items-center justify-between px-6 py-2 sm:pt-6">
<div class="flex items-center gap-3">
<Icon icon={mdiImageMultiple} size="24" class="text-immich-primary dark:text-immich-dark-primary" />
<h2 class="text-lg font-medium text-immich-primary dark:text-immich-dark-primary">
{$t('assets_count', { values: { count: displayedAssetCount } })}
</h2>
</div>
<CloseButton onclick={onClose} />
</div>
<div class="min-h-0 flex-1">
<Timeline
bind:timelineManager
enableRouting={false}
options={timelineOptions}
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
/>
<div class="relative min-h-0 min-w-0 flex-1 overflow-y-auto px-6">
<div class="size-full">
{#if timelineOptions}
<Timeline
bind:timelineManager
enableRouting={false}
options={timelineOptions}
layoutOptions={timelineLayoutOptions}
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
onSelect={handleThumbnailClick}
>
{#snippet empty()}
{#if !isFetching}
<div
in:fade={{ duration: 200 }}
class="flex h-full flex-col items-center justify-center text-center opacity-60"
>
<EmptyPlaceholder text="No photos in this area" />
</div>
{/if}
{/snippet}
</Timeline>
{/if}
</div>
{#if isFetching || !timelineManager?.isInitialized}
<div
transition:fade={{ duration: 200 }}
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-md dark:bg-immich-dark-bg/50"
>
<LoadingSpinner />
</div>
{/if}
</div>
</aside>
</div>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
@ -124,13 +360,13 @@
<Portal target="body">
<AssetSelectControlBar>
<CreateSharedLink />
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
<SelectAllAssets timelineManager={timelineManager!} assetInteraction={assetMultiSelectManager} />
<ActionButton action={Actions.AddToAlbum} />
{#if assetMultiSelectManager.isAllUserOwned}
<FavoriteAction
removeFavorite={assetMultiSelectManager.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
onFavorite={(ids, isFavorite) => timelineManager!.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
@ -138,8 +374,8 @@
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
onStack={(result) => updateStackedAssetInTimeline(timelineManager!, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager!, assets)}
/>
{/if}
{#if isLinkActionAvailable}
@ -156,15 +392,15 @@
<ArchiveAction
menuItem
unarchive={assetMultiSelectManager.isAllArchived}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
onArchive={(ids, visibility) => timelineManager!.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if authManager.preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
onAssetDelete={(assetIds) => timelineManager!.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager!.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />