From 65daf342df470b5597492f87c0c62bfdde096c10 Mon Sep 17 00:00:00 2001 From: Matthias Rupp Date: Sat, 6 May 2023 03:33:30 +0200 Subject: [PATCH] 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 --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 15770 -> 15922 bytes mobile/openapi/doc/AssetApi.md | Bin 53258 -> 55199 bytes mobile/openapi/doc/MapMarkerResponseDto.md | Bin 0 -> 521 bytes mobile/openapi/lib/api.dart | Bin 5044 -> 5087 bytes mobile/openapi/lib/api/asset_api.dart | Bin 47582 -> 49841 bytes mobile/openapi/lib/api_client.dart | Bin 16738 -> 16830 bytes .../lib/model/map_marker_response_dto.dart | Bin 0 -> 3906 bytes mobile/openapi/test/asset_api_test.dart | Bin 5125 -> 5363 bytes .../test/map_marker_response_dto_test.dart | Bin 0 -> 854 bytes .../src/api-v1/asset/asset.controller.ts | 14 ++- .../src/api-v1/asset/asset.service.spec.ts | 18 ++- .../immich/src/api-v1/asset/asset.service.ts | 8 ++ server/immich-openapi-specs.json | 83 ++++++++++++ .../domain/src/asset/response-dto/index.ts | 1 + .../response-dto/map-marker-response.dto.ts | 35 ++++++ web/package-lock.json | 34 +++++ web/package.json | 2 + web/src/api/open-api/api.ts | 119 ++++++++++++++++++ .../asset-viewer/asset-viewer.svelte | 2 +- .../leaflet/asset-marker-cluster.svelte | 119 ++++++++++++++++++ .../shared-components/leaflet/index.ts | 1 + .../leaflet/tile-layer.svelte | 17 ++- .../side-bar/side-bar.svelte | 4 + web/src/lib/constants.ts | 1 + web/src/lib/stores/asset-interaction.store.ts | 7 +- web/src/routes/(user)/map/+page.server.ts | 23 ++++ web/src/routes/(user)/map/+page.svelte | 80 ++++++++++++ 28 files changed, 566 insertions(+), 5 deletions(-) create mode 100644 mobile/openapi/doc/MapMarkerResponseDto.md create mode 100644 mobile/openapi/lib/model/map_marker_response_dto.dart create mode 100644 mobile/openapi/test/map_marker_response_dto_test.dart create mode 100644 server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts create mode 100644 web/src/lib/components/shared-components/leaflet/asset-marker-cluster.svelte create mode 100644 web/src/routes/(user)/map/+page.server.ts create mode 100644 web/src/routes/(user)/map/+page.svelte diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 02067ef62b..45537ddc34 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -55,6 +55,7 @@ doc/JobStatusDto.md doc/LoginCredentialDto.md doc/LoginResponseDto.md doc/LogoutResponseDto.md +doc/MapMarkerResponseDto.md doc/OAuthApi.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md @@ -171,6 +172,7 @@ lib/model/job_status_dto.dart lib/model/login_credential_dto.dart lib/model/login_response_dto.dart lib/model/logout_response_dto.dart +lib/model/map_marker_response_dto.dart lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart @@ -264,6 +266,7 @@ test/job_status_dto_test.dart test/login_credential_dto_test.dart test/login_response_dto_test.dart test/logout_response_dto_test.dart +test/map_marker_response_dto_test.dart test/o_auth_api_test.dart test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index da4a892dc98ed3f382dcc103089d9e8eac284d28..0959d780e879db98397c401a9061bb3d4a8d07ac 100644 GIT binary patch delta 117 zcmbPLy{Try7cnm1!~);MqU_Y7;>j09B-C>g3vwY`O@$f-EiHG~5G^eQ{lwzp)DnH5 yq%K5ua-g`#=8s~svMO->L8-+B`FX{uE+zS~8Y%h7`ucFG$qTeZHuIVEZ~_1n2r7;M delta 17 Zcmdl~Gpl;T7qQJD;!?7kmznf%0su^x2Xz1d diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 213369d797aa640f736b9a351403857c44a33908..3d12d41d82148d459f715dee08480e261e9c8b6f 100644 GIT binary patch delta 291 zcmeBLz&w9F^9EaXPT#}=-^8Nq)S}4~*hDyU6AN-7yp7Dl0{TF%E`+a8qreqCS#Xy0 z=3w^2lg!|{inX-3>=b-5i%V?aVnM0J1^Ic!sV*h?b_(gKC2)z!)`4*1Hsj_G zwv!kqdk4f%{;aRggKqNV0KNcJ!GnQ+C)YWOs^fN^mR76=!g&X)TsDg&n=@_}oAt#M E05I2TdH?_b delta 19 bcmbQgp1ErQ^9Eb?&E4!bCv9H6@QWz`RznF( diff --git a/mobile/openapi/doc/MapMarkerResponseDto.md b/mobile/openapi/doc/MapMarkerResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..5994a8ac3554da5db21fbb261fb2f42383a7b9d2 GIT binary patch literal 521 zcma)2&r8EF6u#%L2=uTuknXOhN_!CO7}HxRG&au}*5-xe^&t4in{)*?K{N!y_r71` zWgHz?}`Eco}APn9(GH4^(BSnS0I&pY~r2GprAt)vb z5rwS~We7m2srfqRD|gij(DeI5zQYVFv)fR0|FO delta 16 YcmcbwzD0e*5AMnNJRF;~dA6_v06td+Pyhe` diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 79e853b97c160a169483ba759f5833175c9d8497..9972700920703097fc78c69a3ca32206d7c7bcf3 100644 GIT binary patch delta 277 zcmccjnQ3Dy^M=4im12dGjKmU!jKs231^0kph0MIP{G!~%lFa-(h1A@n)RdIel*te0 zB~R9$&M{fj)_w9*PC=gZ)Dqvs0^h`WI wbt%cWv$s<~c2M!;_qIlpHwS2P!c|XBbN1f6P&tALN$75s8iKd^f5UwX0FH2L`2YX_ delta 18 Zcmdnk%6#uL(}uvt$x*#Fn-jVMEdW(%2x|ZU diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4a9285c4c6dbe8c394d38e854f4a81757d8747fe..dd8268f608f3976f142c03ec28f4a1aa40d83f2d 100644 GIT binary patch delta 42 pcmaFV#JI1Sal-;FPT#}=-^8Nq)S}6a((*{WjWXJs7ib+40{~;-5MlrT delta 18 acmdnj%=oB@al-0|p~77>SPA%A`k94U9DZedm&T zIaVK8fm#xI-}l@@=J0TEcu24Rx|}}yb81v3+dMIC{Mkiy8+Q5if|!)X|*Fk zTHeyEPxgvb2s4u@l=>tRwnkK!fc#Rq)Nj^u)U#d)-@9yd-?GYi_5J|a5WRLz`b*fI z=yFBuAS~ymye=fc@Ybi%;q#-nO5c#m!`CbGMOuq`!`!to1y{@pZ!C?+$@Kb@%uq+a zl=gteCs5+PWTHqorBS`}^=o`1^^NL2eHs#vQlSzs9|ETBEyT<_{CKcoblAXSdqd^C z-jj{eo^GV4rEtqXOfC_B2%JL zsPcAgr_@ywyooPKsP3>2OEZpRywATQuFH5IKX@}5Z@>srAMdODY8ZUL!pmrKMEM4z zr?M0B=AAPdJ|Zj^N{fO%VTs1HBYk)OBK9gs%M(+zL90J7NUKIvdTUwtq2D5I;R@*R~(a;+QCNUKp*=RTxY z<C6#$|%hSTDwO{T?+>F2(_YzOrRwU4Y zQA;=#YP)86+&G<7A$od-txb^m> z4mVZ+Qr*L?*HnGdbz1HEbah6L?Q1ZW1`Kq%^n?DQV6&(gm}?x#rzVuTt@U%O$xv+u3#sFi87x%F#0%uD z@=Gl7O!1{0Mc%o9KZ~N!tPX12TA;#zp|C|js|O*Bs{ZjhVI|(~#mb0&ZiZo_i=ywi z`#)IvNtpZ$A7rQ7-~a#s literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index ee25a55269889998acb1dd0ceb96ac9b8aea1c50..b9091fbeea78bbeeb2f9a4911cb413c877577261 100644 GIT binary patch delta 136 zcmZqG_^i3%3twWfLPVZc=JWN@@z10uboy zLn*h?lG3778=uVL5*y#d0^h`XL;M0wHasgcTAc5`@E^%xp4EEt0XZ9Z-e%?u=bHZKN(7 z{AaB9e)Gn8p5-|#@9XNv@A9U+UDjm*msi*20*VT5sv1^Radr9UKx9$*t3?+sPL7UG zvRLYljU%--POWuMXE5^Usa4d|KpYmIwH~Z-VbX#93vRu#V^EHsM(xDTILL0%$r;Fb zfjd8KZw&i@9F%UQpoC9p!fLib4Smb{gmR0NYf8{NWyn&*e`ngNJotr>gJ zX^@8CV$wK2JkLblIWyY%EH;^x%@*(hPUxlg6zZ*|lE(ppT!aBBqd-i$K-dsiC-b_n zg@-75*kRCbxk)LbNs`u8Q6<3JE=_uD&G@$`=rQbNi{@Ks!~4;Ozl6REPICvk-AmT( M|A7URzU3`@2h*Jk1ONa4 literal 0 HcmV?d00001 diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 6088b81222..2a20b3dd48 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -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 } from '@app/domain'; +import { AssetResponseDto, ImmichReadStream, MapMarkerResponseDto } 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,6 +260,18 @@ 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 { + return this.assetService.getMapMarkers(authUser, dto); + } + /** * Get all asset of a device that are in the database, ID only. */ diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 0a50832961..a98349cb3a 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -1,7 +1,7 @@ import { IAssetRepository } from './asset-repository'; import { AssetService } from './asset.service'; import { QueryFailedError, Repository } from 'typeorm'; -import { AssetEntity, AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { CreateAssetDto } from './dto/create-asset.dto'; import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; @@ -57,6 +57,9 @@ const _getAsset_1 = () => { asset_1.webpPath = ''; asset_1.encodedVideoPath = ''; asset_1.duration = '0:00:00.000000'; + asset_1.exifInfo = new ExifEntity(); + asset_1.exifInfo.latitude = 49.533547; + asset_1.exifInfo.longitude = 10.703075; return asset_1; }; @@ -492,4 +495,17 @@ 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); + }); + }); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index e9b3c7893b..8fe18bb836 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -30,6 +30,8 @@ import { JobName, mapAsset, mapAssetWithoutExif, + MapMarkerResponseDto, + mapAssetMapMarker, } from '@app/domain'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; @@ -142,6 +144,12 @@ export class AssetService { return assets.map((asset) => mapAsset(asset)); } + public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise { + 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, diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 2a09bc6ba0..788c8258e4 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2601,6 +2601,64 @@ ] } }, + "/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": [] + } + ] + } + }, "/asset/{deviceId}": { "get": { "operationId": "getUserAssetsByDeviceId", @@ -5426,6 +5484,31 @@ "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": { diff --git a/server/libs/domain/src/asset/response-dto/index.ts b/server/libs/domain/src/asset/response-dto/index.ts index 1a274731de..7e17e324f8 100644 --- a/server/libs/domain/src/asset/response-dto/index.ts +++ b/server/libs/domain/src/asset/response-dto/index.ts @@ -1,3 +1,4 @@ export * from './asset-response.dto'; export * from './exif-response.dto'; export * from './smart-info-response.dto'; +export * from './map-marker-response.dto'; diff --git a/server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts b/server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts new file mode 100644 index 0000000000..b695b055a8 --- /dev/null +++ b/server/libs/domain/src/asset/response-dto/map-marker-response.dto.ts @@ -0,0 +1,35 @@ +import { AssetEntity, AssetType } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; + +export class MapMarkerResponseDto { + id!: string; + + @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) + type!: AssetType; + + @ApiProperty({ type: 'number', format: 'double' }) + lat!: number; + + @ApiProperty({ type: 'number', 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, + }; +} diff --git a/web/package-lock.json b/web/package-lock.json index 7792fb0956..f74c1be88d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "handlebars": "^4.7.7", "justified-layout": "^4.1.0", "leaflet": "^1.9.3", + "leaflet.markercluster": "^1.5.3", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "rxjs": "^7.8.0", @@ -31,6 +32,7 @@ "@types/cookie": "^0.5.1", "@types/justified-layout": "^4.1.0", "@types/leaflet": "^1.9.1", + "@types/leaflet.markercluster": "^1.5.1", "@types/lodash-es": "^4.17.6", "@types/luxon": "^3.2.0", "@typescript-eslint/eslint-plugin": "^5.53.0", @@ -3622,6 +3624,15 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz", + "integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==", + "dev": true, + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.191", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", @@ -9044,6 +9055,14 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz", "integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -14055,6 +14074,15 @@ "@types/geojson": "*" } }, + "@types/leaflet.markercluster": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz", + "integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==", + "dev": true, + "requires": { + "@types/leaflet": "*" + } + }, "@types/lodash": { "version": "4.14.191", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", @@ -18045,6 +18073,12 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz", "integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" }, + "leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "requires": {} + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/web/package.json b/web/package.json index 2a9128542c..d74f992a1d 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "@types/cookie": "^0.5.1", "@types/justified-layout": "^4.1.0", "@types/leaflet": "^1.9.1", + "@types/leaflet.markercluster": "^1.5.1", "@types/lodash-es": "^4.17.6", "@types/luxon": "^3.2.0", "@typescript-eslint/eslint-plugin": "^5.53.0", @@ -61,6 +62,7 @@ "handlebars": "^4.7.7", "justified-layout": "^4.1.0", "leaflet": "^1.9.3", + "leaflet.markercluster": "^1.5.3", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "rxjs": "^7.8.0", diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 2d69e3395f..0daea79b83 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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 => { + 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>> { + 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> { 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> { + 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 diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ecf6cc59bc..4eec8f2505 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -296,7 +296,7 @@
+ 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()(); + }; + + + + +{#if cluster} + +{/if} + + diff --git a/web/src/lib/components/shared-components/leaflet/index.ts b/web/src/lib/components/shared-components/leaflet/index.ts index ae216f5012..b31e3a2ae1 100644 --- a/web/src/lib/components/shared-components/leaflet/index.ts +++ b/web/src/lib/components/shared-components/leaflet/index.ts @@ -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'; diff --git a/web/src/lib/components/shared-components/leaflet/tile-layer.svelte b/web/src/lib/components/shared-components/leaflet/tile-layer.svelte index c050d1c1d4..bcbaff1d7a 100644 --- a/web/src/lib/components/shared-components/leaflet/tile-layer.svelte +++ b/web/src/lib/components/shared-components/leaflet/tile-layer.svelte @@ -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(); }); + + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index db437ba9a0..66df696ae7 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -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'} /> + + + { - 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, diff --git a/web/src/routes/(user)/map/+page.server.ts b/web/src/routes/(user)/map/+page.server.ts new file mode 100644 index 0000000000..d4a7ff2e8a --- /dev/null +++ b/web/src/routes/(user)/map/+page.server.ts @@ -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; diff --git a/web/src/routes/(user)/map/+page.svelte b/web/src/routes/(user)/map/+page.svelte new file mode 100644 index 0000000000..db9320cd10 --- /dev/null +++ b/web/src/routes/(user)/map/+page.svelte @@ -0,0 +1,80 @@ + + + +
+ + + + + + {#if $isViewingAssetStoreState} + 1} + on:navigate-next={navigateNext} + on:navigate-previous={navigatePrevious} + on:close={() => { + assetInteractionStore.setIsViewingAsset(false); + }} + /> + {/if} +