1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-24 10:37:28 +02:00

feat(web+server): map improvements (#2498)

* feat(web+server): map improvements

* add number format double to fix mobile
This commit is contained in:
Michel Heusschen 2023-05-21 08:26:06 +02:00 committed by GitHub
parent e028cf9002
commit a7b9adc692
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 481 additions and 303 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -31,7 +31,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto, ImmichReadStream, MapMarkerResponseDto } from '@app/domain';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
@ -260,18 +260,6 @@ export class AssetController {
return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
}
/**
* Get all assets that have GPS information embedded
*/
@Authenticated()
@Get('/map-marker')
getMapMarkers(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<MapMarkerResponseDto[]> {
return this.assetService.getMapMarkers(authUser, dto);
}
/**
* Get all asset of a device that are in the database, ID only.
*/

View File

@ -504,17 +504,4 @@ describe('AssetService', () => {
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
});
});
describe('get map markers', () => {
it('should get geo information of assets', async () => {
assetRepositoryMock.getAllByUserId.mockResolvedValue(_getAssets());
const markers = await sut.getMapMarkers(authStub.admin, {});
expect(markers).toHaveLength(1);
expect(markers[0].lat).toBe(_getAsset_1().exifInfo?.latitude);
expect(markers[0].lon).toBe(_getAsset_1().exifInfo?.longitude);
expect(markers[0].id).toBe(_getAsset_1().id);
});
});
});

View File

@ -30,8 +30,6 @@ import {
JobName,
mapAsset,
mapAssetWithoutExif,
MapMarkerResponseDto,
mapAssetMapMarker,
PartnerCore,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
@ -149,12 +147,6 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset));
}
public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise<MapMarkerResponseDto[]> {
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
return assets.map((asset) => mapAssetMapMarker(asset)).filter((marker) => marker != null) as MapMarkerResponseDto[];
}
public async getAssetByTimeBucket(
authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,

View File

@ -9,6 +9,7 @@ import { InfraModule } from '@app/infra';
import {
AlbumController,
APIKeyController,
AssetController,
AuthController,
PersonController,
JobController,
@ -36,6 +37,7 @@ import { AppCronJobs } from './app.cron-jobs';
AppController,
AlbumController,
APIKeyController,
AssetController,
AuthController,
JobController,
OAuthController,

View File

@ -0,0 +1,20 @@
import { AssetService, AuthUserDto, MapMarkerResponseDto } from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Asset')
@Controller('asset')
@Authenticated()
@UseValidation()
export class AssetController {
constructor(private service: AssetService) {}
@Get('/map-marker')
getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(authUser, options);
}
}

View File

@ -1,5 +1,6 @@
export * from './album.controller';
export * from './api-key.controller';
export * from './asset.controller';
export * from './auth.controller';
export * from './job.controller';
export * from './oauth.controller';

View File

@ -295,6 +295,50 @@
]
}
},
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
"parameters": [
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapMarkerResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/auth/login": {
"post": {
"operationId": "login",
@ -2962,67 +3006,6 @@
]
}
},
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
"description": "Get all assets that have GPS information embedded",
"parameters": [
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapMarkerResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/{deviceId}": {
"get": {
"operationId": "getUserAssetsByDeviceId",
@ -4579,6 +4562,27 @@
"name"
]
},
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
}
},
"required": [
"id",
"lat",
"lon"
]
},
"LoginCredentialDto": {
"type": "object",
"properties": {
@ -5897,31 +5901,6 @@
"timeBucket"
]
},
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
},
"id": {
"type": "string"
}
},
"required": [
"type",
"lat",
"lon",
"id"
]
},
"UpdateAssetDto": {
"type": "object",
"properties": {

View File

@ -12,6 +12,16 @@ export interface LivePhotoSearchOptions {
type: AssetType;
}
export interface MapMarkerSearchOptions {
isFavorite?: boolean;
}
export interface MapMarker {
id: string;
lat: number;
lon: number;
}
export enum WithoutProperty {
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video',
@ -31,4 +41,5 @@ export interface IAssetRepository {
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
}

View File

@ -1,5 +1,5 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { AssetService, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
@ -58,4 +58,29 @@ describe(AssetService.name, () => {
});
});
});
describe('get map markers', () => {
it('should get geo information of assets', async () => {
assetMock.getMapMarkers.mockResolvedValue(
[assetEntityStub.withLocation].map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
})),
);
const markers = await sut.getMapMarkers(authStub.user1, {});
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual({
id: assetEntityStub.withLocation.id,
lat: 100,
lon: 100,
});
});
});
});

