mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
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:
parent
9d6c219276
commit
c2c0ca8032
8 changed files with 1035 additions and 34 deletions
67
e2e/src/ui/specs/map/map.e2e-spec.ts
Normal file
67
e2e/src/ui/specs/map/map.e2e-spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
139
e2e/src/ui/specs/map/utils.ts
Normal file
139
e2e/src/ui/specs/map/utils.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
web/src/lib/components/shared-components/map/utils.ts
Normal file
87
web/src/lib/components/shared-components/map/utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue