feat(web): Global map showing all assets with geo information (#2355)

* First crude implementation of the global asset map in web

* Use single DOM element for all markers

* Minor layout changes

* Refactor

* Add asset viewer

* Add API endpoint that returns only assets with location information (Thanks @EPP100)

* Remove sidebar icon flip

* Add dark theme support

* Center map to most recent asset

* Allow cluster viewing

* Fix linter errors

* Add newlines

* Fix ts errors

* Fix eslint error

* Run prettier

* Server code style

* Fix openapi mobile code generation issues

* Map markers test

* fix: Support video thumbnails

* Update API

* Review suggestions

* Review suggestions

* Linter error

* Chage mapMarker endpoint to map-marker

* Clean up leaflet imports
This commit is contained in:
Matthias Rupp 2023-05-06 03:33:30 +02:00 committed by GitHub
parent 15a498fd60
commit 65daf342df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 902 additions and 5 deletions

View file

@ -1438,6 +1438,39 @@ export interface LogoutResponseDto {
*/
'redirectUri': string;
}
/**
*
* @export
* @interface MapMarkerResponseDto
*/
export interface MapMarkerResponseDto {
/**
*
* @type {AssetTypeEnum}
* @memberof MapMarkerResponseDto
*/
'type': AssetTypeEnum;
/**
*
* @type {number}
* @memberof MapMarkerResponseDto
*/
'lat': number;
/**
*
* @type {number}
* @memberof MapMarkerResponseDto
*/
'lon': number;
/**
*
* @type {string}
* @memberof MapMarkerResponseDto
*/
'id': string;
}
/**
*
* @export
@ -4752,6 +4785,56 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Get all assets that have GPS information embedded
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMapMarkers: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/map-marker`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (isFavorite !== undefined) {
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -5321,6 +5404,18 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Get all assets that have GPS information embedded
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, isArchived, skip, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Get all asset of a device that are in the database, ID only.
* @param {string} deviceId
@ -5577,6 +5672,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
},
/**
* Get all assets that have GPS information embedded
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
return localVarFp.getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(axios, basePath));
},
/**
* Get all asset of a device that are in the database, ID only.
* @param {string} deviceId
@ -5863,6 +5969,19 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath));
}
/**
* Get all assets that have GPS information embedded
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(this.axios, this.basePath));
}
/**
* Get all asset of a device that are in the database, ID only.
* @param {string} deviceId

View file

@ -296,7 +296,7 @@
<section
id="immich-asset-viewer"
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4"
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AssetViewerNavBar

View file

@ -0,0 +1,119 @@
<script lang="ts" context="module">
import { createContext } from '$lib/utils/context';
import { MarkerClusterGroup, Marker, Icon, LeafletEvent } from 'leaflet';
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
export const getClusterContext = () => {
return getContext()();
};
</script>
<script lang="ts">
import 'leaflet.markercluster';
import { onDestroy, onMount } from 'svelte';
import { getMapContext } from './map.svelte';
import { MapMarkerResponseDto, api } from '@api';
import { createEventDispatcher } from 'svelte';
class AssetMarker extends Marker {
marker: MapMarkerResponseDto;
constructor(marker: MapMarkerResponseDto) {
super([marker.lat, marker.lon], {
icon: new Icon({
iconUrl: api.getAssetThumbnailUrl(marker.id),
iconRetinaUrl: api.getAssetThumbnailUrl(marker.id),
iconSize: [60, 60],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
tooltipAnchor: [16, -28],
shadowSize: [41, 41]
})
});
this.marker = marker;
}
getAssetId(): string {
return this.marker.id;
}
}
const dispatch = createEventDispatcher<{ view: { assets: string[] } }>();
export let markers: MapMarkerResponseDto[];
const map = getMapContext();
let cluster: MarkerClusterGroup;
setClusterContext(() => cluster);
onMount(() => {
cluster = new MarkerClusterGroup({
showCoverageOnHover: false,
zoomToBoundsOnClick: false,
spiderfyOnMaxZoom: false,
maxClusterRadius: 30,
spiderLegPolylineOptions: { opacity: 0 },
spiderfyDistanceMultiplier: 3
});
cluster.on('clusterclick', (event: LeafletEvent) => {
const ids = event.sourceTarget
.getAllChildMarkers()
.map((marker: AssetMarker) => marker.getAssetId());
dispatch('view', { assets: ids });
});
for (let marker of markers) {
const leafletMarker = new AssetMarker(marker);
leafletMarker.on('click', () => {
dispatch('view', { assets: [marker.id] });
});
cluster.addLayer(leafletMarker);
}
map.addLayer(cluster);
});
onDestroy(() => {
if (cluster) cluster.remove();
});
</script>
{#if cluster}
<slot />
{/if}
<style>
:global(.leaflet-marker-icon) {
border-radius: 25%;
}
:global(.marker-cluster) {
background-clip: padding-box;
border-radius: 20px;
}
:global(.marker-cluster div) {
width: 40px;
height: 40px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 20px;
font-weight: bold;
background-color: rgb(236, 237, 246);
color: rgb(69, 80, 169);
}
:global(.marker-cluster span) {
line-height: 40px;
}
</style>

View file

@ -1,3 +1,4 @@
export { default as Map } from './map.svelte';
export { default as Marker } from './marker.svelte';
export { default as TileLayer } from './tile-layer.svelte';
export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte';

View file

@ -5,15 +5,30 @@
export let urlTemplate: string;
export let options: TileLayerOptions | undefined = undefined;
export let allowDarkMode = false;
let tileLayer: TileLayer;
const map = getMapContext();
onMount(() => {
tileLayer = new TileLayer(urlTemplate, options).addTo(map);
tileLayer = new TileLayer(urlTemplate, {
className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer',
...options
}).addTo(map);
});
onDestroy(() => {
if (tileLayer) tileLayer.remove();
});
</script>
<style>
:global(.leaflet-layer-dynamic) {
filter: brightness(100%) contrast(100%) saturate(80%);
}
:global(.dark .leaflet-layer-dynamic) {
filter: invert(100%) brightness(130%) saturate(0%);
}
</style>

View file

@ -6,6 +6,7 @@
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import Magnify from 'svelte-material-icons/Magnify.svelte';
import Map from 'svelte-material-icons/Map.svelte';
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
import { AppRoute } from '../../../constants';
import LoadingSpinner from '../loading-spinner.svelte';
@ -108,6 +109,9 @@
isSelected={$page.route.id === '/(user)/explore'}
/>
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
<SideBarButton
title="Sharing"

View file

@ -14,6 +14,7 @@ export enum AppRoute {
EXPLORE = '/explore',
SHARING = '/sharing',
SEARCH = '/search',
MAP = '/map',
AUTH_LOGIN = '/auth/login',
AUTH_LOGOUT = '/auth/logout',

View file

@ -53,7 +53,11 @@ function createAssetInteractionStore() {
* Asset Viewer
*/
const setViewingAsset = async (asset: AssetResponseDto) => {
const { data } = await api.assetApi.getAssetById(asset.id);
setViewingAssetId(asset.id);
};
const setViewingAssetId = async (id: string) => {
const { data } = await api.assetApi.getAssetById(id);
viewingAssetStoreState.set(data);
isViewingAssetStoreState.set(true);
};
@ -140,6 +144,7 @@ function createAssetInteractionStore() {
return {
setViewingAsset,
setViewingAssetId,
setIsViewingAsset,
navigateAsset,
addAssetToMultiselectGroup,

View file

@ -0,0 +1,23 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api, user } }) => {
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
try {
const { data: mapMarkers } = await api.assetApi.getMapMarkers();
return {
user,
mapMarkers,
meta: {
title: 'Map'
}
};
} catch (e) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
}) satisfies PageServerLoad;

