diff --git a/e2e/src/ui/mock-network/map-network.ts b/e2e/src/ui/mock-network/map-network.ts new file mode 100644 index 0000000000..1a1a436c4d --- /dev/null +++ b/e2e/src/ui/mock-network/map-network.ts @@ -0,0 +1,33 @@ +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, + }); + }); +}; + 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..deb453a4d5 --- /dev/null +++ b/e2e/src/ui/specs/map/map.e2e-spec.ts @@ -0,0 +1,171 @@ +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 clusters = mapUtils.getClusters(page); + const clusterCount = await clusters.count(); + + const clickCount = Math.min(2, clusterCount); + for (let i = 0; i < clickCount; i++) { + const cluster = clusters.nth(i); + 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+/); + } + }); +}); diff --git a/e2e/src/ui/specs/map/utils.ts b/e2e/src/ui/specs/map/utils.ts new file mode 100644 index 0000000000..facbcded10 --- /dev/null +++ b/e2e/src/ui/specs/map/utils.ts @@ -0,0 +1,135 @@ +import { Page, expect, Locator, ConsoleMessage } 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"]'); + }, + + /** + * 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); + const text = await element.textContent(); + return Number.parseInt(text || '0', 10); + }, + + /** + * Click on a cluster + */ + async clickCluster(page: Page, clusterElement?: Locator, waitMs = 1500) { + const element = clusterElement || this.getFirstCluster(page); + await element.click(); + 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) { + const errors: string[] = []; + const handler = (msg: ConsoleMessage) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }; + + page.on('console', handler); + await callback(); + page.off('console', handler); + + return errors; + }, +}; diff --git a/i18n/en.json b/i18n/en.json index dba0caf393..79034f75b6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1530,6 +1530,11 @@ "map_settings_include_show_partners": "Include Partners", "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", + "exit_fullscreen": "Exit fullscreen", + "fullscreen": "Fullscreen", + "geolocate": "Geolocate", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out", "map_zoom_to_see_photos": "Zoom out to see photos", "mark_all_as_read": "Mark all as read", "mark_as_read": "Mark as read", @@ -1710,6 +1715,8 @@ "open_in_browser": "Open in browser", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", + "switch_to_flat_map": "Switch to flat map", + "switch_to_globe_map": "Switch to globe map", "open_the_search_filters": "Open the search filters", "options": "Options", "or": "or", diff --git a/web/src/lib/components/shared-components/map/Map.svelte b/web/src/lib/components/shared-components/map/Map.svelte index 4e29506e96..7e49fe7f21 100644 --- a/web/src/lib/components/shared-components/map/Map.svelte +++ b/web/src/lib/components/shared-components/map/Map.svelte @@ -1,8 +1,12 @@ + + - { 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} - +
+
+ {#if showSettings} + + {/if} + + {#if !simplified} + + + + {/if} + + {#if onOpenInMapView} + + {/if} + + {#if onToggleTimeline} + + {/if} +
{#if !simplified} - - - - +
+ + +
+ +
+ +
+
{/if} - {/if} - {#if showSettings} - - - - - - - - {/if} - - {#if onOpenInMapView && showSimpleControls} - - - onOpenInMapView()}> - - - - - {/if} +
+ © OpenStreetMap +
+
asFeature(marker)) ?? [], }} id="geojson" - cluster={{ radius: 35, maxZoom: 18 }} + cluster={{ radius: 35, maxZoom: 17 }} > - handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))} - > - {#snippet children({ feature })} -
- {feature.properties?.point_count?.toLocaleString()} -
- {/snippet} -
- { - if (!popup) { - handleAssetClick(event.feature.properties?.id, map); - } - }} - > - {#snippet children({ feature }: { feature: Feature })} - {#if useLocationPin} - - {:else} - {feature.properties?.city - {/if} - {#if popup} - - {@render popup?.({ marker: asMarker(feature) })} - - {/if} - {/snippet} - + {#if $mapShowHeatmap} + + {:else} + handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))} + > + {#snippet children({ feature })} +
+ {feature.properties?.point_count?.toLocaleString()} +
+ {/snippet} +
+ { + if (!popup) { + handleAssetClick(event.feature.properties?.id, map); + } + }} + > + {#snippet children({ feature }: { feature: Feature })} + {#if useLocationPin} + + {:else} + {feature.properties?.city + {/if} + {#if popup} + + {@render popup?.({ marker: asMarker(feature) })} + + {/if} + {/snippet} + + {/if}
{/snippet}
diff --git a/web/src/lib/components/shared-components/map/__tests__/map-cluster-integration.spec.ts b/web/src/lib/components/shared-components/map/__tests__/map-cluster-integration.spec.ts new file mode 100644 index 0000000000..5c8165748c --- /dev/null +++ b/web/src/lib/components/shared-components/map/__tests__/map-cluster-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__/map-utils.spec.ts b/web/src/lib/components/shared-components/map/__tests__/map-utils.spec.ts new file mode 100644 index 0000000000..cc9f925645 --- /dev/null +++ b/web/src/lib/components/shared-components/map/__tests__/map-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 '../map-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/map-utils.ts b/web/src/lib/components/shared-components/map/map-utils.ts new file mode 100644 index 0000000000..79f49c0130 --- /dev/null +++ b/web/src/lib/components/shared-components/map/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/lib/components/timeline/Scrubber.svelte b/web/src/lib/components/timeline/Scrubber.svelte index 03a3e43d8e..ad66134106 100644 --- a/web/src/lib/components/timeline/Scrubber.svelte +++ b/web/src/lib/components/timeline/Scrubber.svelte @@ -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} -
+
{segment.year}
{/if} {#if segment.hasDot} -
+
{/if} {/if}
diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 6bdc16a34a..658dfe0c98 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -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; 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} > -
+
- 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); }; - - - - + - + - + + + + {#if customDateRange}
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 016942c572..20dd7c049f 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -21,7 +21,7 @@ export const lang = persisted('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; } -const defaultMapSettings = { - allowDarkMode: true, +const defaultMapSettings: MapSettings = { + theme: 'system', includeArchived: false, onlyFavorites: false, withPartners: false, @@ -40,15 +40,17 @@ const defaultMapSettings = { relativeDate: '', }; -const persistedObject = (key: string, defaults: T) => - persisted(key, defaults, { - serializer: { - parse: (text) => ({ ...defaults, ...JSON.parse(text ?? null) }), - stringify: JSON.stringify, - }, - }); +export const mapShowHeatmap = persisted('map-show-heatmap', false, {}); -export const mapSettings = persistedObject('map-settings', defaultMapSettings); +export const mapSettings = persisted('map-settings', defaultMapSettings, { + serializer: { + parse: (text) => { + const parsed = (JSON.parse(text ?? 'null') ?? {}) as Partial; + 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', 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..4d743a06cd 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 @@ -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()); let selectedClusterBBox = $state.raw(); + let currentMapBBox = $state.raw(); let isTimelinePanelVisible = $state(false); + let visibleAssetIds = $state>(); + + // 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(); + } + } + } + + {#if featureFlagsManager.value.map} -
-
- {#await import('$lib/components/shared-components/map/Map.svelte')} - {#await delay(timeToLoadTheMap) then} - -
- -
- {/await} - {:then { default: Map }} - +
+
+ {#await Promise.all([import('$lib/components/shared-components/map/Map.svelte'), delay(timeToLoadTheMap)])} +
+ +
+ {:then [{ default: MapComponent }]} + {/await}
- {#if isTimelinePanelVisible && selectedClusterBBox} -
+ {#if isTimelinePanelVisible} + {#if !isMobile} + + {/if} + +
+ {/if}
+ {#if assetViewerManager.isViewing && !isTimelinePanelVisible} {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} 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..102fdca428 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 @@ -1,3 +1,7 @@ + + - +
{#if assetMultiSelectManager.selectionActive} {@const Actions = getAssetBulkActions($t)} @@ -124,13 +360,13 @@ - + {#if assetMultiSelectManager.isAllUserOwned} timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} + onFavorite={(ids, isFavorite) => timelineManager!.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> @@ -138,8 +374,8 @@ {#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected} 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 @@ timelineManager.update(ids, (asset) => (asset.visibility = visibility))} + onArchive={(ids, visibility) => timelineManager!.update(ids, (asset) => (asset.visibility = visibility))} /> {#if authManager.preferences.tags.enabled} {/if} timelineManager.removeAssets(assetIds)} - onUndoDelete={(assets) => timelineManager.upsertAssets(assets)} + onAssetDelete={(assetIds) => timelineManager!.removeAssets(assetIds)} + onUndoDelete={(assets) => timelineManager!.upsertAssets(assets)} />