Forráskód Böngészése

feat: Maplibre (#4294)

* maplibre on web, custom styles from server

Actually use new vector tile server, custom style.json

support multiple style files, light/dark mode

cleanup, use new map everywhere

send file directly instead of loading first

better light/dark mode switching

remove leaflet

fix mapstyles dto, first draft of map settings

delete and add styles

fix delete default styles

fix tests

only allow one light and one dark style url

revert config core changes

fix server config store

fix tests

move axios fetches to repo

fix package-lock

fix tests

* open api

* add assets to docker container

* web: use mapSettings color for style

* style: add unique ids to map styles

* mobile: use style json for vector / raster

* do not use svelte-material-icons

* add click events to markers, simplify asset detail map

* improve map performance by using asset thumbnails for markers instead of original file

* Remove custom attribution

(by request)

* mobile: update map attribution

* style: map dark mode

* style: map light mode

* zoom level for state

* styling

* overflow gradient

* Limit maxZoom to 14

* mobile: listen for mapStyle changes in MapThumbnail

* mobile: update concurrency

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Daniel Dietzler 1 éve
szülő
commit
a147dee4b6
63 módosított fájl, 5291 hozzáadás és 1008 törlés
  1. 110 7
      cli/src/api/open-api/api.ts
  2. 17 3
      mobile/lib/modules/map/models/map_state.model.dart
  3. 104 4
      mobile/lib/modules/map/providers/map_state.provider.dart
  4. 12 19
      mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
  5. 4 15
      mobile/lib/modules/map/ui/map_thumbnail.dart
  6. 57 67
      mobile/lib/modules/map/views/map_page.dart
  7. 15 12
      mobile/lib/modules/search/ui/curated_places_row.dart
  8. 4 12
      mobile/lib/shared/models/server_info/server_config.model.dart
  9. 0 1
      mobile/lib/shared/providers/server_info.provider.dart
  10. 2 0
      mobile/lib/shared/services/api.service.dart
  11. 3 0
      mobile/openapi/.openapi-generator/FILES
  12. 2 0
      mobile/openapi/README.md
  13. 14 0
      mobile/openapi/doc/MapTheme.md
  14. 0 1
      mobile/openapi/doc/ServerConfigDto.md
  15. 56 0
      mobile/openapi/doc/SystemConfigApi.md
  16. 2 1
      mobile/openapi/doc/SystemConfigMapDto.md
  17. 1 0
      mobile/openapi/lib/api.dart
  18. 49 0
      mobile/openapi/lib/api/system_config_api.dart
  19. 2 0
      mobile/openapi/lib/api_client.dart
  20. 3 0
      mobile/openapi/lib/api_helper.dart
  21. 85 0
      mobile/openapi/lib/model/map_theme.dart
  22. 1 9
      mobile/openapi/lib/model/server_config_dto.dart
  23. 16 8
      mobile/openapi/lib/model/system_config_map_dto.dart
  24. 21 0
      mobile/openapi/test/map_theme_test.dart
  25. 0 5
      mobile/openapi/test/server_config_dto_test.dart
  26. 5 0
      mobile/openapi/test/system_config_api_test.dart
  27. 7 2
      mobile/openapi/test/system_config_map_dto_test.dart
  28. 41 0
      mobile/pubspec.lock
  29. 7 0
      mobile/pubspec.yaml
  30. 1 0
      server/Dockerfile
  31. 1895 0
      server/assets/style-dark.json
  32. 2000 0
      server/assets/style-light.json
  33. 54 6
      server/immich-openapi-specs.json
  34. 2 1
      server/src/domain/repositories/system-config.repository.ts
  35. 0 1
      server/src/domain/server-info/server-info.dto.ts
  36. 0 1
      server/src/domain/server-info/server-info.service.spec.ts
  37. 0 1
      server/src/domain/server-info/server-info.service.ts
  38. 4 1
      server/src/domain/system-config/dto/system-config-map.dto.ts
  39. 13 0
      server/src/domain/system-config/system-config-map-theme.dto.ts
  40. 2 1
      server/src/domain/system-config/system-config.core.ts
  41. 7 6
      server/src/domain/system-config/system-config.service.spec.ts
  42. 12 1
      server/src/domain/system-config/system-config.service.ts
  43. 7 1
      server/src/immich/controllers/system-config.controller.ts
  44. 4 2
      server/src/infra/entities/system-config.entity.ts
  45. 7 1
      server/src/infra/repositories/system-config.repository.ts
  46. 0 1
      server/test/e2e/server-info.e2e-spec.ts
  47. 1 0
      server/test/repositories/system-config.repository.mock.ts
  48. 343 393
      web/package-lock.json
  49. 2 4
      web/package.json
  50. 110 7
      web/src/api/open-api/api.ts
  51. 15 7
      web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
  52. 16 29
      web/src/lib/components/asset-viewer/detail-panel.svelte
  53. 0 35
      web/src/lib/components/shared-components/leaflet/control.svelte
  54. 0 5
      web/src/lib/components/shared-components/leaflet/index.ts
  55. 0 50
      web/src/lib/components/shared-components/leaflet/map.svelte
  56. 0 31
      web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.css
  57. 0 102
      web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte
  58. 0 37
      web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker.ts
  59. 0 50
      web/src/lib/components/shared-components/leaflet/marker.svelte
  60. 0 20
      web/src/lib/components/shared-components/leaflet/tile-layer.svelte
  61. 146 0
      web/src/lib/components/shared-components/map/map.svelte
  62. 0 1
      web/src/lib/stores/server-config.store.ts
  63. 10 47
      web/src/routes/(user)/map/+page.svelte

+ 110 - 7
cli/src/api/open-api/api.ts

@@ -2224,6 +2224,20 @@ export interface MapMarkerResponseDto {
      */
      */
     'lon': number;
     'lon': number;
 }
 }
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const MapTheme = {
+    Light: 'light',
+    Dark: 'dark'
+} as const;
+
+export type MapTheme = typeof MapTheme[keyof typeof MapTheme];
+
+
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -2822,12 +2836,6 @@ export interface ServerConfigDto {
      * @memberof ServerConfigDto
      * @memberof ServerConfigDto
      */
      */
     'loginPageMessage': string;
     'loginPageMessage': string;
-    /**
-     * 
-     * @type {string}
-     * @memberof ServerConfigDto
-     */
-    'mapTileUrl': string;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -3695,6 +3703,12 @@ export interface SystemConfigMachineLearningDto {
  * @interface SystemConfigMapDto
  * @interface SystemConfigMapDto
  */
  */
 export interface SystemConfigMapDto {
 export interface SystemConfigMapDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigMapDto
+     */
+    'darkStyle': string;
     /**
     /**
      * 
      * 
      * @type {boolean}
      * @type {boolean}
@@ -3706,7 +3720,7 @@ export interface SystemConfigMapDto {
      * @type {string}
      * @type {string}
      * @memberof SystemConfigMapDto
      * @memberof SystemConfigMapDto
      */
      */
-    'tileUrl': string;
+    'lightStyle': string;
 }
 }
 /**
 /**
  * 
  * 
@@ -15063,6 +15077,51 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {MapTheme} theme 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getMapStyle: async (theme: MapTheme, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'theme' is not null or undefined
+            assertParamExists('getMapStyle', 'theme', theme)
+            const localVarPath = `/system-config/map/style.json`;
+            // 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 api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+            if (theme !== undefined) {
+                localVarQueryParameter['theme'] = theme;
+            }
+
+
+    
             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};
@@ -15182,6 +15241,16 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {MapTheme} theme 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getMapStyle(theme: MapTheme, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getMapStyle(theme, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -15227,6 +15296,15 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
         getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
         getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
             return localVarFp.getConfigDefaults(options).then((request) => request(axios, basePath));
             return localVarFp.getConfigDefaults(options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
+            return localVarFp.getMapStyle(requestParameters.theme, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -15247,6 +15325,20 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
     };
     };
 };
 };
 
 
+/**
+ * Request parameters for getMapStyle operation in SystemConfigApi.
+ * @export
+ * @interface SystemConfigApiGetMapStyleRequest
+ */
+export interface SystemConfigApiGetMapStyleRequest {
+    /**
+     * 
+     * @type {MapTheme}
+     * @memberof SystemConfigApiGetMapStyle
+     */
+    readonly theme: MapTheme
+}
+
 /**
 /**
  * Request parameters for updateConfig operation in SystemConfigApi.
  * Request parameters for updateConfig operation in SystemConfigApi.
  * @export
  * @export
@@ -15288,6 +15380,17 @@ export class SystemConfigApi extends BaseAPI {
         return SystemConfigApiFp(this.configuration).getConfigDefaults(options).then((request) => request(this.axios, this.basePath));
         return SystemConfigApiFp(this.configuration).getConfigDefaults(options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SystemConfigApi
+     */
+    public getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig) {
+        return SystemConfigApiFp(this.configuration).getMapStyle(requestParameters.theme, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.

+ 17 - 3
mobile/lib/modules/map/models/map_state.model.dart

@@ -1,14 +1,20 @@
+import 'package:vector_map_tiles/vector_map_tiles.dart';
+
 class MapState {
 class MapState {
   final bool isDarkTheme;
   final bool isDarkTheme;
   final bool showFavoriteOnly;
   final bool showFavoriteOnly;
   final bool includeArchived;
   final bool includeArchived;
   final int relativeTime;
   final int relativeTime;
+  final Style? mapStyle;
+  final bool isLoading;
 
 
   MapState({
   MapState({
     this.isDarkTheme = false,
     this.isDarkTheme = false,
     this.showFavoriteOnly = false,
     this.showFavoriteOnly = false,
     this.includeArchived = false,
     this.includeArchived = false,
     this.relativeTime = 0,
     this.relativeTime = 0,
+    this.mapStyle,
+    this.isLoading = false,
   });
   });
 
 
   MapState copyWith({
   MapState copyWith({
@@ -16,18 +22,22 @@ class MapState {
     bool? showFavoriteOnly,
     bool? showFavoriteOnly,
     bool? includeArchived,
     bool? includeArchived,
     int? relativeTime,
     int? relativeTime,
+    Style? mapStyle,
+    bool? isLoading,
   }) {
   }) {
     return MapState(
     return MapState(
       isDarkTheme: isDarkTheme ?? this.isDarkTheme,
       isDarkTheme: isDarkTheme ?? this.isDarkTheme,
       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
       includeArchived: includeArchived ?? this.includeArchived,
       includeArchived: includeArchived ?? this.includeArchived,
       relativeTime: relativeTime ?? this.relativeTime,
       relativeTime: relativeTime ?? this.relativeTime,
+      mapStyle: mapStyle ?? this.mapStyle,
+      isLoading: isLoading ?? this.isLoading,
     );
     );
   }
   }
 
 
   @override
   @override
   String toString() {
   String toString() {
-    return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived)';
+    return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)';
   }
   }
 
 
   @override
   @override
@@ -38,7 +48,9 @@ class MapState {
         other.isDarkTheme == isDarkTheme &&
         other.isDarkTheme == isDarkTheme &&
         other.showFavoriteOnly == showFavoriteOnly &&
         other.showFavoriteOnly == showFavoriteOnly &&
         other.relativeTime == relativeTime &&
         other.relativeTime == relativeTime &&
-        other.includeArchived == includeArchived;
+        other.includeArchived == includeArchived &&
+        other.mapStyle == mapStyle &&
+        other.isLoading == isLoading;
   }
   }
 
 
   @override
   @override
@@ -46,6 +58,8 @@ class MapState {
     return isDarkTheme.hashCode ^
     return isDarkTheme.hashCode ^
         showFavoriteOnly.hashCode ^
         showFavoriteOnly.hashCode ^
         relativeTime.hashCode ^
         relativeTime.hashCode ^
-        includeArchived.hashCode;
+        includeArchived.hashCode ^
+        mapStyle.hashCode ^
+        isLoading.hashCode;
   }
   }
 }
 }

+ 104 - 4
mobile/lib/modules/map/providers/map_state.provider.dart

@@ -1,10 +1,23 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/map/models/map_state.model.dart';
 import 'package:immich_mobile/modules/map/models/map_state.model.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/utils/color_filter_generator.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
+import 'package:vector_map_tiles/vector_map_tiles.dart';
 
 
 class MapStateNotifier extends StateNotifier<MapState> {
 class MapStateNotifier extends StateNotifier<MapState> {
-  MapStateNotifier(this._appSettingsProvider)
+  MapStateNotifier(this._appSettingsProvider, this._apiService)
       : super(
       : super(
           MapState(
           MapState(
             isDarkTheme: _appSettingsProvider
             isDarkTheme: _appSettingsProvider
@@ -15,17 +28,69 @@ class MapStateNotifier extends StateNotifier<MapState> {
                 .getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
                 .getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
             relativeTime: _appSettingsProvider
             relativeTime: _appSettingsProvider
                 .getSetting<int>(AppSettingsEnum.mapRelativeDate),
                 .getSetting<int>(AppSettingsEnum.mapRelativeDate),
+            isLoading: true,
           ),
           ),
-        );
+        ) {
+    _fetchStyleFromServer(
+      _appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode),
+    );
+  }
 
 
   final AppSettingsService _appSettingsProvider;
   final AppSettingsService _appSettingsProvider;
+  final ApiService _apiService;
+  final Logger _log = Logger("MapStateNotifier");
+
+  bool get isRaster =>
+      state.mapStyle != null && state.mapStyle!.rasterTileProvider != null;
+
+  double get maxZoom =>
+      (isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 14)
+          .toDouble();
 
 
   void switchTheme(bool isDarkTheme) {
   void switchTheme(bool isDarkTheme) {
+    _updateThemeMode(isDarkTheme);
+    _fetchStyleFromServer(isDarkTheme);
+  }
+
+  void _updateThemeMode(bool isDarkTheme) {
     _appSettingsProvider.setSetting(
     _appSettingsProvider.setSetting(
       AppSettingsEnum.mapThemeMode,
       AppSettingsEnum.mapThemeMode,
       isDarkTheme,
       isDarkTheme,
     );
     );
-    state = state.copyWith(isDarkTheme: isDarkTheme);
+    state = state.copyWith(isDarkTheme: isDarkTheme, isLoading: true);
+  }
+
+  void _fetchStyleFromServer(bool isDarkTheme) async {
+    final styleResponse = await _apiService.systemConfigApi
+        .getMapStyleWithHttpInfo(isDarkTheme ? MapTheme.dark : MapTheme.light);
+    if (styleResponse.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(styleResponse.statusCode, styleResponse.body);
+    }
+    final styleJsonString = styleResponse.body.isNotEmpty &&
+            styleResponse.statusCode != HttpStatus.noContent
+        ? styleResponse.body
+        : null;
+
+    if (styleJsonString == null) {
+      _log.severe('Style JSON from server is empty');
+      return;
+    }
+    final styleJson = await compute(jsonDecode, styleJsonString);
+    if (styleJson is! Map<String, dynamic>) {
+      _log.severe('Style JSON from server is invalid');
+      return;
+    }
+    final styleReader = StyleReader(uri: '');
+    Style? style;
+    try {
+      style = await styleReader.readFromMap(styleJson);
+    } finally {
+      // Consume all error
+    }
+    state = state.copyWith(
+      mapStyle: style,
+      isLoading: false,
+    );
   }
   }
 
 
   void switchFavoriteOnly(bool isFavoriteOnly) {
   void switchFavoriteOnly(bool isFavoriteOnly) {
@@ -51,9 +116,44 @@ class MapStateNotifier extends StateNotifier<MapState> {
     );
     );
     state = state.copyWith(relativeTime: relativeTime);
     state = state.copyWith(relativeTime: relativeTime);
   }
   }
+
+  Widget getTileLayer([bool forceDark = false]) {
+    if (isRaster) {
+      final rasterProvider = state.mapStyle!.rasterTileProvider;
+      final rasterLayer = TileLayer(
+        urlTemplate: rasterProvider!.url,
+        maxNativeZoom: rasterProvider.maximumZoom,
+        maxZoom: rasterProvider.maximumZoom.toDouble(),
+      );
+      return state.isDarkTheme || forceDark
+          ? InvertionFilter(
+              child: SaturationFilter(
+                saturation: -1,
+                child: BrightnessFilter(
+                  brightness: -1,
+                  child: rasterLayer,
+                ),
+              ),
+            )
+          : rasterLayer;
+    }
+    if (state.mapStyle != null && !isRaster) {
+      return VectorTileLayer(
+        // Tiles and themes will be set for vector providers
+        tileProviders: state.mapStyle!.providers!,
+        theme: state.mapStyle!.theme!,
+        sprites: state.mapStyle!.sprites,
+        concurrency: 6,
+      );
+    }
+    return const Center(child: ImmichLoadingIndicator());
+  }
 }
 }
 
 
 final mapStateNotifier =
 final mapStateNotifier =
     StateNotifierProvider<MapStateNotifier, MapState>((ref) {
     StateNotifierProvider<MapStateNotifier, MapState>((ref) {
-  return MapStateNotifier(ref.watch(appSettingsServiceProvider));
+  return MapStateNotifier(
+    ref.watch(appSettingsServiceProvider),
+    ref.watch(apiServiceProvider),
+  );
 });
 });

