feat(map): new modern map and timeline to match UI/UX
of the rest of the webapp and mobile app - mobile map globe layout and i18n usage - auto zoom on map image clusters on click - adjust format to pretier standars - add e2e, unit and integration tests for the new map. Co-authored-by: Afonso Mendonça Ribeiro <afonso.mendonca.ribeiro@tecnico.ulisboa.pt>pull/28676/head
parent
c42cea5ca9
commit
344d1cd1dd
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script lang="ts" module>
|
||||
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url';
|
||||
import { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
|
||||
import maplibregl, { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
|
||||
import MaplibreWorker from 'maplibre-gl/dist/maplibre-gl-csp-worker?worker';
|
||||
import { Protocol } from 'pmtiles';
|
||||
|
||||
// @ts-expect-error maplibregl types do not include workerClass, but it is required for CSP
|
||||
maplibregl.workerClass = MaplibreWorker;
|
||||
|
||||
let protocol = new Protocol();
|
||||
void addProtocol('pmtiles', protocol.tile);
|
||||
void setRTLTextPlugin(mapboxRtlUrl, true);
|
||||
|
|
@ -13,59 +17,63 @@
|
|||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { mapSettings, mapShowHeatmap } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
|
||||
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { Icon, modalManager, Theme, themeManager } from '@immich/ui';
|
||||
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import {
|
||||
mdiCog,
|
||||
mdiCrosshairsGps,
|
||||
mdiFullscreen,
|
||||
mdiFullscreenExit,
|
||||
mdiImageMultiple,
|
||||
mdiEarth,
|
||||
mdiMap,
|
||||
mdiMapMarker,
|
||||
mdiMinus,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import type { Feature, Point } from 'geojson';
|
||||
import { debounce, isEqual } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import {
|
||||
GlobeControl,
|
||||
LngLat,
|
||||
LngLatBounds,
|
||||
Marker,
|
||||
type ExpressionSpecification,
|
||||
type GeoJSONSource,
|
||||
type LngLatLike,
|
||||
type ProjectionSpecification,
|
||||
type Map,
|
||||
type MapMouseEvent,
|
||||
} from 'maplibre-gl';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import {
|
||||
AttributionControl,
|
||||
Control,
|
||||
ControlButton,
|
||||
ControlGroup,
|
||||
FullscreenControl,
|
||||
GeoJSON,
|
||||
GeolocateControl,
|
||||
MapLibre,
|
||||
MarkerLayer,
|
||||
NavigationControl,
|
||||
Popup,
|
||||
ScaleControl,
|
||||
} from 'svelte-maplibre';
|
||||
import { GeoJSON, HeatmapLayer, MapLibre, MarkerLayer, Popup } from 'svelte-maplibre';
|
||||
import type { SelectionBBox } from './types';
|
||||
import { autoZoomCluster } from './map-utils';
|
||||
|
||||
interface Props {
|
||||
mapMarkers?: MapMarkerResponseDto[];
|
||||
showSettings?: boolean;
|
||||
zoom?: number | undefined;
|
||||
center?: LngLatLike | undefined;
|
||||
hash?: boolean;
|
||||
simplified?: boolean;
|
||||
clickable?: boolean;
|
||||
useLocationPin?: boolean;
|
||||
onOpenInMapView?: (() => Promise<void> | void) | undefined;
|
||||
onSelect?: (assetIds: string[]) => void;
|
||||
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
|
||||
onBoundsChange?: (bbox: SelectionBBox) => void;
|
||||
visibleAssetIds?: Set<string> | undefined;
|
||||
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
|
||||
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
|
||||
rounded?: boolean;
|
||||
showSimpleControls?: boolean;
|
||||
autoFitBounds?: boolean;
|
||||
isTimelineOpen?: boolean;
|
||||
onToggleTimeline?: () => void;
|
||||
sheetHeight?: number;
|
||||
isDraggingSheet?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -73,26 +81,28 @@
|
|||
showSettings = true,
|
||||
zoom = undefined,
|
||||
center = $bindable(undefined),
|
||||
hash = false,
|
||||
simplified = false,
|
||||
clickable = false,
|
||||
useLocationPin = false,
|
||||
onOpenInMapView = undefined,
|
||||
onSelect = () => {},
|
||||
onClusterSelect,
|
||||
onBoundsChange,
|
||||
visibleAssetIds,
|
||||
onClickPoint = () => {},
|
||||
popup,
|
||||
rounded = false,
|
||||
showSimpleControls = true,
|
||||
autoFitBounds = true,
|
||||
isTimelineOpen = false,
|
||||
onToggleTimeline,
|
||||
sheetHeight = 50,
|
||||
isDraggingSheet = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate initial bounds from markers once during initialization
|
||||
const initialBounds = (() => {
|
||||
if (!autoFitBounds || center || zoom !== undefined || !mapMarkers || mapMarkers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bounds = new LngLatBounds();
|
||||
for (const marker of mapMarkers) {
|
||||
bounds.extend([marker.lon, marker.lat]);
|
||||
|
|
@ -100,26 +110,36 @@
|
|||
return bounds;
|
||||
})();
|
||||
|
||||
let map: Map | undefined = $state();
|
||||
let map: Map | undefined = $state.raw();
|
||||
let marker: Marker | null = null;
|
||||
let abortController: AbortController;
|
||||
let isFullscreen = $state(false);
|
||||
let isGlobeView = $state(false);
|
||||
|
||||
const mapTheme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.Light);
|
||||
let innerWidth = $state(1024);
|
||||
let isMobile = $derived(innerWidth < 768);
|
||||
let hideMapControls = $derived(isMobile && sheetHeight > 40);
|
||||
|
||||
const mapTheme = $derived(themeManager.value);
|
||||
const styleUrl = $derived(
|
||||
mapTheme === Theme.Dark ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
|
||||
);
|
||||
const mapProjection = $derived<ProjectionSpecification>({
|
||||
type: isGlobeView ? 'globe' : 'mercator',
|
||||
});
|
||||
|
||||
export function addClipMapMarker(lng: number, lat: number) {
|
||||
if (map) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
center = { lng, lat };
|
||||
marker = new Marker().setLngLat([lng, lat]).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
void addClipMapMarker;
|
||||
|
||||
function handleAssetClick(assetId: string, map: Map | null) {
|
||||
if (!map) {
|
||||
return;
|
||||
|
|
@ -127,47 +147,54 @@
|
|||
onSelect([assetId]);
|
||||
}
|
||||
|
||||
async function handleClusterClick(clusterId: number, map: Map | null) {
|
||||
if (!map) {
|
||||
async function handleClusterClick(clusterId: number, mapInstance: Map | null) {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
const mapSource = mapInstance.getSource('geojson') as GeoJSONSource;
|
||||
|
||||
const mapSource = map.getSource('geojson') as GeoJSONSource;
|
||||
const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0);
|
||||
const ids = leaves.map((leaf) => leaf.properties?.id as string);
|
||||
|
||||
if (onClusterSelect && ids.length > 1) {
|
||||
const [firstLongitude, firstLatitude] = (leaves[0].geometry as Point).coordinates;
|
||||
let west = firstLongitude;
|
||||
let south = firstLatitude;
|
||||
let east = firstLongitude;
|
||||
let north = firstLatitude;
|
||||
|
||||
for (const leaf of leaves.slice(1)) {
|
||||
const [longitude, latitude] = (leaf.geometry as Point).coordinates;
|
||||
west = Math.min(west, longitude);
|
||||
south = Math.min(south, latitude);
|
||||
east = Math.max(east, longitude);
|
||||
north = Math.max(north, latitude);
|
||||
}
|
||||
|
||||
const bbox = { west, south, east, north };
|
||||
onClusterSelect(ids, bbox);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(ids);
|
||||
await autoZoomCluster({
|
||||
map: mapInstance,
|
||||
mapSource,
|
||||
clusterId,
|
||||
onSelect,
|
||||
onClusterSelect,
|
||||
});
|
||||
}
|
||||
|
||||
const handleBoundsChange = debounce(() => {
|
||||
if (!map || !onBoundsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
if (!bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
let west = bounds.getWest();
|
||||
let east = bounds.getEast();
|
||||
let south = Math.max(-90, Math.min(90, bounds.getSouth()));
|
||||
let north = Math.max(-90, Math.min(90, bounds.getNorth()));
|
||||
|
||||
if (east - west >= 360) {
|
||||
west = -180;
|
||||
east = 180;
|
||||
} else {
|
||||
west = bounds.getSouthWest().wrap().lng;
|
||||
east = bounds.getNorthEast().wrap().lng;
|
||||
}
|
||||
|
||||
onBoundsChange({ west, south, east, north });
|
||||
}, 200);
|
||||
|
||||
function handleMapClick(event: MapMouseEvent) {
|
||||
if (clickable) {
|
||||
const { lng, lat } = event.lngLat;
|
||||
onClickPoint({ lng, lat });
|
||||
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
if (map) {
|
||||
marker = new Marker().setLngLat([lng, lat]).addTo(map);
|
||||
}
|
||||
|
|
@ -175,7 +202,6 @@
|
|||
}
|
||||
|
||||
type FeaturePoint = Feature<Point, { id: string; city: string | null; state: string | null; country: string | null }>;
|
||||
|
||||
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
|
|
@ -189,7 +215,7 @@
|
|||
};
|
||||
};
|
||||
|
||||
const asMarker = (feature: Feature<Geometry, GeoJsonProperties>): MapMarkerResponseDto => {
|
||||
const asMarker = (feature: Feature): MapMarkerResponseDto => {
|
||||
const featurePoint = feature as FeaturePoint;
|
||||
const coords = LngLat.convert(featurePoint.geometry.coordinates as [number, number]);
|
||||
return {
|
||||
|
|
@ -204,20 +230,22 @@
|
|||
|
||||
function getFileCreatedDates() {
|
||||
const { relativeDate, dateAfter, dateBefore } = $mapSettings;
|
||||
|
||||
if (relativeDate) {
|
||||
const duration = Duration.fromISO(relativeDate);
|
||||
return {
|
||||
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
|
||||
};
|
||||
return { fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
fileCreatedAfter: dateAfter?.toUTC().toISO(),
|
||||
fileCreatedBefore: dateBefore?.toUTC().toISO(),
|
||||
};
|
||||
}
|
||||
|
||||
const filter: ExpressionSpecification | undefined = $derived.by(() =>
|
||||
visibleAssetIds
|
||||
? (['in', ['get', 'id'], ['literal', Array.from(visibleAssetIds)]] as unknown as ExpressionSpecification)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
async function loadMapMarkers() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
|
|
@ -236,28 +264,27 @@
|
|||
withPartners: withPartners || undefined,
|
||||
withSharedAlbums: withSharedAlbums || undefined,
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
},
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSettingsClick = async () => {
|
||||
const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
|
||||
if (settings) {
|
||||
const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
|
||||
$mapSettings = settings;
|
||||
|
||||
if (shouldUpdate) {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
}
|
||||
}
|
||||
const handleSettingsClick = () => {
|
||||
handlePromiseError(
|
||||
modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } }).then(async (settings) => {
|
||||
if (settings) {
|
||||
const shouldUpdate = !isEqual(settings, $mapSettings);
|
||||
$mapSettings = settings;
|
||||
if (shouldUpdate) {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
afterNavigate(() => {
|
||||
if (map) {
|
||||
map.resize();
|
||||
|
||||
if (globalThis.location.hash) {
|
||||
const hashChangeEvent = new HashChangeEvent('hashchange');
|
||||
globalThis.dispatchEvent(hashChangeEvent);
|
||||
|
|
@ -265,10 +292,12 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!mapMarkers) {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
}
|
||||
onMount(() => {
|
||||
void loadMapMarkers().then((markers) => {
|
||||
if (!mapMarkers) {
|
||||
mapMarkers = markers;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -279,23 +308,15 @@
|
|||
map?.setStyle(styleUrl, {
|
||||
transformStyle: (previousStyle, nextStyle) => {
|
||||
if (previousStyle) {
|
||||
// Preserves the custom map markers from the previous style when the theme is switched
|
||||
// Required until https://github.com/dimfeld/svelte-maplibre/issues/146 is fixed
|
||||
const customLayers = previousStyle.layers.filter((l) => l.type == 'fill' && l.source == 'geojson');
|
||||
const customLayers = previousStyle.layers.filter((l) => l.type === 'fill' && l.source === 'geojson');
|
||||
const layers = nextStyle.layers.concat(customLayers);
|
||||
const sources = nextStyle.sources;
|
||||
|
||||
for (const [key, value] of Object.entries(previousStyle.sources || {})) {
|
||||
if (key.startsWith('geojson')) {
|
||||
sources[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...nextStyle,
|
||||
sources,
|
||||
layers,
|
||||
};
|
||||
return { ...nextStyle, sources, layers };
|
||||
}
|
||||
return nextStyle;
|
||||
},
|
||||
|
|
@ -306,20 +327,72 @@
|
|||
if (!center || !zoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => map?.jumpTo({ center, zoom }));
|
||||
});
|
||||
|
||||
const onAssetsDelete = async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
$effect(() => {
|
||||
const currentMap = map;
|
||||
if (!currentMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile && isTimelineOpen) {
|
||||
const bottomPadding = (sheetHeight / 100) * innerHeight;
|
||||
untrack(() => {
|
||||
currentMap.setPadding({ top: 0, left: 0, right: 0, bottom: bottomPadding });
|
||||
});
|
||||
} else {
|
||||
untrack(() => {
|
||||
currentMap.setPadding({ top: 0, left: 0, right: 0, bottom: 0 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onAssetsDelete = () => {
|
||||
handlePromiseError(
|
||||
loadMapMarkers().then((markers) => {
|
||||
mapMarkers = markers;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const container = map.getContainer().parentElement;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
isFullscreen = false;
|
||||
} else {
|
||||
container.requestFullscreen().catch(() => {});
|
||||
isFullscreen = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocate = () => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => map?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 15 }),
|
||||
(err) => handlePromiseError(Promise.reject(err)),
|
||||
{ enableHighAccuracy: true },
|
||||
);
|
||||
};
|
||||
|
||||
const toggleMapProjection = () => {
|
||||
isGlobeView = !isGlobeView;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
hash={false}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{zoom}
|
||||
|
|
@ -328,46 +401,144 @@
|
|||
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
projection={mapProjection}
|
||||
onload={(event: Map) => {
|
||||
event.setMaxZoom(18);
|
||||
event.on('click', handleMapClick);
|
||||
if (!simplified) {
|
||||
event.addControl(new GlobeControl(), 'top-left');
|
||||
}
|
||||
event.on('moveend', handleBoundsChange);
|
||||
event.on('zoomend', handleBoundsChange);
|
||||
|
||||
handleBoundsChange();
|
||||
|
||||
event.on('mouseenter', 'geojson-clusters', () => {
|
||||
event.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
event.on('mouseleave', 'geojson-clusters', () => {
|
||||
event.getCanvas().style.cursor = '';
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
});
|
||||
}}
|
||||
bind:map
|
||||
>
|
||||
{#snippet children({ map }: { map: Map })}
|
||||
{#if showSimpleControls}
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
<div class="pointer-events-none absolute inset-0 z-10 p-4">
|
||||
<div class="pointer-events-auto absolute top-4 right-4 flex flex-col gap-3">
|
||||
{#if showSettings}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={$t('map_settings')}
|
||||
onclick={handleSettingsClick}
|
||||
>
|
||||
<Icon icon={mdiCog} size="24" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !simplified}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={isGlobeView ? $t('switch_to_flat_map') : $t('switch_to_globe_map')}
|
||||
aria-pressed={isGlobeView}
|
||||
onclick={toggleMapProjection}
|
||||
>
|
||||
<Icon icon={isGlobeView ? mdiMap : mdiEarth} size="24" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={isFullscreen ? $t('exit_fullscreen') : $t('fullscreen')}
|
||||
onclick={toggleFullscreen}
|
||||
>
|
||||
<Icon icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen} size="24" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onOpenInMapView}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={$t('open_in_map_view')}
|
||||
onclick={() => void onOpenInMapView()}
|
||||
>
|
||||
<Icon icon={mdiMap} size="24" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onToggleTimeline}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={$t('timeline')}
|
||||
onclick={() => onToggleTimeline?.()}
|
||||
>
|
||||
<Icon
|
||||
icon={mdiImageMultiple}
|
||||
size="24"
|
||||
class={isTimelineOpen ? 'text-immich-primary dark:text-immich-primary' : ''}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
<div
|
||||
class="absolute right-4 flex flex-col gap-3 transition-all duration-300 ease-out"
|
||||
class:opacity-0={hideMapControls}
|
||||
class:pointer-events-none={hideMapControls}
|
||||
class:pointer-events-auto={!hideMapControls}
|
||||
class:transition-none={isDraggingSheet}
|
||||
style:bottom={isTimelineOpen && isMobile ? `calc(${Math.min(sheetHeight, 40)}vh + 1rem)` : '2.5rem'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center rounded-full border border-white/20 bg-white/70 text-black/80 shadow-lg backdrop-blur-md transition-all hover:bg-white/90 dark:border-white/10 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={$t('geolocate')}
|
||||
onclick={handleLocate}
|
||||
>
|
||||
<Icon icon={mdiCrosshairsGps} size="24" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-2xl border border-white/20 shadow-lg backdrop-blur-md dark:border-white/10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center bg-white/70 text-black/80 transition-all hover:bg-white/90 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={$t('zoom_in')}
|
||||
onclick={() => map.zoomIn()}
|
||||
>
|
||||
<Icon icon={mdiPlus} size="24" />
|
||||
</button>
|
||||
<div class="h-px w-full bg-black/10 dark:bg-white/10"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-11 items-center justify-center bg-white/70 text-black/80 transition-all hover:bg-white/90 dark:bg-immich-dark-gray/70 dark:text-white/80 dark:hover:bg-immich-dark-gray/90"
|
||||
title={$t('zoom_out')}
|
||||
onclick={() => map.zoomOut()}
|
||||
>
|
||||
<Icon icon={mdiMinus} size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={handleSettingsClick}>
|
||||
<Icon icon={mdiCog} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
{#if onOpenInMapView && showSimpleControls}
|
||||
<Control position="top-right">
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={() => onOpenInMapView()}>
|
||||
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute left-4 rounded-sm bg-white/70 px-2 py-0.5 text-[11px] font-medium text-black/80 shadow-sm backdrop-blur-md ease-out {isDraggingSheet
|
||||
? 'transition-none'
|
||||
: 'transition-all duration-300'} dark:bg-immich-dark-gray/70 dark:text-white/80"
|
||||
class:opacity-0={hideMapControls}
|
||||
class:pointer-events-none={hideMapControls}
|
||||
class:pointer-events-auto={!hideMapControls}
|
||||
style:bottom={isTimelineOpen && isMobile ? `calc(${Math.min(sheetHeight, 40)}vh + 0.5rem)` : '0.5rem'}
|
||||
>
|
||||
© OpenStreetMap
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GeoJSON
|
||||
data={{
|
||||
|
|
@ -375,51 +546,78 @@
|
|||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
cluster={{ radius: 35, maxZoom: 17 }}
|
||||
>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-immich-primary font-mono font-bold text-white opacity-90 shadow-lg transition-all duration-200 hover:bg-immich-dark-primary hover:text-immich-dark-bg"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
{#if $mapShowHeatmap}
|
||||
<HeatmapLayer
|
||||
id="asset-heatmap-layer"
|
||||
paint={{
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0,
|
||||
'rgba(255, 255, 255, 0)',
|
||||
0.2,
|
||||
'rgb(255, 235, 59)',
|
||||
0.4,
|
||||
'rgb(255, 152, 0)',
|
||||
0.7,
|
||||
'rgb(244, 67, 54)',
|
||||
1,
|
||||
'rgb(183, 28, 28)',
|
||||
] as ExpressionSpecification,
|
||||
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 0.8, 15, 2] as ExpressionSpecification,
|
||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 20, 15, 40] as ExpressionSpecification,
|
||||
'heatmap-opacity': 0.8,
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-immich-primary font-mono font-bold text-white opacity-90 shadow-lg transition-all duration-200 hover:bg-immich-dark-primary hover:text-immich-dark-bg"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
filter={filter as never}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
{/if}
|
||||
</GeoJSON>
|
||||
{/snippet}
|
||||
</MapLibre>
|
||||
|
|
|
|||
|
|
@ -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 '../map-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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -508,7 +508,7 @@
|
|||
aria-valuemax={toScrollY(1)}
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute inset-e-0 z-1 select-none hover:cursor-row-resize"
|
||||
class="absolute inset-e-0 z-10 select-none hover:cursor-row-resize"
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
|
@ -589,12 +589,16 @@
|
|||
>
|
||||
{#if !usingMobileDevice}
|
||||
{#if segment.hasLabel}
|
||||
<div class="absolute inset-e-5 bottom-0 font-mono text-[13px] dark:text-immich-dark-fg">
|
||||
<div
|
||||
class="absolute inset-e-6 bottom-0 z-10 origin-right -translate-y-1/2 scale-90 text-[11px] font-bold tracking-wider whitespace-nowrap text-gray-400 uppercase transition-all hover:scale-100 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
|
||||
>
|
||||
{segment.year}
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.hasDot}
|
||||
<div class="absolute inset-e-3 bottom-0 size-1 rounded-full bg-gray-300"></div>
|
||||
<div
|
||||
class="absolute inset-e-3 bottom-0 size-1.5 rounded-full bg-gray-200 transition-colors hover:bg-immich-primary dark:bg-gray-800 dark:hover:bg-immich-dark-primary"
|
||||
></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import type {
|
||||
TimelineAsset,
|
||||
TimelineManagerLayoutOptions,
|
||||
TimelineManagerOptions,
|
||||
ViewportTopMonth,
|
||||
} from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||
|
|
@ -36,6 +41,7 @@
|
|||
enableRouting: boolean;
|
||||
timelineManager?: TimelineManager;
|
||||
options?: TimelineManagerOptions;
|
||||
layoutOptions?: Partial<TimelineManagerLayoutOptions>;
|
||||
assetInteraction: AssetMultiSelectManager;
|
||||
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
|
||||
withStacked?: boolean;
|
||||
|
|
@ -68,6 +74,7 @@
|
|||
enableRouting,
|
||||
timelineManager = $bindable(),
|
||||
options,
|
||||
layoutOptions: layoutOptionsOverride = {},
|
||||
assetInteraction,
|
||||
removeAction = null,
|
||||
withStacked = false,
|
||||
|
|
@ -89,7 +96,6 @@
|
|||
$effect(() => options && void timelineManager.updateOptions(options));
|
||||
|
||||
let scrollableElement: HTMLElement | undefined = $state();
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let invisible = $state(true);
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
|
|
@ -114,7 +120,7 @@
|
|||
rowHeight: 235,
|
||||
headerHeight: 48,
|
||||
};
|
||||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
timelineManager.setLayoutOptions({ ...layoutOptions, ...layoutOptionsOverride });
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -257,6 +263,12 @@
|
|||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
|
||||
const handleAssetGridScroll = () => {
|
||||
handleTimelineScroll();
|
||||
timelineManager.updateSlidingWindow();
|
||||
updateIsScrolling();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
invisible = false;
|
||||
|
|
@ -622,14 +634,9 @@
|
|||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
bind:this={scrollableElement}
|
||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
||||
onscroll={handleAssetGridScroll}
|
||||
>
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible
|
||||
style:height={timelineManager.totalViewerHeight + 'px'}
|
||||
>
|
||||
<section id="virtual-timeline" class:invisible style:height={timelineManager.totalViewerHeight + 'px'}>
|
||||
<section
|
||||
bind:clientHeight={timelineManager.topSectionHeight}
|
||||
class:invisible
|
||||
|
|
|
|||
|
|
@ -181,28 +181,28 @@ export class TimelineMonth {
|
|||
const timelineAsset: TimelineAsset = {
|
||||
city: bucketAssets.city?.[i] ?? null,
|
||||
country: bucketAssets.country?.[i] ?? null,
|
||||
duration: bucketAssets.duration[i],
|
||||
duration: bucketAssets.duration?.[i] ?? null,
|
||||
id: bucketAssets.id[i],
|
||||
visibility: bucketAssets.visibility[i],
|
||||
isFavorite: bucketAssets.isFavorite[i],
|
||||
isImage: bucketAssets.isImage[i],
|
||||
isTrashed: bucketAssets.isTrashed[i],
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
visibility: bucketAssets.visibility?.[i] ?? bucketAssets.visibility?.[0],
|
||||
isFavorite: bucketAssets.isFavorite?.[i] ?? false,
|
||||
isImage: bucketAssets.isImage?.[i] ?? true,
|
||||
isTrashed: bucketAssets.isTrashed?.[i] ?? false,
|
||||
isVideo: !(bucketAssets.isImage?.[i] ?? true),
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId?.[i] ?? null,
|
||||
localDateTime,
|
||||
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
ratio: bucketAssets.ratio[i],
|
||||
ownerId: bucketAssets.ownerId?.[i] ?? '',
|
||||
projectionType: bucketAssets.projectionType?.[i] ?? null,
|
||||
ratio: bucketAssets.ratio?.[i] ?? 1,
|
||||
stack: bucketAssets.stack?.[i]
|
||||
? {
|
||||
id: bucketAssets.stack[i]![0],
|
||||
primaryAssetId: bucketAssets.id[i],
|
||||
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
|
||||
}
|
||||
id: bucketAssets.stack[i]![0],
|
||||
primaryAssetId: bucketAssets.id[i],
|
||||
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
|
||||
}
|
||||
: null,
|
||||
thumbhash: bucketAssets.thumbhash[i],
|
||||
thumbhash: bucketAssets.thumbhash?.[i] ?? null,
|
||||
people: null, // People are not included in the bucket assets
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { MapSettings } from '$lib/stores/preferences.store';
|
||||
import { mapShowHeatmap, type MapSettings } from '$lib/stores/preferences.store';
|
||||
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
|
||||
import { Duration } from 'luxon';
|
||||
import { untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
|
|
@ -11,32 +12,34 @@
|
|||
};
|
||||
|
||||
let { settings: initialValues, onClose }: Props = $props();
|
||||
let settings = $state(initialValues);
|
||||
let settings = $state({ ...untrack(() => initialValues) });
|
||||
let showHeatmap = $state($mapShowHeatmap);
|
||||
|
||||
let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
|
||||
|
||||
const onSubmit = () => {
|
||||
$mapShowHeatmap = showHeatmap;
|
||||
onClose(settings);
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('map_settings')} {onClose} {onSubmit} size="small">
|
||||
<Stack gap={4}>
|
||||
<Field label={$t('allow_dark_mode')}>
|
||||
<Switch bind:checked={settings.allowDarkMode} />
|
||||
</Field>
|
||||
<Field label={$t('only_favorites')}>
|
||||
<Field label={$t('map_settings_only_show_favorites')}>
|
||||
<Switch bind:checked={settings.onlyFavorites} />
|
||||
</Field>
|
||||
<Field label={$t('include_archived')}>
|
||||
<Field label={$t('map_settings_include_show_archived')}>
|
||||
<Switch bind:checked={settings.includeArchived} />
|
||||
</Field>
|
||||
<Field label={$t('include_shared_partner_assets')}>
|
||||
<Field label={$t('map_settings_include_show_partners')}>
|
||||
<Switch bind:checked={settings.withPartners} />
|
||||
</Field>
|
||||
<Field label={$t('include_shared_albums')}>
|
||||
<Switch bind:checked={settings.withSharedAlbums} />
|
||||
</Field>
|
||||
<Field label="Show Heatmap">
|
||||
<Switch bind:checked={showHeatmap} />
|
||||
</Field>
|
||||
|
||||
{#if customDateRange}
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const lang = persisted<string>('lang', preferredLocale || defaultLang.cod
|
|||
});
|
||||
|
||||
export interface MapSettings {
|
||||
allowDarkMode: boolean;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
includeArchived: boolean;
|
||||
onlyFavorites: boolean;
|
||||
withPartners: boolean;
|
||||
|
|
@ -31,8 +31,8 @@ export interface MapSettings {
|
|||
dateBefore?: DateTime<true>;
|
||||
}
|
||||
|
||||
const defaultMapSettings = {
|
||||
allowDarkMode: true,
|
||||
const defaultMapSettings: MapSettings = {
|
||||
theme: 'system',
|
||||
includeArchived: false,
|
||||
onlyFavorites: false,
|
||||
withPartners: false,
|
||||
|
|
@ -40,15 +40,17 @@ const defaultMapSettings = {
|
|||
relativeDate: '',
|
||||
};
|
||||
|
||||
const persistedObject = <T>(key: string, defaults: T) =>
|
||||
persisted<T>(key, defaults, {
|
||||
serializer: {
|
||||
parse: (text) => ({ ...defaults, ...JSON.parse(text ?? null) }),
|
||||
stringify: JSON.stringify,
|
||||
},
|
||||
});
|
||||
export const mapShowHeatmap = persisted<boolean>('map-show-heatmap', false, {});
|
||||
|
||||
export const mapSettings = persistedObject<MapSettings>('map-settings', defaultMapSettings);
|
||||
export const mapSettings = persisted<MapSettings>('map-settings', defaultMapSettings, {
|
||||
serializer: {
|
||||
parse: (text) => {
|
||||
const parsed = (JSON.parse(text ?? 'null') ?? {}) as Partial<MapSettings>;
|
||||
return { ...defaultMapSettings, ...parsed };
|
||||
},
|
||||
stringify: JSON.stringify,
|
||||
},
|
||||
});
|
||||
|
||||
export interface AlbumViewSettings {
|
||||
view: string;
|
||||
|
|
@ -71,11 +73,6 @@ export interface PlacesViewSettings {
|
|||
};
|
||||
}
|
||||
|
||||
export interface SidebarSettings {
|
||||
people: boolean;
|
||||
sharing: boolean;
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
Asc = 'asc',
|
||||
Desc = 'desc',
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@
|
|||
import { handlePromiseError } from '$lib/utils';
|
||||
import { delay } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiDragHorizontal } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,12 +25,84 @@
|
|||
let { data }: Props = $props();
|
||||
let selectedClusterIds = $state.raw(new Set<string>());
|
||||
let selectedClusterBBox = $state.raw<SelectionBBox>();
|
||||
let currentMapBBox = $state.raw<SelectionBBox>();
|
||||
let isTimelinePanelVisible = $state(false);
|
||||
let visibleAssetIds = $state<Set<string>>();
|
||||
|
||||
// Desktop Sidebar State
|
||||
const DEFAULT_PANEL_WIDTH = 384;
|
||||
const DESKTOP_PANEL_MIN_WIDTH = 320;
|
||||
const DESKTOP_PANEL_MAX_WIDTH = 700;
|
||||
let timelinePanelWidth = $state(DEFAULT_PANEL_WIDTH);
|
||||
|
||||
// Mobile Bottom Sheet State
|
||||
let sheetHeight = $state(50);
|
||||
let isDraggingSheet = $state(false);
|
||||
let innerWidth = $state(1024);
|
||||
let isMobile = $derived(innerWidth < 768);
|
||||
|
||||
const clampedTimelinePanelWidth = $derived(
|
||||
Math.min(Math.max(timelinePanelWidth, DESKTOP_PANEL_MIN_WIDTH), DESKTOP_PANEL_MAX_WIDTH),
|
||||
);
|
||||
|
||||
const isSameBbox = (a: SelectionBBox | undefined, b: SelectionBBox) => {
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
const epsilon = 0.000_01;
|
||||
return (
|
||||
Math.abs(a.west - b.west) <= epsilon &&
|
||||
Math.abs(a.south - b.south) <= epsilon &&
|
||||
Math.abs(a.east - b.east) <= epsilon &&
|
||||
Math.abs(a.north - b.north) <= epsilon
|
||||
);
|
||||
};
|
||||
|
||||
let isResizingPanel = $state(false);
|
||||
let resizeStartX = $state(0);
|
||||
let resizeStartWidth = $state(0);
|
||||
|
||||
function handleResizeStart(event: PointerEvent) {
|
||||
if (!isMobile) {
|
||||
event.preventDefault();
|
||||
isResizingPanel = true;
|
||||
resizeStartX = event.clientX;
|
||||
resizeStartWidth = timelinePanelWidth;
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeMove(event: PointerEvent) {
|
||||
if (!isResizingPanel) {
|
||||
return;
|
||||
}
|
||||
const deltaX = resizeStartX - event.clientX;
|
||||
const newWidth = resizeStartWidth + deltaX;
|
||||
timelinePanelWidth = Math.min(Math.max(newWidth, DESKTOP_PANEL_MIN_WIDTH), DESKTOP_PANEL_MAX_WIDTH);
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
isResizingPanel = false;
|
||||
}
|
||||
|
||||
function closeTimelinePanel() {
|
||||
isTimelinePanelVisible = false;
|
||||
selectedClusterBBox = undefined;
|
||||
selectedClusterIds = new Set();
|
||||
visibleAssetIds = undefined;
|
||||
sheetHeight = 50; // Reset for next open
|
||||
}
|
||||
|
||||
function toggleTimeline() {
|
||||
isTimelinePanelVisible = !isTimelinePanelVisible;
|
||||
if (!isTimelinePanelVisible) {
|
||||
closeTimelinePanel();
|
||||
} else if (currentMapBBox) {
|
||||
selectedClusterBBox = currentMapBBox;
|
||||
selectedClusterIds = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -50,41 +125,89 @@
|
|||
assetViewerManager.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}
|
||||
|
||||
function onBoundsChange(bbox: SelectionBBox) {
|
||||
currentMapBBox = bbox;
|
||||
if (isTimelinePanelVisible) {
|
||||
if (!isSameBbox(selectedClusterBBox, bbox)) {
|
||||
selectedClusterBBox = bbox;
|
||||
}
|
||||
if (selectedClusterIds.size > 0) {
|
||||
selectedClusterIds = new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth onpointermove={handleResizeMove} onpointerup={handleResizeEnd} />
|
||||
|
||||
{#if featureFlagsManager.value.map}
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<div class="isolate flex size-full flex-col sm:flex-row">
|
||||
<div
|
||||
class={[
|
||||
'min-h-0',
|
||||
isTimelinePanelVisible ? 'h-1/2 w-full pb-2 sm:h-full sm:w-2/3 sm:pe-2 sm:pb-0' : 'size-full',
|
||||
]}
|
||||
>
|
||||
{#await import('$lib/components/shared-components/map/Map.svelte')}
|
||||
{#await delay(timeToLoadTheMap) then}
|
||||
<!-- show the loading spinner only if loading the map takes too much time -->
|
||||
<div class="flex size-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/await}
|
||||
{:then { default: Map }}
|
||||
<Map hash onSelect={onViewAssets} {onClusterSelect} />
|
||||
<div class="relative flex size-full overflow-hidden">
|
||||
<div class="min-w-0 flex-1 transition-all duration-300 ease-in-out">
|
||||
{#await Promise.all([import('$lib/components/shared-components/map/Map.svelte'), delay(timeToLoadTheMap)])}
|
||||
<div class="flex size-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:then [{ default: MapComponent }]}
|
||||
<MapComponent
|
||||
onSelect={onViewAssets}
|
||||
{onClusterSelect}
|
||||
{onBoundsChange}
|
||||
{visibleAssetIds}
|
||||
isTimelineOpen={isTimelinePanelVisible}
|
||||
onToggleTimeline={toggleTimeline}
|
||||
{sheetHeight}
|
||||
{isDraggingSheet}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
{#if isTimelinePanelVisible && selectedClusterBBox}
|
||||
<div class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0">
|
||||
{#if isTimelinePanelVisible}
|
||||
{#if !isMobile}
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto absolute inset-y-0 z-20 flex w-6 cursor-col-resize items-center justify-center text-immich-primary transition-colors hover:text-immich-dark-primary"
|
||||
onpointerdown={handleResizeStart}
|
||||
aria-label="Drag to resize timeline panel"
|
||||
style:right={`${clampedTimelinePanelWidth}px`}
|
||||
title="Drag to resize timeline panel"
|
||||
>
|
||||
<span
|
||||
class="flex h-14 w-3 items-center justify-center rounded-full border border-gray-200 bg-white/90 shadow-md dark:border-immich-dark-gray dark:bg-immich-dark-bg"
|
||||
>
|
||||
<Icon icon={mdiDragHorizontal} size="16" />
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<aside
|
||||
class="absolute inset-x-0 bottom-0 z-30 flex w-full shrink-0 flex-col sm:relative sm:h-full sm:w-(--timeline-panel-width) sm:max-w-175 sm:min-w-[320px] sm:overflow-y-auto sm:border-s sm:border-gray-200 sm:bg-white sm:dark:border-immich-dark-gray sm:dark:bg-immich-dark-bg"
|
||||
style:--timeline-panel-width={isMobile ? '100%' : `${clampedTimelinePanelWidth}px`}
|
||||
transition:fly={{
|
||||
x: isMobile ? 0 : 400,
|
||||
y: isMobile ? 800 : 0,
|
||||
duration: 400,
|
||||
easing: cubicOut,
|
||||
}}
|
||||
>
|
||||
<MapTimelinePanel
|
||||
bbox={selectedClusterBBox}
|
||||
{selectedClusterIds}
|
||||
panelWidth={isMobile ? innerWidth : clampedTimelinePanelWidth}
|
||||
{isMobile}
|
||||
assetCount={selectedClusterIds.size}
|
||||
onClose={closeTimelinePanel}
|
||||
onVisibleIdsChange={(ids) => (visibleAssetIds = ids)}
|
||||
onSelect={onViewAssets}
|
||||
bind:sheetHeight
|
||||
bind:isDraggingSheet
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
|
||||
<Portal target="body">
|
||||
{#if assetViewerManager.isViewing && !isTimelinePanelVisible}
|
||||
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
<script lang="ts" module>
|
||||
const patchedTimelineManagers = new WeakSet<TimelineManager>();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||
|
|
@ -20,9 +24,12 @@
|
|||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import {
|
||||
updateStackedAssetInTimeline,
|
||||
updateUnstackedAssetInTimeline,
|
||||
|
|
@ -30,21 +37,119 @@
|
|||
type OnUnlink,
|
||||
} from '$lib/utils/actions';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
|
||||
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
|
||||
import { ceil, floor } from 'lodash-es';
|
||||
import { ceil, floor, clamp } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
bbox: SelectionBBox;
|
||||
bbox?: SelectionBBox;
|
||||
selectedClusterIds: Set<string>;
|
||||
panelWidth?: number;
|
||||
assetCount: number;
|
||||
onClose: () => void;
|
||||
onVisibleIdsChange?: (ids: Set<string> | undefined) => void;
|
||||
onSelect?: (assetIds: string[]) => void;
|
||||
sheetHeight?: number;
|
||||
isDraggingSheet?: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props();
|
||||
let {
|
||||
bbox,
|
||||
selectedClusterIds,
|
||||
panelWidth = 384,
|
||||
assetCount,
|
||||
onClose,
|
||||
onVisibleIdsChange,
|
||||
onSelect,
|
||||
sheetHeight = $bindable(50),
|
||||
isDraggingSheet = $bindable(false),
|
||||
isMobile,
|
||||
}: Props = $props();
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
let timelineManager = $state<TimelineManager>();
|
||||
let isFetching = $state(false);
|
||||
let lastKnownAssetCount = $state(0);
|
||||
|
||||
$effect.pre(() => {
|
||||
if (!isFetching) {
|
||||
lastKnownAssetCount = selectedClusterIds.size > 0 ? assetCount : (timelineManager?.assetCount ?? assetCount);
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile Swipe Logic
|
||||
const SHEET_MIN_HEIGHT = 15;
|
||||
const SHEET_COLLAPSED_HEIGHT = 30;
|
||||
const SHEET_DEFAULT_HEIGHT = 50;
|
||||
const SHEET_EXPANDED_HEIGHT = 65;
|
||||
const SHEET_DISMISS_THRESHOLD = 20;
|
||||
const SHEET_COLLAPSE_THRESHOLD = 40;
|
||||
const SHEET_EXPAND_THRESHOLD = 58;
|
||||
const SHEET_STEP = 10;
|
||||
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
startY = e.clientY;
|
||||
startHeight = sheetHeight;
|
||||
isDraggingSheet = true;
|
||||
if (e.currentTarget instanceof HTMLElement) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!isDraggingSheet) {
|
||||
return;
|
||||
}
|
||||
const deltaY = startY - e.clientY;
|
||||
const deltaVh = (deltaY / globalThis.innerHeight) * 100;
|
||||
sheetHeight = clamp(startHeight + deltaVh, SHEET_MIN_HEIGHT, SHEET_EXPANDED_HEIGHT);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!isDraggingSheet) {
|
||||
return;
|
||||
}
|
||||
isDraggingSheet = false;
|
||||
|
||||
// Snap states
|
||||
if (sheetHeight < SHEET_DISMISS_THRESHOLD) {
|
||||
onClose(); // Swipe to dismiss
|
||||
} else if (sheetHeight < SHEET_COLLAPSE_THRESHOLD) {
|
||||
sheetHeight = SHEET_COLLAPSED_HEIGHT; // Collapsed (shows top half of first row)
|
||||
} else if (sheetHeight > SHEET_EXPAND_THRESHOLD) {
|
||||
sheetHeight = SHEET_EXPANDED_HEIGHT; // Expanded (leaves room for header)
|
||||
} else {
|
||||
sheetHeight = SHEET_DEFAULT_HEIGHT; // Half screen
|
||||
}
|
||||
}
|
||||
|
||||
function handleSheetKeydown(event: KeyboardEvent) {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
sheetHeight = clamp(sheetHeight + SHEET_STEP, SHEET_MIN_HEIGHT, SHEET_EXPANDED_HEIGHT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
sheetHeight = clamp(sheetHeight - SHEET_STEP, SHEET_MIN_HEIGHT, SHEET_EXPANDED_HEIGHT);
|
||||
if (sheetHeight <= SHEET_DISMISS_THRESHOLD) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derived states
|
||||
let selectedAssets = $derived(assetMultiSelectManager.assets);
|
||||
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
|
||||
let isLinkActionAvailable = $derived.by(() => {
|
||||
|
|
@ -53,22 +158,50 @@
|
|||
selectedAssets.length === 2 &&
|
||||
selectedAssets.some((asset) => asset.isImage) &&
|
||||
selectedAssets.some((asset) => asset.isVideo);
|
||||
|
||||
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (timelineManager && !patchedTimelineManagers.has(timelineManager)) {
|
||||
const originalUpdateOptions = timelineManager.updateOptions.bind(timelineManager);
|
||||
timelineManager.updateOptions = async (opts: Record<string, unknown>) => {
|
||||
isFetching = true;
|
||||
try {
|
||||
await originalUpdateOptions(opts);
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
patchedTimelineManagers.add(timelineManager);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (timelineManager?.isInitialized && !isFetching) {
|
||||
lastKnownAssetCount = selectedClusterIds.size > 0 ? assetCount : timelineManager.assetCount;
|
||||
}
|
||||
});
|
||||
|
||||
const displayedAssetCount = $derived(
|
||||
isFetching
|
||||
? lastKnownAssetCount
|
||||
: selectedClusterIds.size > 0
|
||||
? assetCount
|
||||
: (timelineManager?.assetCount ?? lastKnownAssetCount),
|
||||
);
|
||||
|
||||
const handleLink: OnLink = ({ still, motion }) => {
|
||||
timelineManager.removeAssets([motion.id]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
timelineManager!.removeAssets([motion.id]);
|
||||
timelineManager!.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||
timelineManager.upsertAssets([motion]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
timelineManager!.upsertAssets([motion]);
|
||||
timelineManager!.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
timelineManager!.removeAssets(assetIds);
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
|
|
@ -76,16 +209,66 @@
|
|||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
const timelineBoundingBox = $derived(
|
||||
`${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`,
|
||||
);
|
||||
const handleThumbnailClick = (asset: TimelineAsset) => {
|
||||
onSelect?.([asset.id]);
|
||||
};
|
||||
|
||||
const timelineOptions = $derived({
|
||||
bbox: timelineBoundingBox,
|
||||
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
|
||||
isFavorite: $mapSettings.onlyFavorites || undefined,
|
||||
withPartners: $mapSettings.withPartners || undefined,
|
||||
assetFilter: selectedClusterIds,
|
||||
const timelineBoundingBox = $derived.by(() => {
|
||||
if (!bbox) {
|
||||
return '';
|
||||
}
|
||||
return `${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`;
|
||||
});
|
||||
|
||||
const timelineOptions = $derived.by(() => {
|
||||
if (!timelineBoundingBox) {
|
||||
return undefined;
|
||||
}
|
||||
const assetFilter = selectedClusterIds.size > 0 ? selectedClusterIds : undefined;
|
||||
return {
|
||||
bbox: timelineBoundingBox,
|
||||
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
|
||||
isFavorite: $mapSettings.onlyFavorites || undefined,
|
||||
withPartners: $mapSettings.withPartners || undefined,
|
||||
assetFilter,
|
||||
};
|
||||
});
|
||||
|
||||
const timelineLayoutOptions = $derived.by(() => {
|
||||
const usableWidth = (panelWidth || 384) - 48;
|
||||
const rowHeight = clamp(Math.round(usableWidth * 0.55), 180, 280);
|
||||
const headerHeight = 36;
|
||||
const gap = 4;
|
||||
return { rowHeight, headerHeight, gap };
|
||||
});
|
||||
|
||||
let visibleAssetIds = $derived.by(() => {
|
||||
if (!timelineManager?.isInitialized || !timelineManager.months) {
|
||||
return undefined;
|
||||
}
|
||||
const ids = new SvelteSet<string>();
|
||||
const top = timelineManager.visibleWindow.top;
|
||||
const bottom = timelineManager.visibleWindow.bottom;
|
||||
for (const month of timelineManager.months) {
|
||||
if (month.isInViewport) {
|
||||
for (const day of month.timelineDays) {
|
||||
const dayTop = month.top + day.top;
|
||||
const dayBottom = dayTop + day.height;
|
||||
if (isIntersecting(dayTop, dayBottom, top, bottom)) {
|
||||
for (const asset of day.getAssets()) {
|
||||
if (asset && asset.id) {
|
||||
ids.add(asset.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
onVisibleIdsChange?.(visibleAssetIds);
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
|
|
@ -94,28 +277,81 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<aside class="flex size-full flex-col overflow-hidden bg-immich-bg contain-content dark:bg-immich-dark-bg">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 pe-1 pb-1 dark:border-immich-dark-gray">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={mdiImageMultiple} size="20" />
|
||||
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
|
||||
{$t('assets_count', { values: { count: assetCount } })}
|
||||
</p>
|
||||
<svelte:window onpointermove={handlePointerMove} onpointerup={handlePointerUp} />
|
||||
|
||||
<div
|
||||
class="flex min-w-0 flex-col overflow-hidden bg-white shadow-[0_-8px_30px_rgba(0,0,0,0.12)] ease-out sm:shadow-none dark:bg-immich-dark-bg"
|
||||
class:transition-all={!isDraggingSheet}
|
||||
class:duration-300={!isDraggingSheet}
|
||||
style:height={isMobile ? `${sheetHeight}vh` : '100%'}
|
||||
style:border-top-left-radius={isMobile ? '24px' : '0'}
|
||||
style:border-top-right-radius={isMobile ? '24px' : '0'}
|
||||
>
|
||||
{#if isMobile}
|
||||
<div
|
||||
class="flex cursor-grab touch-none justify-center pt-4 pb-2"
|
||||
class:cursor-grabbing={isDraggingSheet}
|
||||
onpointerdown={handlePointerDown}
|
||||
onkeydown={handleSheetKeydown}
|
||||
role="slider"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Drag to resize"
|
||||
aria-valuenow={sheetHeight}
|
||||
aria-valuemin={SHEET_MIN_HEIGHT}
|
||||
aria-valuemax={SHEET_EXPANDED_HEIGHT}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="h-1.5 w-12 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-2 sm:pt-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon={mdiImageMultiple} size="24" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
<h2 class="text-lg font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
{$t('assets_count', { values: { count: displayedAssetCount } })}
|
||||
</h2>
|
||||
</div>
|
||||
<CloseButton onclick={onClose} />
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Timeline
|
||||
bind:timelineManager
|
||||
enableRouting={false}
|
||||
options={timelineOptions}
|
||||
onEscape={handleEscape}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
showArchiveIcon
|
||||
/>
|
||||
<div class="relative min-h-0 min-w-0 flex-1 overflow-y-auto px-6">
|
||||
<div class="size-full">
|
||||
{#if timelineOptions}
|
||||
<Timeline
|
||||
bind:timelineManager
|
||||
enableRouting={false}
|
||||
options={timelineOptions}
|
||||
layoutOptions={timelineLayoutOptions}
|
||||
onEscape={handleEscape}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
showArchiveIcon
|
||||
onSelect={handleThumbnailClick}
|
||||
>
|
||||
{#snippet empty()}
|
||||
{#if !isFetching}
|
||||
<div
|
||||
in:fade={{ duration: 200 }}
|
||||
class="flex h-full flex-col items-center justify-center text-center opacity-60"
|
||||
>
|
||||
<EmptyPlaceholder text="No photos in this area" />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Timeline>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isFetching || !timelineManager?.isInitialized}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-md dark:bg-immich-dark-bg/50"
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{#if assetMultiSelectManager.selectionActive}
|
||||
{@const Actions = getAssetBulkActions($t)}
|
||||
|
|
@ -124,13 +360,13 @@
|
|||
<Portal target="body">
|
||||
<AssetSelectControlBar>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
||||
<SelectAllAssets timelineManager={timelineManager!} assetInteraction={assetMultiSelectManager} />
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
|
||||
{#if assetMultiSelectManager.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetMultiSelectManager.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
onFavorite={(ids, isFavorite) => timelineManager!.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
|
|
@ -138,8 +374,8 @@
|
|||
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager!, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager!, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
|
|
@ -156,15 +392,15 @@
|
|||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetMultiSelectManager.isAllArchived}
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
onArchive={(ids, visibility) => timelineManager!.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if authManager.preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
onAssetDelete={(assetIds) => timelineManager!.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager!.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
|
|
|
|||
Loading…
Reference in New Issue