feat(web): add look to search feature, timeline filters assets

via map bounding box. Click to zoom, on cluster click it auto
zooms to the perfect bounding box for that area

Co-authored-by: Afonso Mendonça Ribeiro <afonso.mendonca.ribeiro@tecnico.ulisboa.pt>
This commit is contained in:
Miguel Raposo 2026-06-06 16:37:19 +01:00 committed by Miguel Raposo
parent 9d6c219276
commit c2c0ca8032
8 changed files with 1035 additions and 34 deletions

View file

@ -0,0 +1,67 @@
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('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('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);
}
});
});

View file

@ -0,0 +1,139 @@
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);
},
/**
* 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

@ -17,9 +17,9 @@
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 { mdiCog, mdiMap, mdiMapMarker, mdiImageMultiple } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { isEqual, omit } from 'lodash-es';
import { debounce, isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import {
GlobeControl,
@ -30,6 +30,7 @@
type LngLatLike,
type Map,
type MapMouseEvent,
type ExpressionSpecification,
} from 'maplibre-gl';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
@ -47,6 +48,7 @@
ScaleControl,
} from 'svelte-maplibre';
import type { SelectionBBox } from './types';
import { autoZoomCluster } from './utils';
interface Props {
mapMarkers?: MapMarkerResponseDto[];
@ -60,6 +62,10 @@
onOpenInMapView?: (() => Promise<void> | void) | undefined;
onSelect?: (assetIds: string[]) => void;
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
onBoundsChange?: (bbox: SelectionBBox) => void;
visibleAssetIds?: Set<string> | undefined;
isTimelineOpen?: boolean;
onToggleTimeline?: () => void;
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
rounded?: boolean;
@ -79,6 +85,10 @@
onOpenInMapView = undefined,
onSelect = () => {},
onClusterSelect,
onBoundsChange,
visibleAssetIds,
isTimelineOpen = false,
onToggleTimeline,
onClickPoint = () => {},
popup,
rounded = false,
@ -130,33 +140,42 @@
if (!map) {
return;
}
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;
await autoZoomCluster({
map,
mapSource,
clusterId,
onSelect,
onClusterSelect,
});
}
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);
const handleBoundsChange = debounce(() => {
if (!map || !onBoundsChange) {
return;
}
onSelect(ids);
}
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) {
@ -217,6 +236,12 @@
};
}
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();
@ -330,6 +355,18 @@
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
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 = '';
});
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
@ -357,6 +394,21 @@
</Control>
{/if}
{#if onToggleTimeline}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onToggleTimeline?.()}>
<Icon
title={$t('timeline')}
icon={mdiImageMultiple}
size="100%"
class={isTimelineOpen ? 'text-immich-primary dark:text-immich-primary' : 'text-black/80'}
/>
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
@ -390,6 +442,7 @@
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
filter={filter as never}
asButton
onclick={(event) => {
if (!popup) {

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

@ -22,12 +22,45 @@
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>>();
// Mobile Bottom Sheet State
let sheetHeight = $state(50);
let isDraggingSheet = $state(false);
let innerWidth = $state(1024);
let isMobile = $derived(innerWidth < 768);
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
);
};
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,6 +83,18 @@
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>
{#if featureFlagsManager.value.map}
@ -69,7 +114,15 @@
</div>
{/await}
{:then { default: Map }}
<Map hash onSelect={onViewAssets} {onClusterSelect} />
<Map
hash
onSelect={onViewAssets}
{onClusterSelect}
{onBoundsChange}
{visibleAssetIds}
isTimelineOpen={isTimelinePanelVisible}
onToggleTimeline={toggleTimeline}
/>
{/await}
</div>
@ -78,8 +131,8 @@
<MapTimelinePanel
bbox={selectedClusterBBox}
{selectedClusterIds}
assetCount={selectedClusterIds.size}
onClose={closeTimelinePanel}
onVisibleIdsChange={(ids) => (visibleAssetIds = ids)}
/>
</div>
{/if}

View file

@ -34,15 +34,16 @@
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
import { ceil, floor } from 'lodash-es';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
bbox: SelectionBBox;
selectedClusterIds: Set<string>;
assetCount: number;
onClose: () => void;
onVisibleIdsChange?: (ids: Set<string> | undefined) => void;
}
let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props();
let { bbox, selectedClusterIds, onClose, onVisibleIdsChange }: Props = $props();
let timelineManager = $state<TimelineManager>() as TimelineManager;
let selectedAssets = $derived(assetMultiSelectManager.assets);
@ -80,18 +81,57 @@
`${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`,
);
const timelineOptions = $derived({
bbox: timelineBoundingBox,
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
assetFilter: selectedClusterIds,
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,
};
});
$effect.pre(() => {
void timelineOptions;
assetMultiSelectManager.clear();
});
const isIntersecting = (top1: number, bottom1: number, top2: number, bottom2: number) => {
return Math.max(top1, top2) <= Math.min(bottom1, bottom2);
};
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);
});
</script>
<aside class="flex size-full flex-col overflow-hidden bg-immich-bg contain-content dark:bg-immich-dark-bg">
@ -99,7 +139,7 @@
<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 } })}
{$t('assets_count', { values: { count: timelineManager?.assetsCount ?? 0 } })}
</p>
</div>
<CloseButton onclick={onClose} />