+ 12 - 19
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart

@@ -15,7 +15,6 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:immich_mobile/utils/color_filter_generator.dart';
 import 'package:immich_mobile/utils/color_filter_generator.dart';
 import 'package:immich_mobile/utils/debounce.dart';
 import 'package:immich_mobile/utils/debounce.dart';
 import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
-import 'package:url_launcher/url_launcher.dart';
 
 
 class MapPageBottomSheet extends StatefulHookConsumerWidget {
 class MapPageBottomSheet extends StatefulHookConsumerWidget {
   final Stream mapPageEventStream;
   final Stream mapPageEventStream;
@@ -320,24 +319,18 @@ class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
             Positioned(
             Positioned(
               bottom: maxHeight * currentExtend.value,
               bottom: maxHeight * currentExtend.value,
               left: 0,
               left: 0,
-              child: GestureDetector(
-                onTap: () => launchUrl(
-                  Uri.parse('https://openstreetmap.org/copyright'),
-                ),
-                child: ColoredBox(
-                  color: (widget.isDarkTheme
-                      ? Colors.grey[900]
-                      : Colors.grey[100])!,
-                  child: Padding(
-                    padding: const EdgeInsets.all(3),
-                    child: Text(
-                      '© OpenStreetMap contributors',
-                      style: TextStyle(
-                        fontSize: 6,
-                        color: !widget.isDarkTheme
-                            ? Colors.grey[900]
-                            : Colors.grey[100],
-                      ),
+              child: ColoredBox(
+                color:
+                    (widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
+                child: Padding(
+                  padding: const EdgeInsets.all(3),
+                  child: Text(
+                    'OpenStreetMap contributors',
+                    style: TextStyle(
+                      fontSize: 6,
+                      color: !widget.isDarkTheme
+                          ? Colors.grey[900]
+                          : Colors.grey[100],
                     ),
                     ),
                   ),
                   ),
                 ),
                 ),

+ 4 - 15
mobile/lib/modules/map/ui/map_thumbnail.dart

@@ -1,8 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_map/plugin_api.dart';
 import 'package:flutter_map/plugin_api.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/shared/providers/server_info.provider.dart';
-import 'package:immich_mobile/utils/color_filter_generator.dart';
+import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
 import 'package:latlong2/latlong.dart';
 import 'package:latlong2/latlong.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:url_launcher/url_launcher.dart';
 
 
@@ -29,11 +28,7 @@ class MapThumbnail extends HookConsumerWidget {
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    final tileLayer = TileLayer(
-      urlTemplate: ref.watch(
-        serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
-      ),
-    );
+    ref.watch(mapStateNotifier.select((s) => s.mapStyle));
 
 
     return SizedBox(
     return SizedBox(
       height: height,
       height: height,
@@ -55,20 +50,14 @@ class MapThumbnail extends HookConsumerWidget {
                     'OpenStreetMap contributors',
                     'OpenStreetMap contributors',
                     onTap: () => launchUrl(
                     onTap: () => launchUrl(
                       Uri.parse('https://openstreetmap.org/copyright'),
                       Uri.parse('https://openstreetmap.org/copyright'),
+                      mode: LaunchMode.externalApplication,
                     ),
                     ),
                   ),
                   ),
                 ],
                 ],
               ),
               ),
           ],
           ],
           children: [
           children: [
-            isDarkTheme
-                ? InvertionFilter(
-                    child: SaturationFilter(
-                      saturation: -1,
-                      child: tileLayer,
-                    ),
-                  )
-                : tileLayer,
+            ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme),
             if (markers.isNotEmpty) MarkerLayer(markers: markers),
             if (markers.isNotEmpty) MarkerLayer(markers: markers),
           ],
           ],
         ),
         ),

+ 57 - 67
mobile/lib/modules/map/views/map_page.dart

@@ -1,4 +1,5 @@
 import 'dart:async';
 import 'dart:async';
+import 'dart:math' as math;
 
 
 import 'package:auto_route/auto_route.dart';
 import 'package:auto_route/auto_route.dart';
 import 'package:collection/collection.dart';
 import 'package:collection/collection.dart';
@@ -20,10 +21,8 @@ import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
 import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
 import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
-import 'package:immich_mobile/utils/color_filter_generator.dart';
 import 'package:immich_mobile/utils/debounce.dart';
 import 'package:immich_mobile/utils/debounce.dart';
 import 'package:immich_mobile/utils/flutter_map_extensions.dart';
 import 'package:immich_mobile/utils/flutter_map_extensions.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
@@ -79,21 +78,25 @@ class MapPageState extends ConsumerState<MapPage> {
     Set<AssetMarkerData>? assetMarkers, {
     Set<AssetMarkerData>? assetMarkers, {
     bool forceReload = false,
     bool forceReload = false,
   }) {
   }) {
-    final bounds = mapController.bounds;
-    if (bounds != null) {
-      final oldAssetsInBounds = assetsInBounds.toSet();
-      assetsInBounds =
-          assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
-      final shouldReload = forceReload ||
-          assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
-          assetsInBounds.length != oldAssetsInBounds.length;
-      if (shouldReload) {
-        mapPageEventSC.add(
-          MapPageAssetsInBoundUpdated(
-            assetsInBounds.map((e) => e.asset).toList(),
-          ),
-        );
+    try {
+      final bounds = mapController.bounds;
+      if (bounds != null) {
+        final oldAssetsInBounds = assetsInBounds.toSet();
+        assetsInBounds =
+            assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
+        final shouldReload = forceReload ||
+            assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
+            assetsInBounds.length != oldAssetsInBounds.length;
+        if (shouldReload) {
+          mapPageEventSC.add(
+            MapPageAssetsInBoundUpdated(
+              assetsInBounds.map((e) => e.asset).toList(),
+            ),
+          );
+        }
       }
       }
+    } finally {
+      // Consume all error
     }
     }
   }
   }
 
 