View file

@ -0,0 +1,80 @@
<script lang="ts">
import type { PageData } from '../map/$types';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState
} from '$lib/stores/asset-interaction.store';
export let data: PageData;
let initialMapCenter: [number, number] = [48, 11];
$: {
if (data.mapMarkers.length) {
let firstMarker = data.mapMarkers[0];
initialMapCenter = [firstMarker.lat, firstMarker.lon];
}
}
let viewingAssets: string[] = [];
let viewingAssetCursor = 0;
function onViewAssets(assets: string[]) {
assetInteractionStore.setViewingAssetId(assets[0]);
viewingAssets = assets;
viewingAssetCursor = 0;
}
function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
assetInteractionStore.setViewingAssetId(viewingAssets[++viewingAssetCursor]);
}
}
function navigatePrevious() {
if (viewingAssetCursor > 0) {
assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]);
}
}
</script>
<UserPageLayout user={data.user} title={data.meta.title}>
<div slot="buttons" />
<div class="h-[90%] w-full">
{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster }}
<Map latlng={initialMapCenter} zoom={7}>
<TileLayer
allowDarkMode={true}
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}}
/>
<AssetMarkerCluster
markers={data.mapMarkers}
on:view={(event) => onViewAssets(event.detail.assets)}
/>
</Map>
{/await}
</div>
</UserPageLayout>
<Portal target="body">
{#if $isViewingAssetStoreState}
<AssetViewer
asset={$viewingAssetStoreState}
showNavigation={viewingAssets.length > 1}
on:navigate-next={navigateNext}
on:navigate-previous={navigatePrevious}
on:close={() => {
assetInteractionStore.setIsViewingAsset(false);
}}
/>
{/if}
</Portal>