1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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 566 additions and 5 deletions

View File

@ -55,6 +55,7 @@ doc/JobStatusDto.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
doc/LogoutResponseDto.md doc/LogoutResponseDto.md
doc/MapMarkerResponseDto.md
doc/OAuthApi.md doc/OAuthApi.md
doc/OAuthCallbackDto.md doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md doc/OAuthConfigDto.md
@ -171,6 +172,7 @@ lib/model/job_status_dto.dart
lib/model/login_credential_dto.dart lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart lib/model/login_response_dto.dart
lib/model/logout_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_callback_dto.dart
lib/model/o_auth_config_dto.dart lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_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_credential_dto_test.dart
test/login_response_dto_test.dart test/login_response_dto_test.dart
test/logout_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_api_test.dart
test/o_auth_callback_dto_test.dart test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart test/o_auth_config_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/MapMarkerResponseDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

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 { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-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 { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.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); 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. * Get all asset of a device that are in the database, ID only.
*/ */

View File

@ -1,7 +1,7 @@
import { IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { QueryFailedError, Repository } from 'typeorm'; 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 { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
@ -57,6 +57,9 @@ const _getAsset_1 = () => {
asset_1.webpPath = ''; asset_1.webpPath = '';
asset_1.encodedVideoPath = ''; asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000'; 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; return asset_1;
}; };
@ -492,4 +495,17 @@ describe('AssetService', () => {
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg'); 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,6 +30,8 @@ import {
JobName, JobName,
mapAsset, mapAsset,
mapAssetWithoutExif, mapAssetWithoutExif,
MapMarkerResponseDto,
mapAssetMapMarker,
} from '@app/domain'; } from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
@ -142,6 +144,12 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset)); 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( public async getAssetByTimeBucket(
authUser: AuthUserDto, authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto, getAssetByTimeBucketDto: GetAssetByTimeBucketDto,

View File

@ -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}": { "/asset/{deviceId}": {
"get": { "get": {
"operationId": "getUserAssetsByDeviceId", "operationId": "getUserAssetsByDeviceId",
@ -5426,6 +5484,31 @@
"timeBucket" "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": { "UpdateAssetDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,3 +1,4 @@
export * from './asset-response.dto'; export * from './asset-response.dto';
export * from './exif-response.dto'; export * from './exif-response.dto';
export * from './smart-info-response.dto'; export * from './smart-info-response.dto';
export * from './map-marker-response.dto';

View File

@ -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,
};
}

34
web/package-lock.json generated
View File

@ -13,6 +13,7 @@
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"leaflet": "^1.9.3", "leaflet": "^1.9.3",
"leaflet.markercluster": "^1.5.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.2.1", "luxon": "^3.2.1",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
@ -31,6 +32,7 @@
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/justified-layout": "^4.1.0", "@types/justified-layout": "^4.1.0",
"@types/leaflet": "^1.9.1", "@types/leaflet": "^1.9.1",
"@types/leaflet.markercluster": "^1.5.1",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.2.0", "@types/luxon": "^3.2.0",
"@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/eslint-plugin": "^5.53.0",
@ -3622,6 +3624,15 @@
"@types/geojson": "*" "@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": { "node_modules/@types/lodash": {
"version": "4.14.191", "version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", "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", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" "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": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -14055,6 +14074,15 @@
"@types/geojson": "*" "@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": { "@types/lodash": {
"version": "4.14.191", "version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", "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", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz",
"integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" "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": { "leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",

View File

@ -29,6 +29,7 @@
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/justified-layout": "^4.1.0", "@types/justified-layout": "^4.1.0",
"@types/leaflet": "^1.9.1", "@types/leaflet": "^1.9.1",
"@types/leaflet.markercluster": "^1.5.1",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.2.0", "@types/luxon": "^3.2.0",
"@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/eslint-plugin": "^5.53.0",
@ -61,6 +62,7 @@
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"leaflet": "^1.9.3", "leaflet": "^1.9.3",
"leaflet.markercluster": "^1.5.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.2.1", "luxon": "^3.2.1",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",

View File

@ -1438,6 +1438,39 @@ export interface LogoutResponseDto {
*/ */
'redirectUri': string; '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 * @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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -5321,6 +5404,18 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, 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);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* Get all asset of a device that are in the database, ID only. * Get all asset of a device that are in the database, ID only.
* @param {string} deviceId * @param {string} deviceId
@ -5577,6 +5672,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> { getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
return localVarFp.getCuratedObjects(options).then((request) => request(axios, 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));
},
/** /**
* Get all asset of a device that are in the database, ID only. * Get all asset of a device that are in the database, ID only.
* @param {string} deviceId * @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)); 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. * Get all asset of a device that are in the database, ID only.
* @param {string} deviceId * @param {string} deviceId

View File

@ -296,7 +296,7 @@
<section <section
id="immich-asset-viewer" 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"> <div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AssetViewerNavBar <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 Map } from './map.svelte';
export { default as Marker } from './marker.svelte'; export { default as Marker } from './marker.svelte';
export { default as TileLayer } from './tile-layer.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 urlTemplate: string;
export let options: TileLayerOptions | undefined = undefined; export let options: TileLayerOptions | undefined = undefined;
export let allowDarkMode = false;
let tileLayer: TileLayer; let tileLayer: TileLayer;
const map = getMapContext(); const map = getMapContext();
onMount(() => { onMount(() => {
tileLayer = new TileLayer(urlTemplate, options).addTo(map); tileLayer = new TileLayer(urlTemplate, {
className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer',
...options
}).addTo(map);
}); });
onDestroy(() => { onDestroy(() => {
if (tileLayer) tileLayer.remove(); if (tileLayer) tileLayer.remove();
}); });
</script> </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 ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte'; import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import Magnify from 'svelte-material-icons/Magnify.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 StarOutline from 'svelte-material-icons/StarOutline.svelte';
import { AppRoute } from '../../../constants'; import { AppRoute } from '../../../constants';
import LoadingSpinner from '../loading-spinner.svelte'; import LoadingSpinner from '../loading-spinner.svelte';
@ -108,6 +109,9 @@
isSelected={$page.route.id === '/(user)/explore'} isSelected={$page.route.id === '/(user)/explore'}
/> />
</a> </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"> <a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
<SideBarButton <SideBarButton
title="Sharing" title="Sharing"

View File

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

View File

@ -53,7 +53,11 @@ function createAssetInteractionStore() {
* Asset Viewer * Asset Viewer
*/ */
const setViewingAsset = async (asset: AssetResponseDto) => { 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); viewingAssetStoreState.set(data);
isViewingAssetStoreState.set(true); isViewingAssetStoreState.set(true);
}; };
@ -140,6 +144,7 @@ function createAssetInteractionStore() {
return { return {
setViewingAsset, setViewingAsset,
setViewingAssetId,
setIsViewingAsset, setIsViewingAsset,
navigateAsset, navigateAsset,
addAssetToMultiselectGroup, 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>