@@ -120,6 +123,10 @@ class MapPageState extends ConsumerState<MapPage> {
     final selectedAssets = useState(<Asset>{});
     final selectedAssets = useState(<Asset>{});
     final showLoadingIndicator = useState(false);
     final showLoadingIndicator = useState(false);
     final refetchMarkers = useState(true);
     final refetchMarkers = useState(true);
+    final isLoading =
+        ref.watch(mapStateNotifier.select((state) => state.isLoading));
+    final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
+    final zoomLevel = math.min(maxZoom, 14.0);
 
 
     if (refetchMarkers.value) {
     if (refetchMarkers.value) {
       mapMarkerData.value = ref.watch(mapMarkersProvider).when(
       mapMarkerData.value = ref.watch(mapMarkersProvider).when(
@@ -168,7 +175,6 @@ class MapPageState extends ConsumerState<MapPage> {
         final mapMarker = mapMarkerData.value
         final mapMarker = mapMarkerData.value
             .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
             .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
         if (mapMarker != null) {
         if (mapMarker != null) {
-          const zoomLevel = 16.0;
           LatLng? newCenter = mapController.centerBoundsWithPadding(
           LatLng? newCenter = mapController.centerBoundsWithPadding(
             mapMarker.point,
             mapMarker.point,
             const Offset(0, -120),
             const Offset(0, -120),
@@ -230,7 +236,7 @@ class MapPageState extends ConsumerState<MapPage> {
         forceAssetUpdate = true;
         forceAssetUpdate = true;
         mapController.move(
         mapController.move(
           LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
           LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
-          12,
+          zoomLevel,
         );
         );
       } catch (error) {
       } catch (error) {
         log.severe(
         log.severe(
@@ -359,24 +365,6 @@ class MapPageState extends ConsumerState<MapPage> {
       selectedAssets.value = selection;
       selectedAssets.value = selection;
     }
     }
 
 
-    final tileLayer = TileLayer(
-      urlTemplate: ref.watch(
-        serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
-      ),
-      maxNativeZoom: 19,
-      maxZoom: 19,
-    );
-
-    final darkTileLayer = InvertionFilter(
-      child: SaturationFilter(
-        saturation: -1,
-        child: BrightnessFilter(
-          brightness: -1,
-          child: tileLayer,
-        ),
-      ),
-    );
-
     final markerLayer = MarkerLayer(
     final markerLayer = MarkerLayer(
       markers: [
       markers: [
         if (closestAssetMarker.value != null)
         if (closestAssetMarker.value != null)
@@ -451,38 +439,40 @@ class MapPageState extends ConsumerState<MapPage> {
           extendBodyBehindAppBar: true,
           extendBodyBehindAppBar: true,
           body: Stack(
           body: Stack(
             children: [
             children: [
-              FlutterMap(
-                mapController: mapController,
-                options: MapOptions(
-                  maxBounds:
-                      LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
-                  interactiveFlags: InteractiveFlag.doubleTapZoom |
-                      InteractiveFlag.drag |
-                      InteractiveFlag.flingAnimation |
-                      InteractiveFlag.pinchMove |
-                      InteractiveFlag.pinchZoom,
-                  center: LatLng(20, 20),
-                  zoom: 2,
-                  minZoom: 1,
-                  maxZoom: 18, // max level supported by OSM,
-                  onMapReady: () {
-                    mapController.mapEventStream.listen(onMapEvent);
-                  },
+              if (!isLoading)
+                FlutterMap(
+                  mapController: mapController,
+                  options: MapOptions(
+                    maxBounds:
+                        LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
+                    interactiveFlags: InteractiveFlag.doubleTapZoom |
+                        InteractiveFlag.drag |
+                        InteractiveFlag.flingAnimation |
+                        InteractiveFlag.pinchMove |
+                        InteractiveFlag.pinchZoom,
+                    center: LatLng(20, 20),
+                    zoom: 2,
+                    minZoom: 1,
+                    maxZoom: maxZoom,
+                    onMapReady: () {
+                      mapController.mapEventStream.listen(onMapEvent);
+                    },
+                  ),
+                  children: [
+                    ref.read(mapStateNotifier.notifier).getTileLayer(),
+                    heatMapLayer,
+                    markerLayer,
+                  ],
                 ),
                 ),
-                children: [
-                  isDarkTheme ? darkTileLayer : tileLayer,
-                  heatMapLayer,
-                  markerLayer,
-                ],
-              ),
-              MapPageBottomSheet(
-                mapPageEventStream: mapPageEventSC.stream,
-                bottomSheetEventSC: bottomSheetEventSC,
-                selectionEnabled: selectionEnabledHook.value,
-                selectionlistener: selectionListener,
-                isDarkTheme: isDarkTheme,
-              ),
-              if (showLoadingIndicator.value)
+              if (!isLoading)
+                MapPageBottomSheet(
+                  mapPageEventStream: mapPageEventSC.stream,
+                  bottomSheetEventSC: bottomSheetEventSC,
+                  selectionEnabled: selectionEnabledHook.value,
+                  selectionlistener: selectionListener,
+                  isDarkTheme: isDarkTheme,
+                ),
+              if (showLoadingIndicator.value || isLoading)
                 Positioned(
                 Positioned(
                   top: MediaQuery.of(context).size.height * 0.35,
                   top: MediaQuery.of(context).size.height * 0.35,
                   left: MediaQuery.of(context).size.width * 0.425,
                   left: MediaQuery.of(context).size.width * 0.425,

+ 15 - 12
mobile/lib/modules/search/ui/curated_places_row.dart

@@ -46,18 +46,21 @@ class CuratedPlacesRow extends CuratedRow {
                   isDarkTheme: Theme.of(context).brightness == Brightness.dark,
                   isDarkTheme: Theme.of(context).brightness == Brightness.dark,
                 ),
                 ),
               ),
               ),
-              Container(
-                decoration: BoxDecoration(
-                  borderRadius: BorderRadius.circular(10),
-                  color: Colors.black,
-                  gradient: LinearGradient(
-                    begin: FractionalOffset.topCenter,
-                    end: FractionalOffset.bottomCenter,
-                    colors: [
-                      Colors.blueGrey.withOpacity(0.0),
-                      Colors.black.withOpacity(0.4),
-                    ],
-                    stops: const [0.0, 1.0],
+              Padding(
+                padding: const EdgeInsets.only(right: 10.0),
+                child: Container(
+                  decoration: BoxDecoration(
+                    borderRadius: BorderRadius.circular(10),
+                    color: Colors.black,
+                    gradient: LinearGradient(
+                      begin: FractionalOffset.topCenter,
+                      end: FractionalOffset.bottomCenter,
+                      colors: [
+                        Colors.blueGrey.withOpacity(0.0),
+                        Colors.black.withOpacity(0.4),
+                      ],
+                      stops: const [0.0, 0.4],
+                    ),
                   ),
                   ),
                 ),
                 ),
               ),
               ),

+ 4 - 12
mobile/lib/shared/models/server_info/server_config.model.dart

@@ -2,43 +2,35 @@ import 'package:openapi/api.dart';
 
 
 class ServerConfig {
 class ServerConfig {
   final int trashDays;
   final int trashDays;
-  final String mapTileUrl;
 
 
   const ServerConfig({
   const ServerConfig({
     required this.trashDays,
     required this.trashDays,
-    required this.mapTileUrl,
   });
   });
 
 
   ServerConfig copyWith({
   ServerConfig copyWith({
     int? trashDays,
     int? trashDays,
-    String? mapTileUrl,
   }) {
   }) {
     return ServerConfig(
     return ServerConfig(
       trashDays: trashDays ?? this.trashDays,
       trashDays: trashDays ?? this.trashDays,
-      mapTileUrl: mapTileUrl ?? this.mapTileUrl,
     );
     );
   }
   }
 
 
   @override
   @override
   String toString() {
   String toString() {
-    return 'ServerConfig(trashDays: $trashDays, mapTileUrl: $mapTileUrl)';
+    return 'ServerConfig(trashDays: $trashDays)';
   }
   }
 
 
-  ServerConfig.fromDto(ServerConfigDto dto)
-      : trashDays = dto.trashDays,
-        mapTileUrl = dto.mapTileUrl;
+  ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays;
 
 
   @override
   @override
   bool operator ==(Object other) {
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
     if (identical(this, other)) return true;
 
 
-    return other is ServerConfig &&
-        other.trashDays == trashDays &&
-        other.mapTileUrl == mapTileUrl;
+    return other is ServerConfig && other.trashDays == trashDays;
   }
   }
 
 
   @override
   @override
   int get hashCode {
   int get hashCode {
-    return trashDays.hashCode ^ mapTileUrl.hashCode;
+    return trashDays.hashCode;
   }
   }
 }
 }

+ 0 - 1
mobile/lib/shared/providers/server_info.provider.dart

@@ -23,7 +23,6 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
               trash: true,
               trash: true,
             ),
             ),
             serverConfig: const ServerConfig(
             serverConfig: const ServerConfig(
-              mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
               trashDays: 30,
               trashDays: 30,
             ),
             ),
             serverDiskInfo: const ServerDiskInfo(
             serverDiskInfo: const ServerDiskInfo(

+ 2 - 0
mobile/lib/shared/services/api.service.dart

@@ -22,6 +22,7 @@ class ApiService {
   late PersonApi personApi;
   late PersonApi personApi;
   late AuditApi auditApi;
   late AuditApi auditApi;
   late SharedLinkApi sharedLinkApi;
   late SharedLinkApi sharedLinkApi;
+  late SystemConfigApi systemConfigApi;
   late ActivityApi activityApi;
   late ActivityApi activityApi;
 
 
   ApiService() {
   ApiService() {
@@ -48,6 +49,7 @@ class ApiService {
     personApi = PersonApi(_apiClient);
     personApi = PersonApi(_apiClient);
     auditApi = AuditApi(_apiClient);
     auditApi = AuditApi(_apiClient);
     sharedLinkApi = SharedLinkApi(_apiClient);
     sharedLinkApi = SharedLinkApi(_apiClient);
+    systemConfigApi = SystemConfigApi(_apiClient);
     activityApi = ActivityApi(_apiClient);
     activityApi = ActivityApi(_apiClient);
   }
   }
 
 

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -81,6 +81,7 @@ doc/LoginCredentialDto.md
 doc/LoginResponseDto.md
 doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
 doc/LogoutResponseDto.md
 doc/MapMarkerResponseDto.md
 doc/MapMarkerResponseDto.md
+doc/MapTheme.md
 doc/MemoryLaneResponseDto.md
 doc/MemoryLaneResponseDto.md
 doc/MergePersonDto.md
 doc/MergePersonDto.md
 doc/ModelType.md
 doc/ModelType.md
@@ -263,6 +264,7 @@ 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/map_marker_response_dto.dart
+lib/model/map_theme.dart
 lib/model/memory_lane_response_dto.dart
 lib/model/memory_lane_response_dto.dart
 lib/model/merge_person_dto.dart
 lib/model/merge_person_dto.dart
 lib/model/model_type.dart
 lib/model/model_type.dart
@@ -418,6 +420,7 @@ 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/map_marker_response_dto_test.dart
+test/map_theme_test.dart
 test/memory_lane_response_dto_test.dart
 test/memory_lane_response_dto_test.dart
 test/merge_person_dto_test.dart
 test/merge_person_dto_test.dart
 test/model_type_test.dart
 test/model_type_test.dart

+ 2 - 0
mobile/openapi/README.md

@@ -181,6 +181,7 @@ Class | Method | HTTP request | Description
 *SharedLinkApi* | [**updateSharedLink**](doc//SharedLinkApi.md#updatesharedlink) | **PATCH** /shared-link/{id} | 
 *SharedLinkApi* | [**updateSharedLink**](doc//SharedLinkApi.md#updatesharedlink) | **PATCH** /shared-link/{id} | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | 
 *SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | 
+*SystemConfigApi* | [**getMapStyle**](doc//SystemConfigApi.md#getmapstyle) | **GET** /system-config/map/style.json | 
 *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 *SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 *SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 *TagApi* | [**createTag**](doc//TagApi.md#createtag) | **POST** /tag | 
 *TagApi* | [**createTag**](doc//TagApi.md#createtag) | **POST** /tag | 
@@ -274,6 +275,7 @@ Class | Method | HTTP request | Description
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
  - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
  - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
+ - [MapTheme](doc//MapTheme.md)
  - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
  - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
  - [MergePersonDto](doc//MergePersonDto.md)
  - [MergePersonDto](doc//MergePersonDto.md)
  - [ModelType](doc//ModelType.md)
  - [ModelType](doc//ModelType.md)

+ 14 - 0
mobile/openapi/doc/MapTheme.md

@@ -0,0 +1,14 @@
+# openapi.model.MapTheme
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 0 - 1
mobile/openapi/doc/ServerConfigDto.md

@@ -10,7 +10,6 @@ Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
 **isInitialized** | **bool** |  | 
 **isInitialized** | **bool** |  | 
 **loginPageMessage** | **String** |  | 
 **loginPageMessage** | **String** |  | 
-**mapTileUrl** | **String** |  | 
 **oauthButtonText** | **String** |  | 
 **oauthButtonText** | **String** |  | 
 **trashDays** | **int** |  | 
 **trashDays** | **int** |  | 
 
 

+ 56 - 0
mobile/openapi/doc/SystemConfigApi.md

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 ------------- | ------------- | -------------
 [**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config | 
 [**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config | 
 [**getConfigDefaults**](SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | 
 [**getConfigDefaults**](SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | 
+[**getMapStyle**](SystemConfigApi.md#getmapstyle) | **GET** /system-config/map/style.json | 
 [**getStorageTemplateOptions**](SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 [**getStorageTemplateOptions**](SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 [**updateConfig**](SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 [**updateConfig**](SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 
 
@@ -117,6 +118,61 @@ This endpoint does not need any parameter.
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **getMapStyle**
+> Object getMapStyle(theme)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SystemConfigApi();
+final theme = ; // MapTheme | 
+
+try {
+    final result = api_instance.getMapStyle(theme);
+    print(result);
+} catch (e) {
+    print('Exception when calling SystemConfigApi->getMapStyle: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **theme** | [**MapTheme**](.md)|  | 
+
+### Return type
+
+[**Object**](Object.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getStorageTemplateOptions**
 # **getStorageTemplateOptions**
 > SystemConfigTemplateStorageOptionDto getStorageTemplateOptions()
 > SystemConfigTemplateStorageOptionDto getStorageTemplateOptions()
 
 

+ 2 - 1
mobile/openapi/doc/SystemConfigMapDto.md

@@ -8,8 +8,9 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
+**darkStyle** | **String** |  | 
 **enabled** | **bool** |  | 
 **enabled** | **bool** |  | 
-**tileUrl** | **String** |  | 
+**lightStyle** | **String** |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -116,6 +116,7 @@ part 'model/login_credential_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/logout_response_dto.dart';
 part 'model/logout_response_dto.dart';
 part 'model/map_marker_response_dto.dart';
 part 'model/map_marker_response_dto.dart';
+part 'model/map_theme.dart';
 part 'model/memory_lane_response_dto.dart';
 part 'model/memory_lane_response_dto.dart';
 part 'model/merge_person_dto.dart';
 part 'model/merge_person_dto.dart';
 part 'model/model_type.dart';
 part 'model/model_type.dart';

+ 49 - 0
mobile/openapi/lib/api/system_config_api.dart

@@ -98,6 +98,55 @@ class SystemConfigApi {
     return null;
     return null;
   }
   }
 
 
+  /// Performs an HTTP 'GET /system-config/map/style.json' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [MapTheme] theme (required):
+  Future<Response> getMapStyleWithHttpInfo(MapTheme theme,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/system-config/map/style.json';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+      queryParams.addAll(_queryParams('', 'theme', theme));
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [MapTheme] theme (required):
+  Future<Object?> getMapStyle(MapTheme theme,) async {
+    final response = await getMapStyleWithHttpInfo(theme,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /system-config/storage-template-options' operation and returns the [Response].
   /// Performs an HTTP 'GET /system-config/storage-template-options' operation and returns the [Response].
   Future<Response> getStorageTemplateOptionsWithHttpInfo() async {
   Future<Response> getStorageTemplateOptionsWithHttpInfo() async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -321,6 +321,8 @@ class ApiClient {
           return LogoutResponseDto.fromJson(value);
           return LogoutResponseDto.fromJson(value);
         case 'MapMarkerResponseDto':
         case 'MapMarkerResponseDto':
           return MapMarkerResponseDto.fromJson(value);
           return MapMarkerResponseDto.fromJson(value);
+        case 'MapTheme':
+          return MapThemeTypeTransformer().decode(value);
         case 'MemoryLaneResponseDto':
         case 'MemoryLaneResponseDto':
           return MemoryLaneResponseDto.fromJson(value);
           return MemoryLaneResponseDto.fromJson(value);
         case 'MergePersonDto':
         case 'MergePersonDto':

+ 3 - 0
mobile/openapi/lib/api_helper.dart

@@ -88,6 +88,9 @@ String parameterToString(dynamic value) {
   if (value is LibraryType) {
   if (value is LibraryType) {
     return LibraryTypeTypeTransformer().encode(value).toString();
     return LibraryTypeTypeTransformer().encode(value).toString();
   }
   }
+  if (value is MapTheme) {
+    return MapThemeTypeTransformer().encode(value).toString();
+  }
   if (value is ModelType) {
   if (value is ModelType) {
     return ModelTypeTypeTransformer().encode(value).toString();
     return ModelTypeTypeTransformer().encode(value).toString();
   }
   }

+ 85 - 0
mobile/openapi/lib/model/map_theme.dart

@@ -0,0 +1,85 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class MapTheme {
+  /// Instantiate a new enum with the provided [value].
+  const MapTheme._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const light = MapTheme._(r'light');
+  static const dark = MapTheme._(r'dark');
+
+  /// List of all possible values in this [enum][MapTheme].
+  static const values = <MapTheme>[
+    light,
+    dark,
+  ];
+
+  static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value);
+
+  static List<MapTheme>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <MapTheme>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = MapTheme.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [MapTheme] to String,
+/// and [decode] dynamic data back to [MapTheme].
+class MapThemeTypeTransformer {
+  factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._();
+
+  const MapThemeTypeTransformer._();
+
+  String encode(MapTheme data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a MapTheme.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  MapTheme? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'light': return MapTheme.light;
+        case r'dark': return MapTheme.dark;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [MapThemeTypeTransformer] instance.
+  static MapThemeTypeTransformer? _instance;
+}
+

+ 1 - 9
mobile/openapi/lib/model/server_config_dto.dart

@@ -15,7 +15,6 @@ class ServerConfigDto {
   ServerConfigDto({
   ServerConfigDto({
     required this.isInitialized,
     required this.isInitialized,
     required this.loginPageMessage,
     required this.loginPageMessage,
-    required this.mapTileUrl,
     required this.oauthButtonText,
     required this.oauthButtonText,
     required this.trashDays,
     required this.trashDays,
   });
   });
@@ -24,8 +23,6 @@ class ServerConfigDto {
 
 
   String loginPageMessage;
   String loginPageMessage;
 
 
-  String mapTileUrl;
-
   String oauthButtonText;
   String oauthButtonText;
 
 
   int trashDays;
   int trashDays;
@@ -34,7 +31,6 @@ class ServerConfigDto {
   bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
   bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
      other.isInitialized == isInitialized &&
      other.isInitialized == isInitialized &&
      other.loginPageMessage == loginPageMessage &&
      other.loginPageMessage == loginPageMessage &&
-     other.mapTileUrl == mapTileUrl &&
      other.oauthButtonText == oauthButtonText &&
      other.oauthButtonText == oauthButtonText &&
      other.trashDays == trashDays;
      other.trashDays == trashDays;
 
 
@@ -43,18 +39,16 @@ class ServerConfigDto {
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
     (isInitialized.hashCode) +
     (isInitialized.hashCode) +
     (loginPageMessage.hashCode) +
     (loginPageMessage.hashCode) +
-    (mapTileUrl.hashCode) +
     (oauthButtonText.hashCode) +
     (oauthButtonText.hashCode) +
     (trashDays.hashCode);
     (trashDays.hashCode);
 
 
   @override
   @override
-  String toString() => 'ServerConfigDto[isInitialized=$isInitialized, loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays]';
+  String toString() => 'ServerConfigDto[isInitialized=$isInitialized, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
       json[r'isInitialized'] = this.isInitialized;
       json[r'isInitialized'] = this.isInitialized;
       json[r'loginPageMessage'] = this.loginPageMessage;
       json[r'loginPageMessage'] = this.loginPageMessage;
-      json[r'mapTileUrl'] = this.mapTileUrl;
       json[r'oauthButtonText'] = this.oauthButtonText;
       json[r'oauthButtonText'] = this.oauthButtonText;
       json[r'trashDays'] = this.trashDays;
       json[r'trashDays'] = this.trashDays;
     return json;
     return json;
@@ -70,7 +64,6 @@ class ServerConfigDto {
       return ServerConfigDto(
       return ServerConfigDto(
         isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
         isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
         loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
         loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
-        mapTileUrl: mapValueOfType<String>(json, r'mapTileUrl')!,
         oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
         oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
         trashDays: mapValueOfType<int>(json, r'trashDays')!,
         trashDays: mapValueOfType<int>(json, r'trashDays')!,
       );
       );
@@ -122,7 +115,6 @@ class ServerConfigDto {
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
     'isInitialized',
     'isInitialized',
     'loginPageMessage',
     'loginPageMessage',
-    'mapTileUrl',
     'oauthButtonText',
     'oauthButtonText',
     'trashDays',
     'trashDays',
   };
   };

+ 16 - 8
mobile/openapi/lib/model/system_config_map_dto.dart

@@ -13,32 +13,38 @@ part of openapi.api;
 class SystemConfigMapDto {
 class SystemConfigMapDto {
   /// Returns a new [SystemConfigMapDto] instance.
   /// Returns a new [SystemConfigMapDto] instance.
   SystemConfigMapDto({
   SystemConfigMapDto({
+    required this.darkStyle,
     required this.enabled,
     required this.enabled,
-    required this.tileUrl,
+    required this.lightStyle,
   });
   });
 
 
+  String darkStyle;
+
   bool enabled;
   bool enabled;
 
 
-  String tileUrl;
+  String lightStyle;
 
 
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigMapDto &&
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigMapDto &&
+     other.darkStyle == darkStyle &&
      other.enabled == enabled &&
      other.enabled == enabled &&
-     other.tileUrl == tileUrl;
+     other.lightStyle == lightStyle;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
+    (darkStyle.hashCode) +
     (enabled.hashCode) +
     (enabled.hashCode) +
-    (tileUrl.hashCode);
+    (lightStyle.hashCode);
 
 
   @override
   @override
-  String toString() => 'SystemConfigMapDto[enabled=$enabled, tileUrl=$tileUrl]';
+  String toString() => 'SystemConfigMapDto[darkStyle=$darkStyle, enabled=$enabled, lightStyle=$lightStyle]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
+      json[r'darkStyle'] = this.darkStyle;
       json[r'enabled'] = this.enabled;
       json[r'enabled'] = this.enabled;
-      json[r'tileUrl'] = this.tileUrl;
+      json[r'lightStyle'] = this.lightStyle;
     return json;
     return json;
   }
   }
 
 
@@ -50,8 +56,9 @@ class SystemConfigMapDto {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
       return SystemConfigMapDto(
       return SystemConfigMapDto(
+        darkStyle: mapValueOfType<String>(json, r'darkStyle')!,
         enabled: mapValueOfType<bool>(json, r'enabled')!,
         enabled: mapValueOfType<bool>(json, r'enabled')!,
-        tileUrl: mapValueOfType<String>(json, r'tileUrl')!,
+        lightStyle: mapValueOfType<String>(json, r'lightStyle')!,
       );
       );
     }
     }
     return null;
     return null;
@@ -99,8 +106,9 @@ class SystemConfigMapDto {
 
 
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
+    'darkStyle',
     'enabled',
     'enabled',
-    'tileUrl',
+    'lightStyle',
   };
   };
 }
 }
 
 

+ 21 - 0
mobile/openapi/test/map_theme_test.dart

@@ -0,0 +1,21 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for MapTheme
+void main() {
+
+  group('test MapTheme', () {
+
+  });
+
+}

+ 0 - 5
mobile/openapi/test/server_config_dto_test.dart

@@ -26,11 +26,6 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    // String mapTileUrl
-    test('to test the property `mapTileUrl`', () async {
-      // TODO
-    });
-
     // String oauthButtonText
     // String oauthButtonText
     test('to test the property `oauthButtonText`', () async {
     test('to test the property `oauthButtonText`', () async {
       // TODO
       // TODO

+ 5 - 0
mobile/openapi/test/system_config_api_test.dart

@@ -27,6 +27,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future<Object> getMapStyle(MapTheme theme) async
+    test('test getMapStyle', () async {
+      // TODO
+    });
+
     //Future<SystemConfigTemplateStorageOptionDto> getStorageTemplateOptions() async
     //Future<SystemConfigTemplateStorageOptionDto> getStorageTemplateOptions() async
     test('test getStorageTemplateOptions', () async {
     test('test getStorageTemplateOptions', () async {
       // TODO
       // TODO

+ 7 - 2
mobile/openapi/test/system_config_map_dto_test.dart

@@ -16,13 +16,18 @@ void main() {
   // final instance = SystemConfigMapDto();
   // final instance = SystemConfigMapDto();
 
 
   group('test SystemConfigMapDto', () {
   group('test SystemConfigMapDto', () {
+    // String darkStyle
+    test('to test the property `darkStyle`', () async {
+      // TODO
+    });
+
     // bool enabled
     // bool enabled
     test('to test the property `enabled`', () async {
     test('to test the property `enabled`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // String tileUrl
-    test('to test the property `tileUrl`', () async {
+    // String lightStyle
+    test('to test the property `lightStyle`', () async {
       // TODO
       // TODO
     });
     });
 
 

+ 41 - 0
mobile/pubspec.lock

@@ -345,6 +345,14 @@ packages:
       url: "https://pub.dev"
       url: "https://pub.dev"
     source: hosted
     source: hosted
     version: "0.0.2"
     version: "0.0.2"
+  executor_lib:
+    dependency: "direct main"
+    description:
+      name: executor_lib
+      sha256: "544889daa5726462657dab6410b75f2f8e3a77479d85b307a25c346e243bc38e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
   fake_async:
   fake_async:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -1131,6 +1139,14 @@ packages:
       url: "https://pub.dev"
       url: "https://pub.dev"
     source: hosted
     source: hosted
     version: "2.1.0"
     version: "2.1.0"
+  protobuf:
+    dependency: transitive
+    description:
+      name: protobuf
+      sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
   provider:
   provider:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -1520,6 +1536,15 @@ packages:
       url: "https://pub.dev"
       url: "https://pub.dev"
     source: hosted
     source: hosted
     version: "3.0.7"
     version: "3.0.7"
+  vector_map_tiles:
+    dependency: "direct main"
+    description:
+      path: "."
+      ref: immich_above_4
+      resolved-ref: dc685bdbcca2ff2b49b4d0fb77b7bc17fad48608
+      url: "https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git"
+    source: git
+    version: "4.0.0"
   vector_math:
   vector_math:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -1528,6 +1553,22 @@ packages:
       url: "https://pub.dev"
       url: "https://pub.dev"
     source: hosted
     source: hosted
     version: "2.1.4"
     version: "2.1.4"
+  vector_tile:
+    dependency: transitive
+    description:
+      name: vector_tile
+      sha256: "2ac77f6bbd7ddd97efe059207d67bb7eaf807ab98ad58d99fe41a22c230f44e1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0"
+  vector_tile_renderer:
+    dependency: "direct main"
+    description:
+      name: vector_tile_renderer
+      sha256: de212da0f5e48107d3b763a940a428eb1f49d8a4664d41ac0b654f77209a2d0b
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.0"
   video_player:
   video_player:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:

+ 7 - 0
mobile/pubspec.yaml

@@ -28,6 +28,13 @@ dependencies:
   flutter_map: ^4.0.0
   flutter_map: ^4.0.0
   flutter_map_heatmap: ^0.0.4
   flutter_map_heatmap: ^0.0.4
   geolocator: ^10.0.0 # used to move to current location in map view
   geolocator: ^10.0.0 # used to move to current location in map view
+  vector_map_tiles:
+    git:
+      url: https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git
+      ref: immich_above_4
+  # Adding the following as direct dependency since flutter cannot detect them as transitive dep
+  vector_tile_renderer: ^4.0.0
+  executor_lib: 1.1.1
   flutter_udid: ^2.0.0
   flutter_udid: ^2.0.0
   package_info_plus: ^4.1.0
   package_info_plus: ^4.1.0
   url_launcher: ^6.1.3
   url_launcher: ^6.1.3

+ 1 - 0
server/Dockerfile

@@ -18,6 +18,7 @@ ENV NODE_ENV=production
 COPY --from=prod /usr/src/app/node_modules ./node_modules
 COPY --from=prod /usr/src/app/node_modules ./node_modules
 COPY --from=prod /usr/src/app/dist ./dist
 COPY --from=prod /usr/src/app/dist ./dist
 COPY --from=prod /usr/src/app/bin ./bin
 COPY --from=prod /usr/src/app/bin ./bin
+COPY ./assets ./assets
 
 
 COPY LICENSE /licenses/LICENSE.txt
 COPY LICENSE /licenses/LICENSE.txt
 COPY LICENSE /LICENSE
 COPY LICENSE /LICENSE

+ 1895 - 0
server/assets/style-dark.json

@@ -0,0 +1,1895 @@
+{
+  "version": 8,
+  "name": "Immich Map",
+  "metadata": { "maputnik:renderer": "mbgljs" },
+  "sources": {
+    "immich-map": {
+      "type": "vector",
+      "url": "https://api-l.cofractal.com/v0/maps/vt/overture"
+    }
+  },
+  "sprite": "https://maputnik.github.io/osm-liberty/sprites/osm-liberty",
+  "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf",
+  "layers": [
+    {
+      "id": "background",
+      "type": "background",
+      "paint": { "background-color": "rgb(42,42,41)" }
+    },
+    {
+      "id": "park",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "park",
+      "paint": {
+        "fill-color": "rgba(8, 8, 7, 1)",
+        "fill-opacity": 0.7,
+        "fill-outline-color": "rgba(0, 0, 0, 1)",
+        "fill-antialias": false
+      }
+    },
+    {
+      "id": "park_outline",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "park",
+      "paint": { "line-dasharray": [1, 1.5], "line-color": "rgba(55, 55, 55, 1)" }
+    },
+    {
+      "id": "landuse_residential",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "maxzoom": 8,
+      "filter": ["==", "class", "residential"],
+      "paint": {
+        "fill-color": {
+          "base": 1,
+          "stops": [
+            [9, "rgba(59, 56, 56, 0.84)"],
+            [12, "hsla(35, 57%, 88%, 0.49)"]
+          ]
+        }
+      }
+    },
+    {
+      "id": "landcover_wood",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "wood"]],
+      "paint": {
+        "fill-antialias": false,
+        "fill-color": "rgba(186, 209, 173, 0.3)",
+        "fill-opacity": 0.4
+      }
+    },
+    {
+      "id": "landcover_grass",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "grass"]],
+      "paint": {
+        "fill-antialias": false,
+        "fill-color": "rgba(176, 213, 154, 0.2)",
+        "fill-opacity": 0.3
+      }
+    },
+    {
+      "id": "landcover_ice",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "ice"]],
+      "paint": {
+        "fill-antialias": false,
+        "fill-color": "rgba(94, 100, 100, 1)",
+        "fill-opacity": 0.8
+      }
+    },
+    {
+      "id": "landuse_cemetery",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "filter": ["==", "class", "cemetery"],
+      "layout": { "visibility": "none" },
+      "paint": { "fill-color": "rgba(69, 69, 65, 1)" }
+    },
+    {
+      "id": "landuse_hospital",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "filter": ["==", "class", "hospital"],
+      "layout": { "visibility": "none" },
+      "paint": { "fill-color": "#fde" }
+    },
+    {
+      "id": "landuse_school",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "filter": ["==", "class", "school"],
+      "layout": { "visibility": "none" },
+      "paint": { "fill-color": "rgb(236,238,204)" }
+    },
+    {
+      "id": "waterway_tunnel",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["==", "brunnel", "tunnel"]],
+      "paint": {
+        "line-color": "#a0c8f0",
+        "line-dasharray": [3, 3],
+        "line-gap-width": {
+          "stops": [
+            [12, 0],
+            [20, 6]
+          ]
+        },
+        "line-opacity": 1,
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [8, 1],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "waterway_river",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["==", "class", "river"], ["!=", "brunnel", "tunnel"]],
+      "layout": { "line-cap": "round" },
+      "paint": {
+        "line-color": "rgba(78, 85, 88, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [11, 0.5],
+            [20, 6]
+          ]
+        }
+      }
+    },
+    {
+      "id": "waterway_other",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["!=", "class", "river"], ["!=", "brunnel", "tunnel"]],
+      "layout": { "line-cap": "round" },
+      "paint": {
+        "line-color": "#a0c8f0",
+        "line-width": {
+          "base": 1.3,
+          "stops": [
+            [13, 0.5],
+            [20, 6]
+          ]
+        }
+      }
+    },
+    {
+      "id": "water",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "water",
+      "filter": ["all", ["!=", "brunnel", "tunnel"]],
+      "paint": { "fill-color": "rgba(26, 26, 26, 1)" }
+    },
+    {
+      "id": "landcover_sand",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "sand"]],
+      "paint": { "fill-color": "rgba(193, 192, 188, 1)" }
+    },
+    {
+      "id": "aeroway_fill",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "aeroway",
+      "minzoom": 11,
+      "filter": ["==", "$type", "Polygon"],
+      "paint": { "fill-color": "rgba(229, 228, 224, 1)", "fill-opacity": 0.7 }
+    },
+    {
+      "id": "aeroway_runway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "aeroway",
+      "minzoom": 11,
+      "filter": ["all", ["==", "$type", "LineString"], ["==", "class", "runway"]],
+      "paint": {
+        "line-color": "#f0ede9",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [11, 3],
+            [20, 16]
+          ]
+        }
+      }
+    },
+    {
+      "id": "aeroway_taxiway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "aeroway",
+      "minzoom": 11,
+      "filter": ["all", ["==", "$type", "LineString"], ["==", "class", "taxiway"]],
+      "paint": {
+        "line-color": "#f0ede9",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [11, 0.5],
+            [20, 6]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(52, 51, 49, 1)",
+        "line-dasharray": [0.5, 0.25],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_service_track_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-dasharray": [0.5, 0.25],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15, 1],
+            [16, 4],
+            [20, 11]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(40, 38, 36, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_street_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "street", "street_limited"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-opacity": {
+          "stops": [
+            [12, 0],
+            [12.5, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 0.5],
+            [13, 1],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_secondary_tertiary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [8, 1.5],
+            [20, 17]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_trunk_primary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(100, 86, 69, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(28, 26, 26, 1)",
+        "line-dasharray": [0.5, 0.25],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_path_pedestrian",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["==", "brunnel", "tunnel"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "paint": {
+        "line-color": "hsl(0, 0%, 100%)",
+        "line-dasharray": [1, 0.75],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_service_track",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15.5, 0],
+            [16, 2],
+            [20, 7.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(149, 139, 93, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_minor",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "minor"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [13.5, 0],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_secondary_tertiary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff4c6",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [6.5, 0],
+            [7, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_trunk_primary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(116, 114, 97, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(129, 124, 110, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_major_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_major_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_transit_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_transit_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 12,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["==", "ramp", 1]],
+      "layout": {
+        "line-cap": "round",
+        "line-join": "round",
+        "visibility": "visible"
+      },
+      "paint": {
+        "line-color": "rgba(65, 63, 62, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_minor_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "minor"],
+        ["!=", "ramp", 1]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(17, 17, 17, 1)",
+        "line-opacity": {
+          "stops": [
+            [12, 0],
+            [12.5, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 0.5],
+            [13, 1],
+            [14, 4],
+            [20, 20]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_secondary_tertiary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "secondary", "tertiary"],
+        ["!=", "ramp", 1]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(102, 102, 102, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [8, 1.5],
+            [20, 17]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_trunk_primary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(61, 61, 61, 0.6)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 5,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["!=", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(61, 61, 61, 0.6)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 12,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["==", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(184, 184, 179, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_service_track",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": {
+        "line-cap": "round",
+        "line-join": "round",
+        "visibility": "visible"
+      },
+      "paint": {
+        "line-color": "rgba(84, 81, 81, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15.5, 0],
+            [16, 2],
+            [20, 7.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 13,
+      "filter": [
+        "all",
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["==", "ramp", 1],
+        ["!in", "class", "pedestrian", "path", "track", "service", "motorway"]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_minor",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "minor"]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(40, 40, 40, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [13.5, 0],
+            [14, 2.5],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_secondary_tertiary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(36, 33, 33, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [6.5, 0],
+            [8, 0.5],
+            [20, 13]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_trunk_primary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(61, 61, 61, 0.6)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 5,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["!=", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(61, 61, 61, 0.6)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_major_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_major_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_transit_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_transit_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round", "visibility": "visible" },
+      "paint": {
+        "line-color": "rgba(75, 68, 63, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_service_track_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15, 1],
+            [16, 4],
+            [20, 11]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "link"], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_street_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "street", "street_limited"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "hsl(36, 6%, 74%)",
+        "line-opacity": {
+          "stops": [
+            [12, 0],
+            [12.5, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 0.5],
+            [13, 1],
+            [14, 4],
+            [20, 25]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_path_pedestrian_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["==", "brunnel", "bridge"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "paint": {
+        "line-color": "hsl(35, 6%, 80%)",
+        "line-dasharray": [1, 0],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 1.5],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_secondary_tertiary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(61, 57, 52, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [8, 1.5],
+            [20, 17]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_trunk_primary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(102, 102, 102, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round", "visibility": "none" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_path_pedestrian",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["==", "brunnel", "bridge"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "paint": {
+        "line-color": "hsl(0, 0%, 100%)",
+        "line-dasharray": [1, 0.3],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round", "visibility": "none" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_service_track",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15.5, 0],
+            [16, 2],
+            [20, 7.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "link"], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_street",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "minor"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [13.5, 0],
+            [14, 2.5],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_secondary_tertiary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(73, 71, 68, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [6.5, 0],
+            [7, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_trunk_primary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "rgba(147, 147, 143, 1)",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round", "visibility": "none" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_major_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "rail"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_major_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "rail"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_transit_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "transit"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_transit_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "transit"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "building",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "building",
+      "minzoom": 13,
+      "maxzoom": 14,
+      "paint": {
+        "fill-color": "rgba(20, 20, 20, 1)",
+        "fill-outline-color": {
+          "base": 1,
+          "stops": [
+            [13, "rgba(10, 10, 9, 0.32)"],
+            [14, "rgba(22, 22, 22, 1)"]
+          ]
+        }
+      }
+    },
+    {
+      "id": "building-3d",
+      "type": "fill-extrusion",
+      "source": "immich-map",
+      "source-layer": "building",
+      "minzoom": 14,
+      "paint": {
+        "fill-extrusion-color": "rgba(57, 57, 57, 1)",
+        "fill-extrusion-height": {
+          "property": "render_height",
+          "type": "identity"
+        },
+        "fill-extrusion-base": {
+          "property": "render_min_height",
+          "type": "identity"
+        },
+        "fill-extrusion-opacity": 0.8
+      }
+    },
+    {
+      "id": "boundary_state",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary"
+    },
+    {
+      "id": "boundary_3",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "minzoom": 8,
+      "filter": ["all", ["in", "admin_level", 3, 4]],
+      "layout": { "line-join": "round", "visibility": "visible" },
+      "paint": {
+        "line-color": "#9e9cab",
+        "line-dasharray": [5, 1],
+        "line-width": {
+          "base": 1,
+          "stops": [
+            [4, 0.4],
+            [5, 1],
+            [12, 1.8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "boundary_country",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "maxzoom": 5,
+      "filter": ["all", ["==", "admin_level", 2], ["!has", "claimed_by"]],
+      "layout": {
+        "line-cap": "round",
+        "line-join": "round",
+        "visibility": "visible"
+      },
+      "paint": {
+        "line-color": "hsl(248, 1%, 41%)",
+        "line-opacity": {
+          "base": 1,
+          "stops": [
+            [0, 0.4],
+            [4, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1,
+          "stops": [
+            [3, 1],
+            [5, 1.2],
+            [12, 3]
+          ]
+        }
+      }
+    },
+    {
+      "id": "boundary_2_z5-",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "minzoom": 5,
+      "filter": ["all", ["==", "admin_level", 2]],
+      "layout": {
+        "line-cap": "round",
+        "line-join": "round",
+        "visibility": "none"
+      },
+      "paint": {
+        "line-color": "hsl(248, 1%, 41%)",
+        "line-opacity": {
+          "base": 1,
+          "stops": [
+            [0, 0.4],
+            [4, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1,
+          "stops": [
+            [3, 1],
+            [5, 1.2],
+            [12, 3]
+          ]
+        }
+      }
+    },
+    {
+      "id": "water_name_line",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["==", "$type", "LineString"]],
+      "layout": {
+        "text-field": "{name}",
+        "text-font": ["Open Sans Bold"],
+        "text-max-width": 5,
+        "text-size": 12,
+        "symbol-placement": "line"
+      },
+      "paint": {
+        "text-color": "rgba(70, 178, 228, 1)",
+        "text-halo-color": "rgba(255,255,255,0.7)",
+        "text-halo-width": 0
+      }
+    },
+    {
+      "id": "water_name_point",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "water_name",
+      "filter": ["==", "$type", "Point"],
+      "layout": {
+        "text-field": "{name}",
+        "text-font": ["Open Sans Regular"],
+        "text-max-width": 5,
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "rgba(193, 193, 193, 1)",
+        "text-halo-color": "rgba(92, 105, 106, 0.7)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_z16",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "minzoom": 16,
+      "filter": ["all", ["==", "$type", "Point"], [">=", "rank", 20]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "top",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0, 0.6],
+        "text-size": 12,
+        "visibility": "none"
+      },
+      "paint": {
+        "text-color": "#666",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "#ffffff",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_z15",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "minzoom": 15,
+      "filter": ["all", ["==", "$type", "Point"], [">=", "rank", 7], ["<", "rank", 20]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "top",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0, 0.6],
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "rgba(252, 135, 145, 1)",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "rgba(54, 49, 49, 1)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_z14",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "minzoom": 14,
+      "filter": ["all", ["==", "$type", "Point"], [">=", "rank", 1], ["<", "rank", 7]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "top",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Bold Italic"],
+        "text-max-width": 9,
+        "text-offset": [0, 0.6],
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "rgba(153, 242, 197, 1)",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "rgba(0, 0, 0, 1)",
+        "text-halo-width": 0
+      }
+    },
+    {
+      "id": "poi_transit",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "filter": ["all", ["in", "class", "bus", "rail", "airport"]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "left",
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0.9, 0],
+        "text-size": 12,
+        "visibility": "none"
+      },
+      "paint": {
+        "text-color": "#4898ff",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "#ffffff",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "road_label",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "transportation_name",
+      "filter": ["all"],
+      "layout": {
+        "symbol-placement": "line",
+        "text-anchor": "center",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Bold"],
+        "text-offset": [0, 0.15],
+        "text-size": {
+          "base": 1,
+          "stops": [
+            [13, 12],
+            [14, 13]
+          ]
+        }
+      },
+      "paint": {
+        "text-color": "rgba(210, 210, 210, 1)",
+        "text-halo-blur": 0.5,
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "road_shield",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "transportation_name",
+      "minzoom": 7,
+      "filter": ["all", ["<=", "ref_length", 6]],
+      "layout": {
+        "icon-image": "default_{ref_length}",
+        "icon-rotation-alignment": "viewport",
+        "symbol-placement": {
+          "base": 1,
+          "stops": [
+            [10, "point"],
+            [11, "line"]
+          ]
+        },
+        "symbol-spacing": 500,
+        "text-field": "{ref}",
+        "text-font": ["Open Sans Regular"],
+        "text-offset": [0, 0.1],
+        "text-rotation-alignment": "viewport",
+        "text-size": 10,
+        "icon-size": 0.8,
+        "visibility": "none"
+      }
+    },
+    {
+      "id": "place_other",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["in", "class", "hamlet", "island", "islet", "neighbourhood", "suburb", "quarter"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-letter-spacing": 0.1,
+        "text-max-width": 9,
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [12, 10],
+            [15, 14]
+          ]
+        },
+        "text-transform": "uppercase"
+      },
+      "paint": {
+        "text-color": "rgba(255, 255, 255, 1)",
+        "text-halo-color": "rgba(0, 0, 0, 0.8)",
+        "text-halo-width": 1.2
+      }
+    },
+    {
+      "id": "place_village",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "class", "village"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Regular"],
+        "text-max-width": 8,
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [10, 12],
+            [15, 22]
+          ]
+        }
+      },
+      "paint": {
+        "text-color": "rgba(189, 189, 189, 1)",
+        "text-halo-color": "rgba(0, 0, 0, 0.8)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "place_town",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "class", "town"]],
+      "layout": {
+        "icon-image": {
+          "base": 1,
+          "stops": [
+            [0, "dot_9"],
+            [8, ""]
+          ]
+        },
+        "text-anchor": "bottom",
+        "text-field": "{name_en}",
+        "text-font": ["Klokantech Noto Sans Regular"],
+        "text-max-width": 8,
+        "text-offset": [0, 0],
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [7, 12],
+            [11, 16]
+          ]
+        }
+      },
+      "paint": {
+        "text-color": "rgba(247, 247, 247, 0.5)",
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 0
+      }
+    },
+    {
+      "id": "place_city",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "minzoom": 5,
+      "filter": ["all", ["==", "class", "city"]],
+      "layout": {
+        "icon-image": {
+          "base": 1,
+          "stops": [
+            [0, "dot_9"],
+            [8, ""]
+          ]
+        },
+        "text-anchor": "bottom",
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Semibold"],
+        "text-max-width": 8,
+        "text-offset": [0, 0],
+        "text-size": {
+          "base": 0.5,
+          "stops": [
+            [7, 14],
+            [11, 24]
+          ]
+        },
+        "icon-allow-overlap": true,
+        "icon-optional": false
+      },
+      "paint": {
+        "text-color": "rgba(230, 230, 230, 1)",
+        "text-halo-color": "rgba(0, 0, 0, 0.8)",
+        "text-halo-width": 0.5
+      }
+    },
+    {
+      "id": "state",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "minzoom": 4,
+      "maxzoom": 6,
+      "filter": ["all", ["==", "class", "state"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Klokantech Noto Sans Regular"],
+        "text-size": {
+          "stops": [
+            [4, 9],
+            [6, 15]
+          ]
+        },
+        "text-transform": "uppercase"
+      },
+      "paint": {
+        "text-color": "rgba(226, 219, 219, 1)",
+        "text-halo-color": "rgba(0, 0, 0, 0.7)",
+        "text-halo-width": 1,
+        "text-halo-blur": 0,
+        "text-translate": [1, 1]
+      }
+    },
+    {
+      "id": "country_3",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", [">=", "rank", 3], ["==", "class", "country"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Klokantech Noto Sans Bold"],
+        "text-max-width": 6.25,
+        "text-size": {
+          "stops": [
+            [1, 11],
+            [4, 17]
+          ]
+        },
+        "text-transform": "none"
+      },
+      "paint": {
+        "text-color": "rgba(226, 221, 221, 1)",
+        "text-halo-blur": 1,
+        "text-halo-color": "rgba(0, 0, 0, 0.8)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "country_2",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "rank", 2], ["==", "class", "country"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Klokantech Noto Sans Bold"],
+        "text-max-width": 6.25,
+        "text-size": {
+          "stops": [
+            [1, 11],
+            [4, 17]
+          ]
+        },
+        "text-transform": "none"
+      },
+      "paint": {
+        "text-color": "rgba(226, 221, 221, 1)",
+        "text-halo-blur": 1,
+        "text-halo-color": "rgba(0, 0, 0, 0.8)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "country_1",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "rank", 1], ["==", "class", "country"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Klokantech Noto Sans Bold"],
+        "text-max-width": 6.25,
+        "text-size": {
+          "stops": [
+            [1, 11],
+            [4, 17]
+          ]
+        },
+        "text-transform": "none"
+      },
+      "paint": {
+        "text-color": "rgba(226, 221, 221, 1)",
+        "text-halo-blur": 1,
+        "text-halo-color": "rgba(0, 0, 0, 0.8)",
+        "text-halo-width": 1
+      }
+    }
+  ],
+  "id": "immich-map-dark"
+}

+ 2000 - 0
server/assets/style-light.json

@@ -0,0 +1,2000 @@
+{
+  "version": 8,
+  "name": "Immich Map",
+  "metadata": { "maputnik:renderer": "mbgljs" },
+  "sources": {
+    "immich-map": {
+      "type": "vector",
+      "url": "https://api-l.cofractal.com/v0/maps/vt/overture"
+    }
+  },
+  "sprite": "https://maputnik.github.io/osm-liberty/sprites/osm-liberty",
+  "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf",
+  "layers": [
+    {
+      "id": "background",
+      "type": "background",
+      "paint": { "background-color": "rgba(232, 244, 237, 1)" }
+    },
+    {
+      "id": "park",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "park",
+      "paint": {
+        "fill-color": "#d8e8c8",
+        "fill-opacity": 0.7,
+        "fill-outline-color": "rgba(95, 208, 100, 1)"
+      }
+    },
+    {
+      "id": "park_outline",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "park",
+      "paint": {
+        "line-dasharray": [1, 1.5],
+        "line-color": "rgba(228, 241, 215, 1)"
+      }
+    },
+    {
+      "id": "landuse_residential",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "maxzoom": 8,
+      "filter": ["==", "class", "residential"],
+      "paint": {
+        "fill-color": {
+          "base": 1,
+          "stops": [
+            [9, "hsla(0, 3%, 85%, 0.84)"],
+            [12, "hsla(35, 57%, 88%, 0.49)"]
+          ]
+        }
+      }
+    },
+    {
+      "id": "landcover_wood",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "wood"]],
+      "paint": {
+        "fill-antialias": false,
+        "fill-color": "hsla(98, 61%, 72%, 0.7)",
+        "fill-opacity": 0.4
+      }
+    },
+    {
+      "id": "landcover_grass",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "grass"]],
+      "paint": {
+        "fill-antialias": false,
+        "fill-color": "rgba(176, 213, 154, 1)",
+        "fill-opacity": 0.3
+      }
+    },
+    {
+      "id": "landcover_ice",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "ice"]],
+      "paint": {
+        "fill-antialias": false,
+        "fill-color": "rgba(224, 236, 236, 1)",
+        "fill-opacity": 0.8
+      }
+    },
+    {
+      "id": "landuse_cemetery",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "filter": ["==", "class", "cemetery"],
+      "paint": { "fill-color": "hsl(75, 37%, 81%)" }
+    },
+    {
+      "id": "landuse_hospital",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "filter": ["==", "class", "hospital"],
+      "paint": { "fill-color": "#fde" }
+    },
+    {
+      "id": "landuse_school",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landuse",
+      "filter": ["==", "class", "school"],
+      "paint": { "fill-color": "rgb(236,238,204)" }
+    },
+    {
+      "id": "waterway_tunnel",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["==", "brunnel", "tunnel"]],
+      "paint": {
+        "line-color": "#a0c8f0",
+        "line-dasharray": [3, 3],
+        "line-gap-width": {
+          "stops": [
+            [12, 0],
+            [20, 6]
+          ]
+        },
+        "line-opacity": 1,
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [8, 1],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "waterway_river",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["==", "class", "river"], ["!=", "brunnel", "tunnel"]],
+      "layout": { "line-cap": "round" },
+      "paint": {
+        "line-color": "#a0c8f0",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [11, 0.5],
+            [20, 6]
+          ]
+        }
+      }
+    },
+    {
+      "id": "waterway_other",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["!=", "class", "river"], ["!=", "brunnel", "tunnel"]],
+      "layout": { "line-cap": "round" },
+      "paint": {
+        "line-color": "#a0c8f0",
+        "line-width": {
+          "base": 1.3,
+          "stops": [
+            [13, 0.5],
+            [20, 6]
+          ]
+        }
+      }
+    },
+    {
+      "id": "water",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "water",
+      "filter": ["all", ["!=", "brunnel", "tunnel"]],
+      "paint": { "fill-color": "rgba(148, 209, 236, 0.66)" }
+    },
+    {
+      "id": "landcover_sand",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "landcover",
+      "filter": ["all", ["==", "class", "sand"]],
+      "paint": { "fill-color": "rgba(247, 239, 195, 1)" }
+    },
+    {
+      "id": "aeroway_fill",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "aeroway",
+      "minzoom": 11,
+      "filter": ["==", "$type", "Polygon"],
+      "paint": { "fill-color": "rgba(229, 228, 224, 1)", "fill-opacity": 0.7 }
+    },
+    {
+      "id": "aeroway_runway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "aeroway",
+      "minzoom": 11,
+      "filter": ["all", ["==", "$type", "LineString"], ["==", "class", "runway"]],
+      "paint": {
+        "line-color": "#f0ede9",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [11, 3],
+            [20, 16]
+          ]
+        }
+      }
+    },
+    {
+      "id": "aeroway_taxiway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "aeroway",
+      "minzoom": 11,
+      "filter": ["all", ["==", "$type", "LineString"], ["==", "class", "taxiway"]],
+      "paint": {
+        "line-color": "#f0ede9",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [11, 0.5],
+            [20, 6]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-dasharray": [0.5, 0.25],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_service_track_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-dasharray": [0.5, 0.25],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15, 1],
+            [16, 4],
+            [20, 11]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_street_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "street", "street_limited"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-opacity": {
+          "stops": [
+            [12, 0],
+            [12.5, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 0.5],
+            [13, 1],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_secondary_tertiary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [8, 1.5],
+            [20, 17]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_trunk_primary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-dasharray": [0.5, 0.25],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_path_pedestrian",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["==", "brunnel", "tunnel"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "paint": {
+        "line-color": "hsl(0, 0%, 100%)",
+        "line-dasharray": [1, 0.75],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_service_track",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15.5, 0],
+            [16, 2],
+            [20, 7.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff4c6",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_minor",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "minor"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [13.5, 0],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_secondary_tertiary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff4c6",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [6.5, 0],
+            [7, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_trunk_primary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff4c6",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_motorway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#ffdaa6",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_major_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_major_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_transit_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "tunnel_transit_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "tunnel"], ["==", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_area_pattern",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "$type", "Polygon"]],
+      "paint": { "fill-pattern": "pedestrian_polygon" }
+    },
+    {
+      "id": "road_motorway_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 12,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["==", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_service_track_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15, 1],
+            [16, 4],
+            [20, 11]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 13,
+      "filter": [
+        "all",
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["!in", "class", "pedestrian", "path", "track", "service", "motorway"],
+        ["==", "ramp", 1]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_minor_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "minor"],
+        ["!=", "ramp", 1]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-opacity": {
+          "stops": [
+            [12, 0],
+            [12.5, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 0.5],
+            [13, 1],
+            [14, 4],
+            [20, 20]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_secondary_tertiary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "secondary", "tertiary"],
+        ["!=", "ramp", 1]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [8, 1.5],
+            [20, 17]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_trunk_primary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 5,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["!=", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_path_pedestrian",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 14,
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "hsl(0, 0%, 100%)",
+        "line-dasharray": [1, 0.7],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 1],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 12,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["==", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_service_track",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "service", "track"]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15.5, 0],
+            [16, 2],
+            [20, 7.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 13,
+      "filter": [
+        "all",
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["==", "ramp", 1],
+        ["!in", "class", "pedestrian", "path", "track", "service", "motorway"]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_minor",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["!in", "brunnel", "bridge", "tunnel"],
+        ["in", "class", "minor"]
+      ],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [13.5, 0],
+            [14, 2.5],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_secondary_tertiary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [6.5, 0],
+            [8, 0.5],
+            [20, 13]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_trunk_primary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_motorway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 5,
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "motorway"], ["!=", "ramp", 1]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": {
+          "base": 1,
+          "stops": [
+            [5, "hsl(26, 87%, 62%)"],
+            [6, "#fc8"]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_major_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_major_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "rail"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_transit_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_transit_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["!in", "brunnel", "bridge", "tunnel"], ["==", "class", "transit"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "road_one_way_arrow",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 15,
+      "filter": ["==", "oneway", 1],
+      "layout": { "icon-image": "arrow", "symbol-placement": "line" }
+    },
+    {
+      "id": "road_one_way_arrow_opposite",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "minzoom": 15,
+      "filter": ["==", "oneway", -1],
+      "layout": {
+        "icon-image": "arrow",
+        "symbol-placement": "line",
+        "icon-rotate": 180
+      }
+    },
+    {
+      "id": "bridge_motorway_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_service_track_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#cfcdca",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15, 1],
+            [16, 4],
+            [20, 11]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_link_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "link"], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 1],
+            [13, 3],
+            [14, 4],
+            [20, 15]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_street_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "street", "street_limited"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "hsl(36, 6%, 74%)",
+        "line-opacity": {
+          "stops": [
+            [12, 0],
+            [12.5, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12, 0.5],
+            [13, 1],
+            [14, 4],
+            [20, 25]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_path_pedestrian_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["==", "brunnel", "bridge"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "paint": {
+        "line-color": "hsl(35, 6%, 80%)",
+        "line-dasharray": [1, 0],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 1.5],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_secondary_tertiary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [8, 1.5],
+            [20, 17]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_trunk_primary_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway_casing",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#e9ac77",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0.4],
+            [6, 0.7],
+            [7, 1.75],
+            [20, 22]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_path_pedestrian",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": [
+        "all",
+        ["==", "$type", "LineString"],
+        ["==", "brunnel", "bridge"],
+        ["in", "class", "path", "pedestrian"]
+      ],
+      "paint": {
+        "line-color": "hsl(0, 0%, 100%)",
+        "line-dasharray": [1, 0.3],
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [14, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_service_track",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "service", "track"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [15.5, 0],
+            [16, 2],
+            [20, 7.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_link",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "link"], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [12.5, 0],
+            [13, 1.5],
+            [14, 2.5],
+            [20, 11.5]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_street",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "minor"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fff",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [13.5, 0],
+            [14, 2.5],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_secondary_tertiary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "secondary", "tertiary"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [6.5, 0],
+            [7, 0.5],
+            [20, 10]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_trunk_primary",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "primary", "trunk"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fea",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_motorway",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#fc8",
+        "line-width": {
+          "base": 1.2,
+          "stops": [
+            [5, 0],
+            [7, 1],
+            [20, 18]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_major_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "rail"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_major_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "rail"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_transit_rail",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "transit"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14, 0.4],
+            [15, 0.75],
+            [20, 2]
+          ]
+        }
+      }
+    },
+    {
+      "id": "bridge_transit_rail_hatching",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "transportation",
+      "filter": ["all", ["==", "class", "transit"], ["==", "brunnel", "bridge"]],
+      "paint": {
+        "line-color": "#bbb",
+        "line-dasharray": [0.2, 8],
+        "line-width": {
+          "base": 1.4,
+          "stops": [
+            [14.5, 0],
+            [15, 3],
+            [20, 8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "building",
+      "type": "fill",
+      "source": "immich-map",
+      "source-layer": "building",
+      "minzoom": 13,
+      "maxzoom": 14,
+      "paint": {
+        "fill-color": "hsl(35, 8%, 85%)",
+        "fill-outline-color": {
+          "base": 1,
+          "stops": [
+            [13, "hsla(35, 6%, 79%, 0.32)"],
+            [14, "hsl(35, 6%, 79%)"]
+          ]
+        }
+      }
+    },
+    {
+      "id": "building-3d",
+      "type": "fill-extrusion",
+      "source": "immich-map",
+      "source-layer": "building",
+      "minzoom": 14,
+      "paint": {
+        "fill-extrusion-color": "hsl(35, 8%, 85%)",
+        "fill-extrusion-height": {
+          "property": "render_height",
+          "type": "identity"
+        },
+        "fill-extrusion-base": {
+          "property": "render_min_height",
+          "type": "identity"
+        },
+        "fill-extrusion-opacity": 0.8
+      }
+    },
+    {
+      "id": "boundary_state",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "paint": { "line-color": "rgba(185, 185, 185, 0.58)" }
+    },
+    {
+      "id": "boundary_3",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "minzoom": 8,
+      "filter": ["all", ["in", "admin_level", 3, 4]],
+      "layout": { "line-join": "round" },
+      "paint": {
+        "line-color": "#9e9cab",
+        "line-dasharray": [5, 1],
+        "line-width": {
+          "base": 1,
+          "stops": [
+            [4, 0.4],
+            [5, 1],
+            [12, 1.8]
+          ]
+        }
+      }
+    },
+    {
+      "id": "boundary_2_z0-4",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "maxzoom": 5,
+      "filter": ["all", ["==", "admin_level", 2], ["!has", "claimed_by"]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "hsl(240, 50%, 60%)",
+        "line-opacity": {
+          "base": 1,
+          "stops": [
+            [0, 0.4],
+            [4, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1,
+          "stops": [
+            [3, 1],
+            [5, 1.2],
+            [12, 3]
+          ]
+        }
+      }
+    },
+    {
+      "id": "boundary_2_z5-",
+      "type": "line",
+      "source": "immich-map",
+      "source-layer": "boundary",
+      "minzoom": 5,
+      "filter": ["all", ["==", "admin_level", 2]],
+      "layout": { "line-cap": "round", "line-join": "round" },
+      "paint": {
+        "line-color": "hsl(248, 1%, 41%)",
+        "line-opacity": {
+          "base": 1,
+          "stops": [
+            [0, 0.4],
+            [4, 1]
+          ]
+        },
+        "line-width": {
+          "base": 1,
+          "stops": [
+            [3, 1],
+            [5, 1.2],
+            [12, 3]
+          ]
+        }
+      }
+    },
+    {
+      "id": "water_name_line",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "waterway",
+      "filter": ["all", ["==", "$type", "LineString"]],
+      "layout": {
+        "text-field": "{name}",
+        "text-font": ["Open Sans Regular"],
+        "text-max-width": 5,
+        "text-size": 12,
+        "symbol-placement": "line"
+      },
+      "paint": {
+        "text-color": "#5d60be",
+        "text-halo-color": "rgba(255,255,255,0.7)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "water_name_point",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "water_name",
+      "filter": ["==", "$type", "Point"],
+      "layout": {
+        "text-field": "{name}",
+        "text-font": ["Open Sans Regular"],
+        "text-max-width": 5,
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "#5d60be",
+        "text-halo-color": "rgba(255,255,255,0.7)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_z16",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "minzoom": 16,
+      "filter": ["all", ["==", "$type", "Point"], [">=", "rank", 20]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "top",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0, 0.6],
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "#666",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "#ffffff",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_z15",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "minzoom": 15,
+      "filter": ["all", ["==", "$type", "Point"], [">=", "rank", 7], ["<", "rank", 20]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "top",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0, 0.6],
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "#666",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "#ffffff",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_z14",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "minzoom": 14,
+      "filter": ["all", ["==", "$type", "Point"], [">=", "rank", 1], ["<", "rank", 7]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "top",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0, 0.6],
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "#666",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "#ffffff",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "poi_transit",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "poi",
+      "filter": ["all", ["in", "class", "bus", "rail", "airport"]],
+      "layout": {
+        "icon-image": "{class}_11",
+        "text-anchor": "left",
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 9,
+        "text-offset": [0.9, 0],
+        "text-size": 12
+      },
+      "paint": {
+        "text-color": "#4898ff",
+        "text-halo-blur": 0.5,
+        "text-halo-color": "#ffffff",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "road_label",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "transportation_name",
+      "filter": ["all"],
+      "layout": {
+        "symbol-placement": "line",
+        "text-anchor": "center",
+        "text-field": "{name}",
+        "text-font": ["Open Sans Regular"],
+        "text-offset": [0, 0.15],
+        "text-size": {
+          "base": 1,
+          "stops": [
+            [13, 12],
+            [14, 13]
+          ]
+        }
+      },
+      "paint": {
+        "text-color": "#765",
+        "text-halo-blur": 0.5,
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "road_shield",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "transportation_name",
+      "minzoom": 7,
+      "filter": ["all", ["<=", "ref_length", 6]],
+      "layout": {
+        "icon-image": "default_{ref_length}",
+        "icon-rotation-alignment": "viewport",
+        "symbol-placement": {
+          "base": 1,
+          "stops": [
+            [10, "point"],
+            [11, "line"]
+          ]
+        },
+        "symbol-spacing": 500,
+        "text-field": "{ref}",
+        "text-font": ["Open Sans Regular"],
+        "text-offset": [0, 0.1],
+        "text-rotation-alignment": "viewport",
+        "text-size": 10,
+        "icon-size": 0.8
+      }
+    },
+    {
+      "id": "place_other",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["in", "class", "hamlet", "island", "islet", "neighbourhood", "suburb", "quarter"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-letter-spacing": 0.1,
+        "text-max-width": 9,
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [12, 10],
+            [15, 14]
+          ]
+        },
+        "text-transform": "uppercase"
+      },
+      "paint": {
+        "text-color": "#633",
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1.2
+      }
+    },
+    {
+      "id": "place_village",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "class", "village"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Regular"],
+        "text-max-width": 8,
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [10, 12],
+            [15, 22]
+          ]
+        }
+      },
+      "paint": {
+        "text-color": "#333",
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1.2
+      }
+    },
+    {
+      "id": "place_town",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "class", "town"]],
+      "layout": {
+        "icon-image": {
+          "base": 1,
+          "stops": [
+            [0, "dot_9"],
+            [8, ""]
+          ]
+        },
+        "text-anchor": "bottom",
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Regular"],
+        "text-max-width": 8,
+        "text-offset": [0, 0],
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [7, 12],
+            [11, 16]
+          ]
+        }
+      },
+      "paint": {
+        "text-color": "#333",
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1.2
+      }
+    },
+    {
+      "id": "place_city",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "minzoom": 5,
+      "filter": ["all", ["==", "class", "city"]],
+      "layout": {
+        "icon-image": {
+          "base": 1,
+          "stops": [
+            [0, "dot_9"],
+            [8, ""]
+          ]
+        },
+        "text-anchor": "bottom",
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Semibold"],
+        "text-max-width": 8,
+        "text-offset": [0, 0],
+        "text-size": {
+          "base": 1.2,
+          "stops": [
+            [7, 14],
+            [11, 24]
+          ]
+        },
+        "icon-allow-overlap": true,
+        "icon-optional": false
+      },
+      "paint": {
+        "text-color": "#333",
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1.2
+      }
+    },
+    {
+      "id": "state",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "maxzoom": 6,
+      "minzoom": 3.5,
+      "filter": ["all", ["==", "class", "state"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-size": {
+          "stops": [
+            [4, 11],
+            [6, 15]
+          ]
+        },
+        "text-transform": "uppercase"
+      },
+      "paint": {
+        "text-color": "#633",
+        "text-halo-color": "rgba(255,255,255,0.7)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "country_3",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", [">=", "rank", 3], ["==", "class", "country"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 6.25,
+        "text-size": {
+          "stops": [
+            [3, 11],
+            [7, 17]
+          ]
+        },
+        "text-transform": "none"
+      },
+      "paint": {
+        "text-color": "#334",
+        "text-halo-blur": 1,
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "country_2",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "rank", 2], ["==", "class", "country"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 6.25,
+        "text-size": {
+          "stops": [
+            [2, 11],
+            [5, 17]
+          ]
+        },
+        "text-transform": "none"
+      },
+      "paint": {
+        "text-color": "#334",
+        "text-halo-blur": 1,
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "country_1",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "filter": ["all", ["==", "rank", 1], ["==", "class", "country"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-max-width": 6.25,
+        "text-size": {
+          "stops": [
+            [1, 11],
+            [4, 17]
+          ]
+        },
+        "text-transform": "none"
+      },
+      "paint": {
+        "text-color": "#334",
+        "text-halo-blur": 1,
+        "text-halo-color": "rgba(255,255,255,0.8)",
+        "text-halo-width": 1
+      }
+    },
+    {
+      "id": "continent",
+      "type": "symbol",
+      "source": "immich-map",
+      "source-layer": "place",
+      "maxzoom": 1,
+      "filter": ["all", ["==", "class", "continent"]],
+      "layout": {
+        "text-field": "{name_en}",
+        "text-font": ["Open Sans Italic"],
+        "text-size": 13,
+        "text-transform": "uppercase",
+        "text-justify": "center"
+      },
+      "paint": {
+        "text-color": "#633",
+        "text-halo-color": "rgba(255,255,255,0.7)",
+        "text-halo-width": 1
+      }
+    }
+  ],
+  "id": "immich-map-light"
+}

+ 54 - 6
server/immich-openapi-specs.json

@@ -4881,6 +4881,47 @@
         ]
         ]
       }
       }
     },
     },
+    "/system-config/map/style.json": {
+      "get": {
+        "operationId": "getMapStyle",
+        "parameters": [
+          {
+            "name": "theme",
+            "required": true,
+            "in": "query",
+            "schema": {
+              "$ref": "#/components/schemas/MapTheme"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "System Config"
+        ]
+      }
+    },
     "/system-config/storage-template-options": {
     "/system-config/storage-template-options": {
       "get": {
       "get": {
         "operationId": "getStorageTemplateOptions",
         "operationId": "getStorageTemplateOptions",
@@ -7418,6 +7459,13 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "MapTheme": {
+        "enum": [
+          "light",
+          "dark"
+        ],
+        "type": "string"
+      },
       "MemoryLaneResponseDto": {
       "MemoryLaneResponseDto": {
         "properties": {
         "properties": {
           "assets": {
           "assets": {
@@ -7887,9 +7935,6 @@
           "loginPageMessage": {
           "loginPageMessage": {
             "type": "string"
             "type": "string"
           },
           },
-          "mapTileUrl": {
-            "type": "string"
-          },
           "oauthButtonText": {
           "oauthButtonText": {
             "type": "string"
             "type": "string"
           },
           },
@@ -7901,7 +7946,6 @@
           "trashDays",
           "trashDays",
           "oauthButtonText",
           "oauthButtonText",
           "loginPageMessage",
           "loginPageMessage",
-          "mapTileUrl",
           "isInitialized"
           "isInitialized"
         ],
         ],
         "type": "object"
         "type": "object"
@@ -8552,16 +8596,20 @@
       },
       },
       "SystemConfigMapDto": {
       "SystemConfigMapDto": {
         "properties": {
         "properties": {
+          "darkStyle": {
+            "type": "string"
+          },
           "enabled": {
           "enabled": {
             "type": "boolean"
             "type": "boolean"
           },
           },
-          "tileUrl": {
+          "lightStyle": {
             "type": "string"
             "type": "string"
           }
           }
         },
         },
         "required": [
         "required": [
           "enabled",
           "enabled",
-          "tileUrl"
+          "lightStyle",
+          "darkStyle"
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },

+ 2 - 1
server/src/domain/repositories/system-config.repository.ts

@@ -3,8 +3,9 @@ import { SystemConfigEntity } from '@app/infra/entities';
 export const ISystemConfigRepository = 'ISystemConfigRepository';
 export const ISystemConfigRepository = 'ISystemConfigRepository';
 
 
 export interface ISystemConfigRepository {
 export interface ISystemConfigRepository {
+  fetchStyle(url: string): Promise<any>;
   load(): Promise<SystemConfigEntity[]>;
   load(): Promise<SystemConfigEntity[]>;
-  readFile(filename: string): Promise<Buffer>;
+  readFile(filename: string): Promise<string>;
   saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
   saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
   deleteKeys(keys: string[]): Promise<void>;
   deleteKeys(keys: string[]): Promise<void>;
 }
 }

+ 0 - 1
server/src/domain/server-info/server-info.dto.ts

@@ -85,7 +85,6 @@ export class ServerThemeDto extends SystemConfigThemeDto {}
 export class ServerConfigDto {
 export class ServerConfigDto {
   oauthButtonText!: string;
   oauthButtonText!: string;
   loginPageMessage!: string;
   loginPageMessage!: string;
-  mapTileUrl!: string;
   @ApiProperty({ type: 'integer' })
   @ApiProperty({ type: 'integer' })
   trashDays!: number;
   trashDays!: number;
   isInitialized!: boolean;
   isInitialized!: boolean;

+ 0 - 1
server/src/domain/server-info/server-info.service.spec.ts

@@ -185,7 +185,6 @@ describe(ServerInfoService.name, () => {
         loginPageMessage: '',
         loginPageMessage: '',
         oauthButtonText: 'Login with OAuth',
         oauthButtonText: 'Login with OAuth',
         trashDays: 30,
         trashDays: 30,
-        mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
       });
       });
       expect(configMock.load).toHaveBeenCalled();
       expect(configMock.load).toHaveBeenCalled();
     });
     });

+ 0 - 1
server/src/domain/server-info/server-info.service.ts

@@ -85,7 +85,6 @@ export class ServerInfoService {
 
 
     return {
     return {
       loginPageMessage,
       loginPageMessage,
-      mapTileUrl: config.map.tileUrl,
       trashDays: config.trash.days,
       trashDays: config.trash.days,
       oauthButtonText: config.oauth.buttonText,
       oauthButtonText: config.oauth.buttonText,
       isInitialized,
       isInitialized,

+ 4 - 1
server/src/domain/system-config/dto/system-config-map.dto.ts

@@ -5,5 +5,8 @@ export class SystemConfigMapDto {
   enabled!: boolean;
   enabled!: boolean;
 
 
   @IsString()
   @IsString()
-  tileUrl!: string;
+  lightStyle!: string;
+
+  @IsString()
+  darkStyle!: string;
 }
 }

+ 13 - 0
server/src/domain/system-config/system-config-map-theme.dto.ts

@@ -0,0 +1,13 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum } from 'class-validator';
+
+export enum MapTheme {
+  LIGHT = 'light',
+  DARK = 'dark',
+}
+
+export class MapThemeDto {
+  @IsEnum(MapTheme)
+  @ApiProperty({ enum: MapTheme, enumName: 'MapTheme' })
+  theme!: MapTheme;
+}

+ 2 - 1
server/src/domain/system-config/system-config.core.ts

@@ -80,7 +80,8 @@ export const defaults = Object.freeze<SystemConfig>({
   },
   },
   map: {
   map: {
     enabled: true,
     enabled: true,
-    tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+    lightStyle: '',
+    darkStyle: '',
   },
   },
   reverseGeocoding: {
   reverseGeocoding: {
     enabled: true,
     enabled: true,

+ 7 - 6
server/src/domain/system-config/system-config.service.spec.ts

@@ -80,7 +80,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
   },
   },
   map: {
   map: {
     enabled: true,
     enabled: true,
-    tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+    lightStyle: '',
+    darkStyle: '',
   },
   },
   reverseGeocoding: {
   reverseGeocoding: {
     enabled: true,
     enabled: true,
@@ -185,7 +186,7 @@ describe(SystemConfigService.name, () => {
     it('should load the config from a file', async () => {
     it('should load the config from a file', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } };
       const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } };
-      configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
+      configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
 
 
       await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
       await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
 
 
@@ -194,7 +195,7 @@ describe(SystemConfigService.name, () => {
 
 
     it('should accept an empty configuration file', async () => {
     it('should accept an empty configuration file', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
-      configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
+      configMock.readFile.mockResolvedValue(JSON.stringify({}));
 
 
       await expect(sut.getConfig()).resolves.toEqual(defaults);
       await expect(sut.getConfig()).resolves.toEqual(defaults);
 
 
@@ -204,7 +205,7 @@ describe(SystemConfigService.name, () => {
     it('should allow underscores in the machine learning url', async () => {
     it('should allow underscores in the machine learning url', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
       const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
-      configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
+      configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
 
 
       const config = await sut.getConfig();
       const config = await sut.getConfig();
       expect(config.machineLearning.url).toEqual('immich_machine_learning');
       expect(config.machineLearning.url).toEqual('immich_machine_learning');
@@ -222,7 +223,7 @@ describe(SystemConfigService.name, () => {
     for (const test of tests) {
     for (const test of tests) {
       it(`should ${test.should}`, async () => {
       it(`should ${test.should}`, async () => {
         process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
         process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
-        configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(test.config)));
+        configMock.readFile.mockResolvedValue(JSON.stringify(test.config));
 
 
         await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
         await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
       });
       });
@@ -286,7 +287,7 @@ describe(SystemConfigService.name, () => {
 
 
     it('should throw an error if a config file is in use', async () => {
     it('should throw an error if a config file is in use', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
-      configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
+      configMock.readFile.mockResolvedValue(JSON.stringify({}));
       await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
       await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
       expect(configMock.saveAll).not.toHaveBeenCalled();
       expect(configMock.saveAll).not.toHaveBeenCalled();
     });
     });

+ 12 - 1
server/src/domain/system-config/system-config.service.ts

@@ -20,7 +20,7 @@ import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
 export class SystemConfigService {
 export class SystemConfigService {
   private core: SystemConfigCore;
   private core: SystemConfigCore;
   constructor(
   constructor(
-    @Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
+    @Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
     @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
     @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
   ) {
   ) {
@@ -76,4 +76,15 @@ export class SystemConfigService {
 
 
     return options;
     return options;
   }
   }
+
+  async getMapStyle(theme: 'light' | 'dark') {
+    const { map } = await this.getConfig();
+    const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle;
+
+    if (styleUrl) {
+      return this.repository.fetchStyle(styleUrl);
+    }
+
+    return JSON.parse(await this.repository.readFile(`./assets/style-${theme}.json`));
+  }
 }
 }

+ 7 - 1
server/src/immich/controllers/system-config.controller.ts

@@ -1,5 +1,6 @@
 import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain';
 import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain';
-import { Body, Controller, Get, Put } from '@nestjs/common';
+import { MapThemeDto } from '@app/domain/system-config/system-config-map-theme.dto';
+import { Body, Controller, Get, Put, Query } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { ApiTags } from '@nestjs/swagger';
 import { Authenticated } from '../app.guard';
 import { Authenticated } from '../app.guard';
 import { UseValidation } from '../app.utils';
 import { UseValidation } from '../app.utils';
@@ -30,4 +31,9 @@ export class SystemConfigController {
   getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
   getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
     return this.service.getStorageTemplateOptions();
     return this.service.getStorageTemplateOptions();
   }
   }
+
+  @Get('map/style.json')
+  getMapStyle(@Query() dto: MapThemeDto) {
+    return this.service.getMapStyle(dto.theme);
+  }
 }
 }

+ 4 - 2
server/src/infra/entities/system-config.entity.ts

@@ -62,7 +62,8 @@ export enum SystemConfigKey {
   MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES = 'machineLearning.facialRecognition.minFaces',
   MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES = 'machineLearning.facialRecognition.minFaces',
 
 
   MAP_ENABLED = 'map.enabled',
   MAP_ENABLED = 'map.enabled',
-  MAP_TILE_URL = 'map.tileUrl',
+  MAP_LIGHT_STYLE = 'map.lightStyle',
+  MAP_DARK_STYLE = 'map.darkStyle',
 
 
   REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
   REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
   REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
   REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
@@ -194,7 +195,8 @@ export interface SystemConfig {
   };
   };
   map: {
   map: {
     enabled: boolean;
     enabled: boolean;
-    tileUrl: string;
+    lightStyle: string;
+    darkStyle: string;
   };
   };
   reverseGeocoding: {
   reverseGeocoding: {
     enabled: boolean;
     enabled: boolean;

+ 7 - 1
server/src/infra/repositories/system-config.repository.ts

@@ -1,5 +1,6 @@
 import { ISystemConfigRepository } from '@app/domain';
 import { ISystemConfigRepository } from '@app/domain';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
+import axios from 'axios';
 import { readFile } from 'fs/promises';
 import { readFile } from 'fs/promises';
 import { In, Repository } from 'typeorm';
 import { In, Repository } from 'typeorm';
 import { SystemConfigEntity } from '../entities';
 import { SystemConfigEntity } from '../entities';
@@ -9,12 +10,17 @@ export class SystemConfigRepository implements ISystemConfigRepository {
     @InjectRepository(SystemConfigEntity)
     @InjectRepository(SystemConfigEntity)
     private repository: Repository<SystemConfigEntity>,
     private repository: Repository<SystemConfigEntity>,
   ) {}
   ) {}
+  async fetchStyle(url: string) {
+    return axios.get(url).then((response) => response.data);
+  }
 
 
   load(): Promise<SystemConfigEntity[]> {
   load(): Promise<SystemConfigEntity[]> {
     return this.repository.find();
     return this.repository.find();
   }
   }
 
 
-  readFile = readFile;
+  readFile(filename: string): Promise<string> {
+    return readFile(filename, { encoding: 'utf-8' });
+  }
 
 
   saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
   saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
     return this.repository.save(items);
     return this.repository.save(items);

+ 0 - 1
server/test/e2e/server-info.e2e-spec.ts

@@ -96,7 +96,6 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
       expect(body).toEqual({
       expect(body).toEqual({
         loginPageMessage: '',
         loginPageMessage: '',
         oauthButtonText: 'Login with OAuth',
         oauthButtonText: 'Login with OAuth',
-        mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
         trashDays: 30,
         trashDays: 30,
         isInitialized: true,
         isInitialized: true,
       });
       });

+ 1 - 0
server/test/repositories/system-config.repository.mock.ts

@@ -6,6 +6,7 @@ export const newSystemConfigRepositoryMock = (reset = true): jest.Mocked<ISystem
   }
   }
 
 
   return {
   return {
+    fetchStyle: jest.fn(),
     load: jest.fn().mockResolvedValue([]),
     load: jest.fn().mockResolvedValue([]),
     readFile: jest.fn(),
     readFile: jest.fn(),
     saveAll: jest.fn().mockResolvedValue([]),
     saveAll: jest.fn().mockResolvedValue([]),

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 343 - 393
web/package-lock.json


+ 2 - 4
web/package.json

@@ -31,8 +31,6 @@
     "@types/cookie": "^0.5.1",
     "@types/cookie": "^0.5.1",
     "@types/dom-to-image": "^2.6.4",
     "@types/dom-to-image": "^2.6.4",
     "@types/justified-layout": "^4.1.0",
     "@types/justified-layout": "^4.1.0",
-    "@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",
@@ -70,13 +68,13 @@
     "dom-to-image": "^2.6.0",
     "dom-to-image": "^2.6.0",
     "handlebars": "^4.7.7",
     "handlebars": "^4.7.7",
     "justified-layout": "^4.1.0",
     "justified-layout": "^4.1.0",
-    "leaflet": "^1.9.4",
-    "leaflet.markercluster": "^1.5.3",
     "lodash-es": "^4.17.21",
     "lodash-es": "^4.17.21",
     "luxon": "^3.2.1",
     "luxon": "^3.2.1",
+    "maplibre-gl": "^3.5.2",
     "socket.io-client": "^4.6.1",
     "socket.io-client": "^4.6.1",
     "svelte-loading-spinners": "^0.3.4",
     "svelte-loading-spinners": "^0.3.4",
     "svelte-local-storage-store": "^0.5.0",
     "svelte-local-storage-store": "^0.5.0",
+    "svelte-maplibre": "^0.6.0",
     "thumbhash": "^0.1.1"
     "thumbhash": "^0.1.1"
   }
   }
 }
 }

+ 110 - 7
web/src/api/open-api/api.ts

@@ -2224,6 +2224,20 @@ export interface MapMarkerResponseDto {
      */
      */
     'lon': number;
     'lon': number;
 }
 }
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const MapTheme = {
+    Light: 'light',
+    Dark: 'dark'
+} as const;
+
+export type MapTheme = typeof MapTheme[keyof typeof MapTheme];
+
+
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -2822,12 +2836,6 @@ export interface ServerConfigDto {
      * @memberof ServerConfigDto
      * @memberof ServerConfigDto
      */
      */
     'loginPageMessage': string;
     'loginPageMessage': string;
-    /**
-     * 
-     * @type {string}
-     * @memberof ServerConfigDto
-     */
-    'mapTileUrl': string;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -3695,6 +3703,12 @@ export interface SystemConfigMachineLearningDto {
  * @interface SystemConfigMapDto
  * @interface SystemConfigMapDto
  */
  */
 export interface SystemConfigMapDto {
 export interface SystemConfigMapDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigMapDto
+     */
+    'darkStyle': string;
     /**
     /**
      * 
      * 
      * @type {boolean}
      * @type {boolean}
@@ -3706,7 +3720,7 @@ export interface SystemConfigMapDto {
      * @type {string}
      * @type {string}
      * @memberof SystemConfigMapDto
      * @memberof SystemConfigMapDto
      */
      */
-    'tileUrl': string;
+    'lightStyle': string;
 }
 }
 /**
 /**
  * 
  * 
@@ -15063,6 +15077,51 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {MapTheme} theme 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getMapStyle: async (theme: MapTheme, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'theme' is not null or undefined
+            assertParamExists('getMapStyle', 'theme', theme)
+            const localVarPath = `/system-config/map/style.json`;
+            // 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 api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+            if (theme !== undefined) {
+                localVarQueryParameter['theme'] = theme;
+            }
+
+
+    
             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};
@@ -15182,6 +15241,16 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {MapTheme} theme 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getMapStyle(theme: MapTheme, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getMapStyle(theme, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -15227,6 +15296,15 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
         getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
         getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
             return localVarFp.getConfigDefaults(options).then((request) => request(axios, basePath));
             return localVarFp.getConfigDefaults(options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
+            return localVarFp.getMapStyle(requestParameters.theme, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -15247,6 +15325,20 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
     };
     };
 };
 };
 
 
+/**
+ * Request parameters for getMapStyle operation in SystemConfigApi.
+ * @export
+ * @interface SystemConfigApiGetMapStyleRequest
+ */
+export interface SystemConfigApiGetMapStyleRequest {
+    /**
+     * 
+     * @type {MapTheme}
+     * @memberof SystemConfigApiGetMapStyle
+     */
+    readonly theme: MapTheme
+}
+
 /**
 /**
  * Request parameters for updateConfig operation in SystemConfigApi.
  * Request parameters for updateConfig operation in SystemConfigApi.
  * @export
  * @export
@@ -15288,6 +15380,17 @@ export class SystemConfigApi extends BaseAPI {
         return SystemConfigApiFp(this.configuration).getConfigDefaults(options).then((request) => request(this.axios, this.basePath));
         return SystemConfigApiFp(this.configuration).getConfigDefaults(options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SystemConfigApi
+     */
+    public getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig) {
+        return SystemConfigApiFp(this.configuration).getMapStyle(requestParameters.theme, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.

+ 15 - 7
web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte

@@ -9,9 +9,9 @@
   import { fade } from 'svelte/transition';
   import { fade } from 'svelte/transition';
   import SettingAccordion from '../setting-accordion.svelte';
   import SettingAccordion from '../setting-accordion.svelte';
   import SettingButtonsRow from '../setting-buttons-row.svelte';
   import SettingButtonsRow from '../setting-buttons-row.svelte';
-  import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
   import SettingSwitch from '../setting-switch.svelte';
   import SettingSwitch from '../setting-switch.svelte';
   import SettingSelect from '../setting-select.svelte';
   import SettingSelect from '../setting-select.svelte';
+  import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
 
 
   export let config: SystemConfigDto; // this is the config that is being edited
   export let config: SystemConfigDto; // this is the config that is being edited
   export let disabled = false;
   export let disabled = false;
@@ -34,7 +34,8 @@
           ...current,
           ...current,
           map: {
           map: {
             enabled: config.map.enabled,
             enabled: config.map.enabled,
-            tileUrl: config.map.tileUrl,
+            lightStyle: config.map.lightStyle,
+            darkStyle: config.map.darkStyle,
           },
           },
           reverseGeocoding: {
           reverseGeocoding: {
             enabled: config.reverseGeocoding.enabled,
             enabled: config.reverseGeocoding.enabled,
@@ -95,12 +96,19 @@
 
 
               <SettingInputField
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
                 inputType={SettingInputFieldType.TEXT}
-                label="Tile URL"
-                desc="URL to a leaflet compatible tile server"
-                bind:value={config.map.tileUrl}
-                required={true}
+                label="Light Style"
+                desc="URL to a style.json map theme"
+                bind:value={config.map.lightStyle}
+                disabled={disabled || !config.map.enabled}
+                isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
+              />
+              <SettingInputField
+                inputType={SettingInputFieldType.TEXT}
+                label="Dark Style"
+                desc="URL to a style.json map theme"
+                bind:value={config.map.darkStyle}
                 disabled={disabled || !config.map.enabled}
                 disabled={disabled || !config.map.enabled}
-                isEdited={config.map.tileUrl !== savedConfig.map.tileUrl}
+                isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
               />
               />
             </div></SettingAccordion
             </div></SettingAccordion
           >
           >

+ 16 - 29
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -1,10 +1,9 @@
 <script lang="ts">
 <script lang="ts">
   import { page } from '$app/stores';
   import { page } from '$app/stores';
   import { locale } from '$lib/stores/preferences.store';
   import { locale } from '$lib/stores/preferences.store';
-  import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
+  import { featureFlags } from '$lib/stores/server-config.store';
   import { getAssetFilename } from '$lib/utils/asset-utils';
   import { getAssetFilename } from '$lib/utils/asset-utils';
   import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
   import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
-  import type { LatLngTuple } from 'leaflet';
   import { DateTime } from 'luxon';
   import { DateTime } from 'luxon';
   import { createEventDispatcher } from 'svelte';
   import { createEventDispatcher } from 'svelte';
   import { asByteUnitString } from '../../utils/byte-units';
   import { asByteUnitString } from '../../utils/byte-units';
@@ -12,6 +11,7 @@
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import { mdiCalendar, mdiCameraIris, mdiClose, mdiImageOutline, mdiMapMarkerOutline } from '@mdi/js';
   import { mdiCalendar, mdiCameraIris, mdiClose, mdiImageOutline, mdiMapMarkerOutline } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import Icon from '$lib/components/elements/icon.svelte';
+  import Map from '../shared-components/map/map.svelte';
 
 
   export let asset: AssetResponseDto;
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
   export let albums: AlbumResponseDto[] = [];
@@ -36,20 +36,10 @@
     const lng = asset.exifInfo?.longitude;
     const lng = asset.exifInfo?.longitude;
 
 
     if (lat && lng) {
     if (lat && lng) {
-      return [Number(lat.toFixed(7)), Number(lng.toFixed(7))] as LatLngTuple;
+      return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
     }
     }
   })();
   })();
 
 
-  $: {
-    if (!asset.exifInfo) {
-      api.assetApi.getAssetById({ id: asset.id }).then((res) => {
-        asset.exifInfo = res.data?.exifInfo;
-      });
-    }
-  }
-  $: lat = latlng ? latlng[0] : undefined;
-  $: lng = latlng ? latlng[1] : undefined;
-
   $: people = asset.people || [];
   $: people = asset.people || [];
 
 
   const dispatch = createEventDispatcher();
   const dispatch = createEventDispatcher();
@@ -297,24 +287,21 @@
 
 
 {#if latlng && $featureFlags.loaded && $featureFlags.map}
 {#if latlng && $featureFlags.loaded && $featureFlags.map}
   <div class="h-[360px]">
   <div class="h-[360px]">
-    {#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
-      <Map center={latlng} zoom={14}>
-        <TileLayer
-          urlTemplate={$serverConfig.mapTileUrl}
-          options={{
-            attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
-          }}
-        />
-        <Marker {latlng}>
-          <p>
-            {lat}, {lng}
-          </p>
-          <a href="https://www.openstreetmap.org/?mlat={lat}&mlon={lng}&zoom=15#map=15/{lat}/{lng}">
+    <Map mapMarkers={[{ lat: latlng.lat, lon: latlng.lng, id: asset.id }]} center={latlng} zoom={14} simplified>
+      <svelte:fragment slot="popup" let:marker>
+        {@const { lat, lon } = marker}
+        <div class="flex flex-col items-center gap-1">
+          <p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
+          <a
+            href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=15#map=15/{lat}/{lon}"
+            target="_blank"
+            class="font-medium text-immich-primary"
+          >
             Open in OpenStreetMap
             Open in OpenStreetMap
           </a>
           </a>
-        </Marker>
-      </Map>
-    {/await}
+        </div>
+      </svelte:fragment>
+    </Map>
   </div>
   </div>
 {/if}
 {/if}
 
 

+ 0 - 35
web/src/lib/components/shared-components/leaflet/control.svelte

@@ -1,35 +0,0 @@
-<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>

+ 0 - 5
web/src/lib/components/shared-components/leaflet/index.ts

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

+ 0 - 50
web/src/lib/components/shared-components/leaflet/map.svelte

@@ -1,50 +0,0 @@
-<script lang="ts" context="module">
-  import { createContext } from '$lib/utils/context';
-
-  const { get: getContext, set: setMapContext } = createContext<() => Map>();
-
-  export const getMapContext = () => {
-    const getMap = getContext();
-    return getMap();
-  };
-</script>
-
-<script lang="ts">
-  import { onMount, onDestroy } from 'svelte';
-  import { browser } from '$app/environment';
-  import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
-  import 'leaflet/dist/leaflet.css';
-
-  export let center: LatLngExpression;
-  export let zoom: number;
-  export let options: MapOptions | undefined = undefined;
-  export let allowDarkMode = false;
-  let container: HTMLDivElement;
-  let map: Map;
-
-  setMapContext(() => map);
-
-  onMount(() => {
-    if (browser) {
-      map = new Map(container, options);
-    }
-  });
-
-  onDestroy(() => {
-    if (map) map.remove();
-  });
-
-  $: if (map) map.setView(center, zoom);
-</script>
-
-<div bind:this={container} class="h-full w-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>

+ 0 - 31
web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.css

@@ -1,31 +0,0 @@
-.asset-marker-icon {
-  @apply rounded-full;
-  @apply object-cover;
-  @apply border;
-  @apply border-immich-primary;
-  @apply transition-all;
-  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-icon {
-  @apply h-full;
-  @apply w-full;
-  @apply flex;
-  @apply justify-center;
-  @apply items-center;
-  @apply rounded-full;
-  @apply font-bold;
-  @apply bg-violet-50;
-  @apply border;
-  @apply border-immich-primary;
-  @apply text-immich-primary;
-  box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
-}
-
-.dark .map-dark .marker-cluster-icon {
-  @apply bg-blue-200;
-  @apply text-black;
-  @apply border-blue-200;
-  box-shadow: none;
-}

+ 0 - 102
web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker-cluster.svelte

@@ -1,102 +0,0 @@
-<script lang="ts" context="module">
-  import { createContext } from '$lib/utils/context';
-  import { MarkerClusterGroup } from 'leaflet';
-
-  const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
-
-  export const getClusterContext = () => {
-    return getContext()();
-  };
-</script>
-
-<script lang="ts">
-  import type { MapMarkerResponseDto } from '@api';
-  import { DivIcon, LeafletEvent, LeafletMouseEvent, MarkerCluster, Point } from 'leaflet';
-  import 'leaflet.markercluster';
-  import { createEventDispatcher, onDestroy, onMount } from 'svelte';
-  import { getMapContext } from '../map.svelte';
-  import AssetMarker from './asset-marker';
-  import './asset-marker-cluster.css';
-
-  export let markers: MapMarkerResponseDto[];
-  export let spiderfyLimit = 10;
-  let cluster: MarkerClusterGroup;
-
-  const map = getMapContext();
-  const dispatch = createEventDispatcher<{
-    view: { assetIds: string[]; activeAssetIndex: number };
-  }>();
-
-  setClusterContext(() => cluster);
-
-  onMount(() => {
-    cluster = new MarkerClusterGroup({
-      showCoverageOnHover: false,
-      zoomToBoundsOnClick: false,
-      spiderfyOnMaxZoom: false,
-      maxClusterRadius: (zoom) => 80 - zoom * 2,
-      spiderLegPolylineOptions: { opacity: 0 },
-      spiderfyDistanceMultiplier: 3,
-      iconCreateFunction: (options) => {
-        const childCount = options.getChildCount();
-        const iconSize = childCount > spiderfyLimit ? 45 : 40;
-
-        return new DivIcon({
-          html: `<div class="marker-cluster-icon">${childCount}</div>`,
-          className: '',
-          iconSize: new Point(iconSize, iconSize),
-        });
-      },
-    });
-
-    cluster.on('clusterclick', (event: LeafletEvent) => {
-      const markerCluster: MarkerCluster = event.sourceTarget;
-      const childCount = markerCluster.getChildCount();
-
-      if (childCount > spiderfyLimit) {
-        const markers = markerCluster.getAllChildMarkers() as AssetMarker[];
-        onView(markers, markers[0].id);
-      } else {
-        markerCluster.spiderfy();
-      }
-    });
-
-    cluster.on('click', (event: LeafletMouseEvent) => {
-      const marker: AssetMarker = event.sourceTarget;
-      const markerCluster = getClusterByMarker(marker);
-      const markers = markerCluster ? (markerCluster.getAllChildMarkers() as AssetMarker[]) : [marker];
-
-      onView(markers, marker.id);
-    });
-
-    map.addLayer(cluster);
-  });
-
-  /* eslint-disable-next-line  @typescript-eslint/no-explicit-any */
-  const getClusterByMarker = (marker: any): MarkerCluster | undefined => {
-    const mapZoom = map.getZoom();
-
-    while (marker && marker._zoom !== mapZoom) {
-      marker = marker.__parent;
-    }
-
-    return marker;
-  };
-
-  const onView = (markers: AssetMarker[], activeAssetId: string) => {
-    const assetIds = markers.map((marker) => marker.id);
-    const activeAssetIndex = assetIds.indexOf(activeAssetId) || 0;
-    dispatch('view', { assetIds, activeAssetIndex });
-  };
-
-  $: if (cluster) {
-    const leafletMarkers = markers.map((marker) => new AssetMarker(marker));
-
-    cluster.clearLayers();
-    cluster.addLayers(leafletMarkers);
-  }
-
-  onDestroy(() => {
-    if (cluster) cluster.remove();
-  });
-</script>

+ 0 - 37
web/src/lib/components/shared-components/leaflet/marker-cluster/asset-marker.ts

@@ -1,37 +0,0 @@
-import { api, MapMarkerResponseDto } from '@api';
-import { Icon, Map, Marker } from 'leaflet';
-
-export default class AssetMarker extends Marker {
-  id: string;
-  private iconCreated = false;
-
-  constructor(marker: MapMarkerResponseDto) {
-    super([marker.lat, marker.lon]);
-    this.id = marker.id;
-  }
-
-  onAdd(map: Map) {
-    // Set icon when the marker gets actually added to the map. This only
-    // gets called for individual assets and when selecting a cluster, so
-    // creating an icon for every marker in advance is pretty wasteful.
-    if (!this.iconCreated) {
-      this.iconCreated = true;
-      this.setIcon(this.getIcon());
-    }
-
-    return super.onAdd(map);
-  }
-
-  getIcon() {
-    return new Icon({
-      iconUrl: api.getAssetThumbnailUrl(this.id),
-      iconRetinaUrl: api.getAssetThumbnailUrl(this.id),
-      iconSize: [60, 60],
-      iconAnchor: [12, 41],
-      popupAnchor: [1, -34],
-      tooltipAnchor: [16, -28],
-      shadowSize: [41, 41],
-      className: 'asset-marker-icon',
-    });
-  }
-}

+ 0 - 50
web/src/lib/components/shared-components/leaflet/marker.svelte

@@ -1,50 +0,0 @@
-<script lang="ts">
-  import { onDestroy, onMount } from 'svelte';
-  import { Marker, Icon, type LatLngExpression } from 'leaflet';
-  import { getMapContext } from './map.svelte';
-  import iconUrl from 'leaflet/dist/images/marker-icon.png';
-  import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
-  import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
-
-  export let latlng: LatLngExpression;
-  let popupHTML: string;
-  let marker: Marker;
-
-  const defaultIcon = new Icon({
-    iconUrl,
-    iconRetinaUrl,
-    shadowUrl,
-
-    // Default values from Leaflet
-    iconSize: [25, 41],
-    iconAnchor: [12, 41],
-    popupAnchor: [1, -34],
-    tooltipAnchor: [16, -28],
-    shadowSize: [41, 41],
-  });
-  const map = getMapContext();
-
-  onMount(() => {
-    marker = new Marker(latlng, {
-      icon: defaultIcon,
-    }).addTo(map);
-  });
-
-  onDestroy(() => {
-    if (marker) marker.remove();
-  });
-
-  $: if (marker) {
-    marker.setLatLng(latlng);
-
-    if (popupHTML) {
-      marker.bindPopup(popupHTML);
-    } else {
-      marker.unbindPopup();
-    }
-  }
-</script>
-
-<span contenteditable="true" bind:innerHTML={popupHTML} class="hide">
-  <slot />
-</span>

+ 0 - 20
web/src/lib/components/shared-components/leaflet/tile-layer.svelte

@@ -1,20 +0,0 @@
-<script lang="ts">
-  import { TileLayer, type TileLayerOptions } from 'leaflet';
-  import { onDestroy, onMount } from 'svelte';
-  import { getMapContext } from './map.svelte';
-
-  export let urlTemplate: string;
-  export let options: TileLayerOptions | undefined = undefined;
-
-  let tileLayer: TileLayer;
-
-  const map = getMapContext();
-
-  onMount(() => {
-    tileLayer = new TileLayer(urlTemplate, options).addTo(map);
-  });
-
-  onDestroy(() => {
-    tileLayer?.remove();
-  });
-</script>

+ 146 - 0
web/src/lib/components/shared-components/map/map.svelte

@@ -0,0 +1,146 @@
+<script lang="ts">
+  import {
+    MapLibre,
+    GeoJSON,
+    MarkerLayer,
+    AttributionControl,
+    ControlButton,
+    Control,
+    ControlGroup,
+    Map,
+    FullscreenControl,
+    GeolocateControl,
+    NavigationControl,
+    ScaleControl,
+    Popup,
+  } from 'svelte-maplibre';
+  import { mapSettings } from '$lib/stores/preferences.store';
+  import { MapMarkerResponseDto, api } from '@api';
+  import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
+  import type { Feature, Geometry, GeoJsonProperties, Point } from 'geojson';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import { mdiCog } from '@mdi/js';
+  import { createEventDispatcher } from 'svelte';
+
+  export let mapMarkers: MapMarkerResponseDto[];
+  export let showSettingsModal: boolean | undefined = undefined;
+  export let zoom: number | undefined = undefined;
+  export let center: LngLatLike | undefined = undefined;
+  export let simplified = false;
+
+  $: style = (async () => {
+    const { data } = await api.systemConfigApi.getMapStyle({ theme: $mapSettings.allowDarkMode ? 'dark' : 'light' });
+    return data as StyleSpecification;
+  })();
+
+  const dispatch = createEventDispatcher<{ selected: string[] }>();
+
+  function handleAssetClick(assetId: string, map: Map | null) {
+    if (!map) {
+      return;
+    }
+    dispatch('selected', [assetId]);
+  }
+
+  function handleClusterClick(clusterId: number, map: Map | null) {
+    if (!map) {
+      return;
+    }
+
+    const mapSource = map?.getSource('geojson') as GeoJSONSource;
+    mapSource.getClusterLeaves(clusterId, 10000, 0, (error, leaves) => {
+      if (error) {
+        return;
+      }
+
+      if (leaves) {
+        const ids = leaves.map((leaf) => leaf.properties?.id);
+        dispatch('selected', ids);
+      }
+    });
+  }
+
+  type FeaturePoint = Feature<Point, { id: string }>;
+
+  const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
+    return {
+      type: 'Feature',
+      geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
+      properties: {
+        id: marker.id,
+      },
+    };
+  };
+
+  const asMarker = (feature: Feature<Geometry, GeoJsonProperties>): MapMarkerResponseDto => {
+    const featurePoint = feature as FeaturePoint;
+    return {
+      lat: featurePoint.geometry.coordinates[0],
+      lon: featurePoint.geometry.coordinates[1],
+      id: featurePoint.properties.id,
+    };
+  };
+</script>
+
+{#await style then style}
+  <MapLibre {style} class="h-full" {center} {zoom} attributionControl={false} let:map>
+    <NavigationControl position="top-left" showCompass={!simplified} />
+    {#if !simplified}
+      <GeolocateControl position="top-left" fitBoundsOptions={{ maxZoom: 12 }} />
+      <FullscreenControl position="top-left" />
+      <ScaleControl />
+      <AttributionControl compact={false} />
+    {/if}
+    {#if showSettingsModal !== undefined}
+      <Control>
+        <ControlGroup>
+          <ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton>
+        </ControlGroup>
+      </Control>
+    {/if}
+    <GeoJSON
+      data={{
+        type: 'FeatureCollection',
+        features: mapMarkers.map((marker) => {
+          return asFeature(marker);
+        }),
+      }}
+      id="geojson"
+      cluster={{ maxZoom: 14, radius: 500 }}
+    >
+      <MarkerLayer
+        applyToClusters
+        asButton
+        let:feature
+        on:click={(event) => {
+          handleClusterClick(event.detail.feature.properties.cluster_id, map);
+        }}
+      >
+        <div
+          class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
+        >
+          {feature.properties?.point_count}
+        </div>
+      </MarkerLayer>
+      <MarkerLayer
+        applyToClusters={false}
+        asButton
+        let:feature
+        on:click={(event) => {
+          $$slots.popup || handleAssetClick(event.detail.feature.properties.id, map);
+        }}
+      >
+        <img
+          src={api.getAssetThumbnailUrl(feature.properties?.id)}
+          class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150"
+          alt={`Image with id ${feature.properties?.id}`}
+        />
+        {#if $$slots.popup}
+          <Popup openOn="click" closeOnClickOutside>
+            <slot name="popup" marker={asMarker(feature)} />
+          </Popup>
+        {/if}
+      </MarkerLayer>
+    </GeoJSON>
+  </MapLibre>
+{/await}

+ 0 - 1
web/src/lib/stores/server-config.store.ts

@@ -24,7 +24,6 @@ export type ServerConfig = ServerConfigDto & { loaded: boolean };
 export const serverConfig = writable<ServerConfig>({
 export const serverConfig = writable<ServerConfig>({
   loaded: false,
   loaded: false,
   oauthButtonText: '',
   oauthButtonText: '',
-  mapTileUrl: '',
   loginPageMessage: '',
   loginPageMessage: '',
   trashDays: 30,
   trashDays: 30,
   isInitialized: false,
   isInitialized: false,

+ 10 - 47
web/src/routes/(user)/map/+page.svelte

@@ -7,29 +7,26 @@
   import { AppRoute } from '$lib/constants';
   import { AppRoute } from '$lib/constants';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { mapSettings } from '$lib/stores/preferences.store';
   import { mapSettings } from '$lib/stores/preferences.store';
-  import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
+  import { featureFlags } from '$lib/stores/server-config.store';
   import { MapMarkerResponseDto, api } from '@api';
   import { MapMarkerResponseDto, api } from '@api';
   import { isEqual, omit } from 'lodash-es';
   import { isEqual, omit } from 'lodash-es';
   import { DateTime, Duration } from 'luxon';
   import { DateTime, Duration } from 'luxon';
   import { onDestroy, onMount } from 'svelte';
   import { onDestroy, onMount } from 'svelte';
-  import Icon from '$lib/components/elements/icon.svelte';
   import type { PageData } from './$types';
   import type { PageData } from './$types';
-  import { mdiCog } from '@mdi/js';
+  import Map from '$lib/components/shared-components/map/map.svelte';
 
 
   export let data: PageData;
   export let data: PageData;
 
 
   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
 
 
-  let leaflet: typeof import('$lib/components/shared-components/leaflet');
-  let mapMarkers: MapMarkerResponseDto[] = [];
   let abortController: AbortController;
   let abortController: AbortController;
+  let mapMarkers: MapMarkerResponseDto[] = [];
   let viewingAssets: string[] = [];
   let viewingAssets: string[] = [];
   let viewingAssetCursor = 0;
   let viewingAssetCursor = 0;
   let showSettingsModal = false;
   let showSettingsModal = false;
 
 
   onMount(() => {
   onMount(() => {
     loadMapMarkers().then((data) => (mapMarkers = data));
     loadMapMarkers().then((data) => (mapMarkers = data));
-    import('$lib/components/shared-components/leaflet').then((data) => (leaflet = data));
   });
   });
 
 
   onDestroy(() => {
   onDestroy(() => {
@@ -84,10 +81,10 @@
     }
     }
   }
   }
 
 
-  function onViewAssets(assetIds: string[], activeAssetIndex: number) {
-    assetViewingStore.setAssetId(assetIds[activeAssetIndex]);
+  function onViewAssets(assetIds: string[]) {
+    assetViewingStore.setAssetId(assetIds[0]);
     viewingAssets = assetIds;
     viewingAssets = assetIds;
-    viewingAssetCursor = activeAssetIndex;
+    viewingAssetCursor = 0;
   }
   }
 
 
   function navigateNext() {
   function navigateNext() {
@@ -106,44 +103,9 @@
 {#if $featureFlags.loaded && $featureFlags.map}
 {#if $featureFlags.loaded && $featureFlags.map}
   <UserPageLayout user={data.user} title={data.meta.title}>
   <UserPageLayout user={data.user} title={data.meta.title}>
     <div class="isolate h-full w-full">
     <div class="isolate h-full w-full">
-      {#if leaflet}
-        {@const { Map, TileLayer, AssetMarkerCluster, Control } = leaflet}
-        <Map
-          center={[30, 0]}
-          zoom={3}
-          allowDarkMode={$mapSettings.allowDarkMode}
-          options={{
-            maxBounds: [
-              [-90, -180],
-              [90, 180],
-            ],
-            minZoom: 2,
-          }}
-        >
-          <TileLayer
-            urlTemplate={$serverConfig.mapTileUrl}
-            options={{
-              attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
-            }}
-          />
-          <AssetMarkerCluster
-            markers={mapMarkers}
-            on:view={({ detail }) => onViewAssets(detail.assetIds, detail.activeAssetIndex)}
-          />
-          <Control>
-            <button
-              class="flex h-8 w-8 items-center justify-center rounded-sm border-2 border-black/20 bg-white font-bold text-black/70 hover:bg-gray-50 focus:bg-gray-50"
-              title="Open map settings"
-              on:click={() => (showSettingsModal = true)}
-            >
-              <Icon path={mdiCog} size="100%" class="p-1" />
-            </button>
-          </Control>
-        </Map>
-      {/if}
-    </div>
-  </UserPageLayout>
-
+      <Map bind:mapMarkers bind:showSettingsModal on:selected={(event) => onViewAssets(event.detail)} />
+    </div></UserPageLayout
+  >
   <Portal target="body">
   <Portal target="body">
     {#if $showAssetViewer}
     {#if $showAssetViewer}
       <AssetViewer
       <AssetViewer
@@ -152,6 +114,7 @@
         on:next={navigateNext}
         on:next={navigateNext}
         on:previous={navigatePrevious}
         on:previous={navigatePrevious}
         on:close={() => assetViewingStore.showAssetViewer(false)}
         on:close={() => assetViewingStore.showAssetViewer(false)}
+        isShared={false}
       />
       />
     {/if}
     {/if}
   </Portal>
   </Portal>

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott