From c2c0ca8032fcae2021d15972415c6205c8b99a49 Mon Sep 17 00:00:00 2001 From: Miguel Raposo Date: Sat, 6 Jun 2026 16:37:19 +0100 Subject: [PATCH] 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Afonso Mendonça Ribeiro --- e2e/src/ui/specs/map/map.e2e-spec.ts | 67 ++++ e2e/src/ui/specs/map/utils.ts | 139 +++++++ .../shared-components/map/Map.svelte | 99 +++-- .../__tests__/clusters-integration.spec.ts | 210 +++++++++++ .../map/__tests__/utils.spec.ts | 352 ++++++++++++++++++ .../components/shared-components/map/utils.ts | 87 +++++ .../[[assetId=id]]/+page.svelte | 57 ++- .../[[assetId=id]]/MapTimelinePanel.svelte | 58 ++- 8 files changed, 1035 insertions(+), 34 deletions(-) create mode 100644 e2e/src/ui/specs/map/map.e2e-spec.ts create mode 100644 e2e/src/ui/specs/map/utils.ts create mode 100644 web/src/lib/components/shared-components/map/__tests__/clusters-integration.spec.ts create mode 100644 web/src/lib/components/shared-components/map/__tests__/utils.spec.ts create mode 100644 web/src/lib/components/shared-components/map/utils.ts diff --git a/e2e/src/ui/specs/map/map.e2e-spec.ts b/e2e/src/ui/specs/map/map.e2e-spec.ts new file mode 100644 index 0000000000..dfb4e51e51 --- /dev/null +++ b/e2e/src/ui/specs/map/map.e2e-spec.ts @@ -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); + } + }); +}); diff --git a/e2e/src/ui/specs/map/utils.ts b/e2e/src/ui/specs/map/utils.ts new file mode 100644 index 0000000000..cde5aa6359 --- /dev/null +++ b/e2e/src/ui/specs/map/utils.ts @@ -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) { + 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; + }, +}; diff --git a/web/src/lib/components/shared-components/map/Map.svelte b/web/src/lib/components/shared-components/map/Map.svelte index 9172b7bee4..3ad4d8554d 100644 --- a/web/src/lib/components/shared-components/map/Map.svelte +++ b/web/src/lib/components/shared-components/map/Map.svelte @@ -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) | undefined; onSelect?: (assetIds: string[]) => void; onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void; + onBoundsChange?: (bbox: SelectionBBox) => void; + visibleAssetIds?: Set | 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 @@ {/if} + {#if onToggleTimeline} + + + onToggleTimeline?.()}> + + + + + {/if} + {#if onOpenInMapView && showSimpleControls} @@ -390,6 +442,7 @@ { if (!popup) { diff --git a/web/src/lib/components/shared-components/map/__tests__/clusters-integration.spec.ts b/web/src/lib/components/shared-components/map/__tests__/clusters-integration.spec.ts new file mode 100644 index 0000000000..5c8165748c --- /dev/null +++ b/web/src/lib/components/shared-components/map/__tests__/clusters-integration.spec.ts @@ -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; + let mockMapSource: Partial; + let onSelect: Mock; + let onClusterSelect: Mock; + + const createMockLeaf = (id: string, lon: number, lat: number): Feature => ({ + 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[] = [ + { + 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); + }); + }); +}); diff --git a/web/src/lib/components/shared-components/map/__tests__/utils.spec.ts b/web/src/lib/components/shared-components/map/__tests__/utils.spec.ts new file mode 100644 index 0000000000..c44da1af85 --- /dev/null +++ b/web/src/lib/components/shared-components/map/__tests__/utils.spec.ts @@ -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; + let mockMapSource: Partial; + let onSelect: Mock; + let onClusterSelect: Mock; + + const createMockLeaf = (id: string, lon: number, lat: number): Feature => ({ + 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[] = [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [10, 20] }, + properties: {}, + } as unknown as Feature, + ]; + + (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); + }); + }); +}); diff --git a/web/src/lib/components/shared-components/map/utils.ts b/web/src/lib/components/shared-components/map/utils.ts new file mode 100644 index 0000000000..79f49c0130 --- /dev/null +++ b/web/src/lib/components/shared-components/map/utils.ts @@ -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 { + 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); +} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0519398da1..ca347dd3c4 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -22,12 +22,45 @@ let { data }: Props = $props(); let selectedClusterIds = $state.raw(new Set()); let selectedClusterBBox = $state.raw(); + let currentMapBBox = $state.raw(); let isTimelinePanelVisible = $state(false); + let visibleAssetIds = $state>(); + + // 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(); + } + } + } {#if featureFlagsManager.value.map} @@ -69,7 +114,15 @@ {/await} {:then { default: Map }} - + {/await} @@ -78,8 +131,8 @@ (visibleAssetIds = ids)} /> {/if} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte index 9bec1be1f8..6f01b8df45 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte @@ -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; - assetCount: number; onClose: () => void; + onVisibleIdsChange?: (ids: Set | undefined) => void; } - let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props(); + let { bbox, selectedClusterIds, onClose, onVisibleIdsChange }: Props = $props(); let timelineManager = $state() 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(); + 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); + });