View File

@ -1,14 +1,17 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { Inject } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository';
import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto } from './response-dto';
export class AssetService {
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.assetCore = new AssetCore(assetRepository, jobRepository);
@ -28,4 +31,8 @@ export class AssetService {
save(asset: Partial<AssetEntity>) {
return this.assetCore.save(asset);
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
}
}

View File

@ -0,0 +1,10 @@
import { toBoolean } from 'apps/immich/src/utils/transform.util';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
export class MapMarkerDto {
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
isFavorite?: boolean;
}

View File

@ -1,35 +1,12 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
export class MapMarkerResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
@ApiProperty({ type: 'number', format: 'double' })
@ApiProperty({ format: 'double' })
lat!: number;
@ApiProperty({ type: 'number', format: 'double' })
@ApiProperty({ format: 'double' })
lon!: number;
}
export function mapAssetMapMarker(entity: AssetEntity): MapMarkerResponseDto | null {
if (!entity.exifInfo) {
return null;
}
const lat = entity.exifInfo.latitude;
const lon = entity.exifInfo.longitude;
if (!lat || !lon) {
return null;
}
return {
id: entity.id,
type: entity.type,
lon,
lat,
};
}

View File

@ -9,5 +9,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
deleteAll: jest.fn(),
save: jest.fn(),
findLivePhotoMatch: jest.fn(),
getMapMarkers: jest.fn(),
};
};

View File

@ -12,6 +12,7 @@ import {
UserEntity,
UserTokenEntity,
AssetFaceEntity,
ExifEntity,
} from '@app/infra/entities';
import {
AlbumResponseDto,
@ -220,6 +221,38 @@ export const assetEntityStub = {
fileModifiedAt: '2022-06-19T23:41:36.910Z',
fileCreatedAt: '2022-06-19T23:41:36.910Z',
} as AssetEntity),
withLocation: Object.freeze<AssetEntity>({
id: 'asset-with-favorite-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: '2023-02-23T05:06:29.716Z',
fileCreatedAt: '2023-02-23T05:06:29.716Z',
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
type: AssetType.IMAGE,
webpPath: null,
encodedVideoPath: null,
createdAt: '2023-02-23T05:06:29.716Z',
updatedAt: '2023-02-23T05:06:29.716Z',
mimeType: null,
isFavorite: false,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
exifInfo: {
latitude: 100,
longitude: 100,
} as ExifEntity,
}),
};
export const albumStub = {

View File

@ -1,4 +1,11 @@
import { AssetSearchOptions, IAssetRepository, LivePhotoSearchOptions, WithoutProperty } from '@app/domain';
import {
AssetSearchOptions,
IAssetRepository,
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
WithoutProperty,
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
@ -21,7 +28,6 @@ export class AssetRepository implements IAssetRepository {
},
});
}
async deleteAll(ownerId: string): Promise<void> {
await this.repository.delete({ ownerId });
}
@ -166,4 +172,44 @@ export class AssetRepository implements IAssetRepository {
order: { fileCreatedAt: 'DESC' },
});
}
async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
const { isFavorite } = options;
const assets = await this.repository.find({
select: {
id: true,
exifInfo: {
latitude: true,
longitude: true,
},
},
where: {
ownerId,
isVisible: true,
isArchived: false,
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
return assets.map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
}));
}
}

View File

@ -1471,10 +1471,10 @@ export interface LogoutResponseDto {
export interface MapMarkerResponseDto {
/**
*
* @type {AssetTypeEnum}
* @type {string}
* @memberof MapMarkerResponseDto
*/
'type': AssetTypeEnum;
'id': string;
/**
*
* @type {number}
@ -1487,15 +1487,7 @@ export interface MapMarkerResponseDto {
* @memberof MapMarkerResponseDto
*/
'lon': number;
/**
*
* @type {string}
* @memberof MapMarkerResponseDto
*/
'id': string;
}
/**
*
* @export
@ -4858,14 +4850,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: 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}
*/
getMapMarkers: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getMapMarkers: async (isFavorite?: boolean, 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);
@ -4891,14 +4881,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -5471,15 +5453,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
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);
async getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -5739,15 +5719,13 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
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));
getMapMarkers(isFavorite?: boolean, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
return localVarFp.getMapMarkers(isFavorite, options).then((request) => request(axios, basePath));
},
/**
* Get all asset of a device that are in the database, ID only.
@ -6036,16 +6014,14 @@ export class AssetApi extends BaseAPI {
}
/**
* 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));
public getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMapMarkers(isFavorite, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -237,7 +237,7 @@
{#if latlng}
<div class="h-[360px]">
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
<Map {latlng} zoom={14}>
<Map center={latlng} zoom={14}>
<TileLayer
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{

View File

@ -0,0 +1,40 @@
<script lang="ts" context="module">
export interface MapSettings {
allowDarkMode: boolean;
onlyFavorites: boolean;
}
</script>
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { createEventDispatcher } from 'svelte';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
export let settings: MapSettings;
const dispatch = createEventDispatcher<{
close: void;
save: MapSettings;
}>();
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div
class="flex flex-col gap-8 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-96 max-w-lg rounded-3xl"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium self-center">
Map Settings
</h1>
<form on:submit|preventDefault={() => dispatch('save', settings)} class="flex flex-col gap-4">
<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch title="Show only favorites" bind:checked={settings.onlyFavorites} />
<div class="flex w-full gap-4 mt-4">
<Button color="gray" size="sm" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
<Button type="submit" size="sm" fullwidth>Save</Button>
</div>
</form>
</div>
</FullScreenModal>

View File

@ -3,7 +3,7 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher<{ clickOutside: void }>();
</script>
<section

View File

@ -0,0 +1,39 @@
.marker-cluster {
background-clip: padding-box;
}
.asset-marker-icon {
@apply rounded-full;
object-fit: cover;
border: 1px solid rgb(69, 80, 169);
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
}
.marker-cluster div {
width: 40px;
height: 40px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
@apply rounded-full;
font-weight: bold;
background-color: rgb(236, 237, 246);
border: 1px solid rgb(69, 80, 169);
color: rgb(69, 80, 169);
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
}
.dark .marker-cluster div {
background-color: #adcbfa;
border: 1px solid black;
color: black;
}
.marker-cluster span {
line-height: 40px;
}

View File

@ -1,6 +1,6 @@
<script lang="ts" context="module">
import { createContext } from '$lib/utils/context';
import { MarkerClusterGroup, Marker, Icon, LeafletEvent } from 'leaflet';
import { Icon, LeafletEvent, Marker, MarkerClusterGroup } from 'leaflet';
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
@ -10,11 +10,11 @@
</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';
import 'leaflet.markercluster';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import './asset-marker-cluster.css';
import { getMapContext } from './map.svelte';
class AssetMarker extends Marker {
marker: MapMarkerResponseDto;
@ -95,49 +95,3 @@
if (cluster) cluster.remove();
});
</script>
{#if cluster}
<slot />
{/if}
<style lang="postcss">
:global(.marker-cluster) {
background-clip: padding-box;
}
:global(.asset-marker-icon) {
@apply rounded-full;
object-fit: cover;
border: 1px solid rgb(69, 80, 169);
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
}
:global(.marker-cluster div) {
width: 40px;
height: 40px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
@apply rounded-full;
font-weight: bold;
background-color: rgb(236, 237, 246);
border: 1px solid rgb(69, 80, 169);
color: rgb(69, 80, 169);
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
}
:global(.dark .marker-cluster div) {
background-color: #adcbfa;
border: 1px solid black;
color: black;
}
:global(.marker-cluster span) {
line-height: 40px;
}
</style>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { Control, type ControlPosition } from 'leaflet';
import { getMapContext } from './map.svelte';
export let position: ControlPosition | undefined = undefined;
let className: string | undefined = undefined;
export { className as class };
let control: Control;
let target: HTMLDivElement;
const map = getMapContext();
onMount(() => {
const ControlClass = Control.extend({
position,
onAdd: () => target
});
control = new ControlClass().addTo(map);
});
onDestroy(() => {
control.remove();
});
$: if (control && position) {
control.setPosition(position);
}
</script>
<div bind:this={target} class={className}>
<slot />
</div>

View File

@ -1,4 +1,5 @@
export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte';
export { default as Control } from './control.svelte';
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

@ -12,11 +12,13 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { Map, type LatLngExpression } from 'leaflet';
import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
import 'leaflet/dist/leaflet.css';
export let latlng: LatLngExpression;
export let center: LatLngExpression;
export let zoom: number;
export let options: MapOptions | undefined = undefined;
export let allowDarkMode = false;
let container: HTMLDivElement;
let map: Map;
@ -24,7 +26,7 @@
onMount(() => {
if (browser) {
map = new Map(container);
map = new Map(container, options);
}
});
@ -32,11 +34,17 @@
if (map) map.remove();
});
$: if (map) map.setView(latlng, zoom);
$: if (map) map.setView(center, zoom);
</script>
<div bind:this={container} class="w-full h-full">
<div bind:this={container} class="w-full h-full" class:map-dark={allowDarkMode}>
{#if map}
<slot />
{/if}
</div>
<style>
:global(.dark) .map-dark :global(.leaflet-layer) {
filter: invert(100%) brightness(130%) saturate(0%);
}
</style>

View File

@ -5,30 +5,16 @@
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, {
className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer',
...options
}).addTo(map);
tileLayer = new TileLayer(urlTemplate, 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

@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import { MapSettings } from '$lib/components/map-page/map-settings-modal.svelte';
import { persisted } from 'svelte-local-storage-store';
const initialTheme =
@ -19,3 +20,8 @@ export const locale = persisted<string | undefined>('locale', undefined, {
stringify: (obj) => obj ?? ''
}
});
export const mapSettings = persisted<MapSettings>('map-settings', {
allowDarkMode: true,
onlyFavorites: false
});

View File

@ -2,22 +2,15 @@ import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api, user } }) => {
export const load = (async ({ locals: { 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);
}
return {
user,
meta: {
title: 'Map'
}
};
}) satisfies PageServerLoad;

View File

@ -1,27 +1,43 @@
<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 UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState
} from '$lib/stores/asset-interaction.store';
import { mapSettings } from '$lib/stores/preferences.store';
import { MapMarkerResponseDto, api } from '@api';
import { onDestroy, onMount } from 'svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import type { PageData } from './$types';
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 mapMarkersPromise: Promise<MapMarkerResponseDto[]>;
let abortController = new AbortController();
let viewingAssets: string[] = [];
let viewingAssetCursor = 0;
let showSettingsModal = false;
onMount(() => {
mapMarkersPromise = loadMapMarkers();
});
onDestroy(() => {
abortController.abort();
assetInteractionStore.clearMultiselect();
assetInteractionStore.setIsViewingAsset(false);
});
async function loadMapMarkers() {
const { data } = await api.assetApi.getMapMarkers($mapSettings.onlyFavorites || undefined, {
signal: abortController.signal
});
return data;
}
function onViewAssets(assets: string[]) {
assetInteractionStore.setViewingAssetId(assets[0]);
@ -40,27 +56,55 @@
assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]);
}
}
function getMapCenter(mapMarkers: MapMarkerResponseDto[]): [number, number] {
const marker = mapMarkers[0];
if (marker) {
return [marker.lat, marker.lon];
}
return [48, 11];
}
</script>
<UserPageLayout user={data.user} title={data.meta.title}>
<div slot="buttons" />
<div class="h-full w-full relative z-0">
{#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'}
<div class="h-full w-full isolate">
{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster, Control }}
{#await mapMarkersPromise then mapMarkers}
<Map
center={getMapCenter(mapMarkers)}
zoom={7}
allowDarkMode={$mapSettings.allowDarkMode}
options={{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxBounds: [
[-90, -180],
[90, 180]
],
minZoom: 3
}}
/>
<AssetMarkerCluster
markers={data.mapMarkers}
on:view={(event) => onViewAssets(event.detail.assets)}
/>
</Map>
>
<TileLayer
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}}
/>
<AssetMarkerCluster
markers={mapMarkers}
on:view={(event) => onViewAssets(event.detail.assets)}
/>
<Control>
<button
class="flex justify-center items-center bg-white text-black/70 w-8 h-8 font-bold rounded-sm border-2 border-black/20 hover:bg-gray-50 focus:bg-gray-50"
title="Open map settings"
on:click={() => (showSettingsModal = true)}
>
<Cog size="100%" class="p-1" />
</button>
</Control>
</Map>
{/await}
{/await}
</div>
</UserPageLayout>
@ -78,3 +122,20 @@
/>
{/if}
</Portal>
{#if showSettingsModal}
<MapSettingsModal
settings={{ ...$mapSettings }}
on:close={() => (showSettingsModal = false)}
on:save={async ({ detail }) => {
const shouldUpdate = detail.onlyFavorites !== $mapSettings.onlyFavorites;
showSettingsModal = false;
$mapSettings = detail;
if (shouldUpdate) {
const markers = await loadMapMarkers();
mapMarkersPromise = Promise.resolve(markers);
}
}}
/>
{/if}