Pārlūkot izejas kodu

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 gadu atpakaļ
vecāks
revīzija
a147dee4b6
63 mainītis faili ar 5291 papildinājumiem un 1008 dzēšanām
  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;
 }
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const MapTheme = {
+    Light: 'light',
+    Dark: 'dark'
+} as const;
+
+export type MapTheme = typeof MapTheme[keyof typeof MapTheme];
+
+
 /**
  * 
  * @export
@@ -2822,12 +2836,6 @@ export interface ServerConfigDto {
      * @memberof ServerConfigDto
      */
     'loginPageMessage': string;
-    /**
-     * 
-     * @type {string}
-     * @memberof ServerConfigDto
-     */
-    'mapTileUrl': string;
     /**
      * 
      * @type {string}
@@ -3695,6 +3703,12 @@ export interface SystemConfigMachineLearningDto {
  * @interface SystemConfigMapDto
  */
 export interface SystemConfigMapDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigMapDto
+     */
+    'darkStyle': string;
     /**
      * 
      * @type {boolean}
@@ -3706,7 +3720,7 @@ export interface SystemConfigMapDto {
      * @type {string}
      * @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);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15182,6 +15241,16 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
             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.
@@ -15227,6 +15296,15 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
         getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
             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.
@@ -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.
  * @export
@@ -15288,6 +15380,17 @@ export class SystemConfigApi extends BaseAPI {
         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.

+ 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 {
   final bool isDarkTheme;
   final bool showFavoriteOnly;
   final bool includeArchived;
   final int relativeTime;
+  final Style? mapStyle;
+  final bool isLoading;
 
   MapState({
     this.isDarkTheme = false,
     this.showFavoriteOnly = false,
     this.includeArchived = false,
     this.relativeTime = 0,
+    this.mapStyle,
+    this.isLoading = false,
   });
 
   MapState copyWith({
@@ -16,18 +22,22 @@ class MapState {
     bool? showFavoriteOnly,
     bool? includeArchived,
     int? relativeTime,
+    Style? mapStyle,
+    bool? isLoading,
   }) {
     return MapState(
       isDarkTheme: isDarkTheme ?? this.isDarkTheme,
       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
       includeArchived: includeArchived ?? this.includeArchived,
       relativeTime: relativeTime ?? this.relativeTime,
+      mapStyle: mapStyle ?? this.mapStyle,
+      isLoading: isLoading ?? this.isLoading,
     );
   }
 
   @override
   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
@@ -38,7 +48,9 @@ class MapState {
         other.isDarkTheme == isDarkTheme &&
         other.showFavoriteOnly == showFavoriteOnly &&
         other.relativeTime == relativeTime &&
-        other.includeArchived == includeArchived;
+        other.includeArchived == includeArchived &&
+        other.mapStyle == mapStyle &&
+        other.isLoading == isLoading;
   }
 
   @override
@@ -46,6 +58,8 @@ class MapState {
     return isDarkTheme.hashCode ^
         showFavoriteOnly.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: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/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> {
-  MapStateNotifier(this._appSettingsProvider)
+  MapStateNotifier(this._appSettingsProvider, this._apiService)
       : super(
           MapState(
             isDarkTheme: _appSettingsProvider
@@ -15,17 +28,69 @@ class MapStateNotifier extends StateNotifier<MapState> {
                 .getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
             relativeTime: _appSettingsProvider
                 .getSetting<int>(AppSettingsEnum.mapRelativeDate),
+            isLoading: true,
           ),
-        );
+        ) {
+    _fetchStyleFromServer(
+      _appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode),
+    );
+  }
 
   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) {
+    _updateThemeMode(isDarkTheme);
+    _fetchStyleFromServer(isDarkTheme);
+  }
+
+  void _updateThemeMode(bool isDarkTheme) {
     _appSettingsProvider.setSetting(
       AppSettingsEnum.mapThemeMode,
       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) {
@@ -51,9 +116,44 @@ class MapStateNotifier extends StateNotifier<MapState> {
     );
     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 =
     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/debounce.dart';
 import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
-import 'package:url_launcher/url_launcher.dart';
 
 class MapPageBottomSheet extends StatefulHookConsumerWidget {
   final Stream mapPageEventStream;
@@ -320,24 +319,18 @@ class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
             Positioned(
               bottom: maxHeight * currentExtend.value,
               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_map/plugin_api.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:url_launcher/url_launcher.dart';
 
@@ -29,11 +28,7 @@ class MapThumbnail extends HookConsumerWidget {
 
   @override
   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(
       height: height,
@@ -55,20 +50,14 @@ class MapThumbnail extends HookConsumerWidget {
                     'OpenStreetMap contributors',
                     onTap: () => launchUrl(
                       Uri.parse('https://openstreetmap.org/copyright'),
+                      mode: LaunchMode.externalApplication,
                     ),
                   ),
                 ],
               ),
           ],
           children: [
-            isDarkTheme
-                ? InvertionFilter(
-                    child: SaturationFilter(
-                      saturation: -1,
-                      child: tileLayer,
-                    ),
-                  )
-                : tileLayer,
+            ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme),
             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:math' as math;
 
 import 'package:auto_route/auto_route.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/routing/router.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_toast.dart';
-import 'package:immich_mobile/utils/color_filter_generator.dart';
 import 'package:immich_mobile/utils/debounce.dart';
 import 'package:immich_mobile/utils/flutter_map_extensions.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
@@ -79,21 +78,25 @@ class MapPageState extends ConsumerState<MapPage> {
     Set<AssetMarkerData>? assetMarkers, {
     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 showLoadingIndicator = useState(false);
     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) {
       mapMarkerData.value = ref.watch(mapMarkersProvider).when(
@@ -168,7 +175,6 @@ class MapPageState extends ConsumerState<MapPage> {
         final mapMarker = mapMarkerData.value
             .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
         if (mapMarker != null) {
-          const zoomLevel = 16.0;
           LatLng? newCenter = mapController.centerBoundsWithPadding(
             mapMarker.point,
             const Offset(0, -120),
@@ -230,7 +236,7 @@ class MapPageState extends ConsumerState<MapPage> {
         forceAssetUpdate = true;
         mapController.move(
           LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
-          12,
+          zoomLevel,
         );
       } catch (error) {
         log.severe(
@@ -359,24 +365,6 @@ class MapPageState extends ConsumerState<MapPage> {
       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(
       markers: [
         if (closestAssetMarker.value != null)
@@ -451,38 +439,40 @@ class MapPageState extends ConsumerState<MapPage> {
           extendBodyBehindAppBar: true,
           body: Stack(
             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(
                   top: MediaQuery.of(context).size.height * 0.35,
                   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,
                 ),
               ),
-              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 {
   final int trashDays;
-  final String mapTileUrl;
 
   const ServerConfig({
     required this.trashDays,
-    required this.mapTileUrl,
   });
 
   ServerConfig copyWith({
     int? trashDays,
-    String? mapTileUrl,
   }) {
     return ServerConfig(
       trashDays: trashDays ?? this.trashDays,
-      mapTileUrl: mapTileUrl ?? this.mapTileUrl,
     );
   }
 
   @override
   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
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
 
-    return other is ServerConfig &&
-        other.trashDays == trashDays &&
-        other.mapTileUrl == mapTileUrl;
+    return other is ServerConfig && other.trashDays == trashDays;
   }
 
   @override
   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,
             ),
             serverConfig: const ServerConfig(
-              mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
               trashDays: 30,
             ),
             serverDiskInfo: const ServerDiskInfo(

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

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

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

@@ -81,6 +81,7 @@ doc/LoginCredentialDto.md
 doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
 doc/MapMarkerResponseDto.md
+doc/MapTheme.md
 doc/MemoryLaneResponseDto.md
 doc/MergePersonDto.md
 doc/ModelType.md
@@ -263,6 +264,7 @@ lib/model/login_credential_dto.dart
 lib/model/login_response_dto.dart
 lib/model/logout_response_dto.dart
 lib/model/map_marker_response_dto.dart
+lib/model/map_theme.dart
 lib/model/memory_lane_response_dto.dart
 lib/model/merge_person_dto.dart
 lib/model/model_type.dart
@@ -418,6 +420,7 @@ test/login_credential_dto_test.dart
 test/login_response_dto_test.dart
 test/logout_response_dto_test.dart
 test/map_marker_response_dto_test.dart
+test/map_theme_test.dart
 test/memory_lane_response_dto_test.dart
 test/merge_person_dto_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} | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *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* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 *TagApi* | [**createTag**](doc//TagApi.md#createtag) | **POST** /tag | 
@@ -274,6 +275,7 @@ Class | Method | HTTP request | Description
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
  - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
+ - [MapTheme](doc//MapTheme.md)
  - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
  - [MergePersonDto](doc//MergePersonDto.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** |  | 
 **loginPageMessage** | **String** |  | 
-**mapTileUrl** | **String** |  | 
 **oauthButtonText** | **String** |  | 
 **trashDays** | **int** |  | 
 

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

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config | 
 [**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 | 
 [**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)
 
+# **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**
 > SystemConfigTemplateStorageOptionDto getStorageTemplateOptions()
 

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

@@ -8,8 +8,9 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
+**darkStyle** | **String** |  | 
 **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)
 

+ 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/logout_response_dto.dart';
 part 'model/map_marker_response_dto.dart';
+part 'model/map_theme.dart';
 part 'model/memory_lane_response_dto.dart';
 part 'model/merge_person_dto.dart';
 part 'model/model_type.dart';

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

@@ -98,6 +98,55 @@ class SystemConfigApi {
     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].
   Future<Response> getStorageTemplateOptionsWithHttpInfo() async {
     // ignore: prefer_const_declarations

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

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

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

@@ -88,6 +88,9 @@ String parameterToString(dynamic value) {
   if (value is LibraryType) {
     return LibraryTypeTypeTransformer().encode(value).toString();
   }
+  if (value is MapTheme) {
+    return MapThemeTypeTransformer().encode(value).toString();
+  }
   if (value is ModelType) {
     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({
     required this.isInitialized,
     required this.loginPageMessage,
-    required this.mapTileUrl,
     required this.oauthButtonText,
     required this.trashDays,
   });
@@ -24,8 +23,6 @@ class ServerConfigDto {
 
   String loginPageMessage;
 
-  String mapTileUrl;
-
   String oauthButtonText;
 
   int trashDays;
@@ -34,7 +31,6 @@ class ServerConfigDto {
   bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
      other.isInitialized == isInitialized &&
      other.loginPageMessage == loginPageMessage &&
-     other.mapTileUrl == mapTileUrl &&
      other.oauthButtonText == oauthButtonText &&
      other.trashDays == trashDays;
 
@@ -43,18 +39,16 @@ class ServerConfigDto {
     // ignore: unnecessary_parenthesis
     (isInitialized.hashCode) +
     (loginPageMessage.hashCode) +
-    (mapTileUrl.hashCode) +
     (oauthButtonText.hashCode) +
     (trashDays.hashCode);
 
   @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() {
     final json = <String, dynamic>{};
       json[r'isInitialized'] = this.isInitialized;
       json[r'loginPageMessage'] = this.loginPageMessage;
-      json[r'mapTileUrl'] = this.mapTileUrl;
       json[r'oauthButtonText'] = this.oauthButtonText;
       json[r'trashDays'] = this.trashDays;
     return json;
@@ -70,7 +64,6 @@ class ServerConfigDto {
       return ServerConfigDto(
         isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
         loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
-        mapTileUrl: mapValueOfType<String>(json, r'mapTileUrl')!,
         oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
         trashDays: mapValueOfType<int>(json, r'trashDays')!,
       );
@@ -122,7 +115,6 @@ class ServerConfigDto {
   static const requiredKeys = <String>{
     'isInitialized',
     'loginPageMessage',
-    'mapTileUrl',
     'oauthButtonText',
     'trashDays',
   };

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

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

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

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

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

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

+ 41 - 0
mobile/pubspec.lock

@@ -345,6 +345,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     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:
     dependency: transitive
     description:
@@ -1131,6 +1139,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.0"
+  protobuf:
+    dependency: transitive
+    description:
+      name: protobuf
+      sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
   provider:
     dependency: transitive
     description:
@@ -1520,6 +1536,15 @@ packages:
       url: "https://pub.dev"
     source: hosted
     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:
     dependency: transitive
     description:
@@ -1528,6 +1553,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     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:
     dependency: "direct main"
     description:

+ 7 - 0
mobile/pubspec.yaml

@@ -28,6 +28,13 @@ dependencies:
   flutter_map: ^4.0.0
   flutter_map_heatmap: ^0.0.4
   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
   package_info_plus: ^4.1.0
   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/dist ./dist
 COPY --from=prod /usr/src/app/bin ./bin
+COPY ./assets ./assets
 
 COPY LICENSE /licenses/LICENSE.txt
 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": {
       "get": {
         "operationId": "getStorageTemplateOptions",
@@ -7418,6 +7459,13 @@
         ],
         "type": "object"
       },
+      "MapTheme": {
+        "enum": [
+          "light",
+          "dark"
+        ],
+        "type": "string"
+      },
       "MemoryLaneResponseDto": {
         "properties": {
           "assets": {
@@ -7887,9 +7935,6 @@
           "loginPageMessage": {
             "type": "string"
           },
-          "mapTileUrl": {
-            "type": "string"
-          },
           "oauthButtonText": {
             "type": "string"
           },
@@ -7901,7 +7946,6 @@
           "trashDays",
           "oauthButtonText",
           "loginPageMessage",
-          "mapTileUrl",
           "isInitialized"
         ],
         "type": "object"
@@ -8552,16 +8596,20 @@
       },
       "SystemConfigMapDto": {
         "properties": {
+          "darkStyle": {
+            "type": "string"
+          },
           "enabled": {
             "type": "boolean"
           },
-          "tileUrl": {
+          "lightStyle": {
             "type": "string"
           }
         },
         "required": [
           "enabled",
-          "tileUrl"
+          "lightStyle",
+          "darkStyle"
         ],
         "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 interface ISystemConfigRepository {
+  fetchStyle(url: string): Promise<any>;
   load(): Promise<SystemConfigEntity[]>;
-  readFile(filename: string): Promise<Buffer>;
+  readFile(filename: string): Promise<string>;
   saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
   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 {
   oauthButtonText!: string;
   loginPageMessage!: string;
-  mapTileUrl!: string;
   @ApiProperty({ type: 'integer' })
   trashDays!: number;
   isInitialized!: boolean;

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

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

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

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

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

@@ -5,5 +5,8 @@ export class SystemConfigMapDto {
   enabled!: boolean;
 
   @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: {
     enabled: true,
-    tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+    lightStyle: '',
+    darkStyle: '',
   },
   reverseGeocoding: {
     enabled: true,

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

@@ -80,7 +80,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
   },
   map: {
     enabled: true,
-    tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+    lightStyle: '',
+    darkStyle: '',
   },
   reverseGeocoding: {
     enabled: true,
@@ -185,7 +186,7 @@ describe(SystemConfigService.name, () => {
     it('should load the config from a file', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       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);
 
@@ -194,7 +195,7 @@ describe(SystemConfigService.name, () => {
 
     it('should accept an empty configuration file', async () => {
       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);
 
@@ -204,7 +205,7 @@ describe(SystemConfigService.name, () => {
     it('should allow underscores in the machine learning url', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       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();
       expect(config.machineLearning.url).toEqual('immich_machine_learning');
@@ -222,7 +223,7 @@ describe(SystemConfigService.name, () => {
     for (const test of tests) {
       it(`should ${test.should}`, async () => {
         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);
       });
@@ -286,7 +287,7 @@ describe(SystemConfigService.name, () => {
 
     it('should throw an error if a config file is in use', async () => {
       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);
       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 {
   private core: SystemConfigCore;
   constructor(
-    @Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
+    @Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
     @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
   ) {
@@ -76,4 +76,15 @@ export class SystemConfigService {
 
     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 { 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 { Authenticated } from '../app.guard';
 import { UseValidation } from '../app.utils';
@@ -30,4 +31,9 @@ export class SystemConfigController {
   getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
     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',
 
   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_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
@@ -194,7 +195,8 @@ export interface SystemConfig {
   };
   map: {
     enabled: boolean;
-    tileUrl: string;
+    lightStyle: string;
+    darkStyle: string;
   };
   reverseGeocoding: {
     enabled: boolean;

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

@@ -1,5 +1,6 @@
 import { ISystemConfigRepository } from '@app/domain';
 import { InjectRepository } from '@nestjs/typeorm';
+import axios from 'axios';
 import { readFile } from 'fs/promises';
 import { In, Repository } from 'typeorm';
 import { SystemConfigEntity } from '../entities';
@@ -9,12 +10,17 @@ export class SystemConfigRepository implements ISystemConfigRepository {
     @InjectRepository(SystemConfigEntity)
     private repository: Repository<SystemConfigEntity>,
   ) {}
+  async fetchStyle(url: string) {
+    return axios.get(url).then((response) => response.data);
+  }
 
   load(): Promise<SystemConfigEntity[]> {
     return this.repository.find();
   }
 
-  readFile = readFile;
+  readFile(filename: string): Promise<string> {
+    return readFile(filename, { encoding: 'utf-8' });
+  }
 
   saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
     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({
         loginPageMessage: '',
         oauthButtonText: 'Login with OAuth',
-        mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
         trashDays: 30,
         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 {
+    fetchStyle: jest.fn(),
     load: jest.fn().mockResolvedValue([]),
     readFile: jest.fn(),
     saveAll: jest.fn().mockResolvedValue([]),

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 343 - 393
web/package-lock.json


+ 2 - 4
web/package.json

@@ -31,8 +31,6 @@
     "@types/cookie": "^0.5.1",
     "@types/dom-to-image": "^2.6.4",
     "@types/justified-layout": "^4.1.0",
-    "@types/leaflet": "^1.9.1",
-    "@types/leaflet.markercluster": "^1.5.1",
     "@types/lodash-es": "^4.17.6",
     "@types/luxon": "^3.2.0",
     "@typescript-eslint/eslint-plugin": "^5.53.0",
@@ -70,13 +68,13 @@
     "dom-to-image": "^2.6.0",
     "handlebars": "^4.7.7",
     "justified-layout": "^4.1.0",
-    "leaflet": "^1.9.4",
-    "leaflet.markercluster": "^1.5.3",
     "lodash-es": "^4.17.21",
     "luxon": "^3.2.1",
+    "maplibre-gl": "^3.5.2",
     "socket.io-client": "^4.6.1",
     "svelte-loading-spinners": "^0.3.4",
     "svelte-local-storage-store": "^0.5.0",
+    "svelte-maplibre": "^0.6.0",
     "thumbhash": "^0.1.1"
   }
 }

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

@@ -2224,6 +2224,20 @@ export interface MapMarkerResponseDto {
      */
     'lon': number;
 }
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const MapTheme = {
+    Light: 'light',
+    Dark: 'dark'
+} as const;
+
+export type MapTheme = typeof MapTheme[keyof typeof MapTheme];
+
+
 /**
  * 
  * @export
@@ -2822,12 +2836,6 @@ export interface ServerConfigDto {
      * @memberof ServerConfigDto
      */
     'loginPageMessage': string;
-    /**
-     * 
-     * @type {string}
-     * @memberof ServerConfigDto
-     */
-    'mapTileUrl': string;
     /**
      * 
      * @type {string}
@@ -3695,6 +3703,12 @@ export interface SystemConfigMachineLearningDto {
  * @interface SystemConfigMapDto
  */
 export interface SystemConfigMapDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigMapDto
+     */
+    'darkStyle': string;
     /**
      * 
      * @type {boolean}
@@ -3706,7 +3720,7 @@ export interface SystemConfigMapDto {
      * @type {string}
      * @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);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15182,6 +15241,16 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
             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.
@@ -15227,6 +15296,15 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
         getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
             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.
@@ -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.
  * @export
@@ -15288,6 +15380,17 @@ export class SystemConfigApi extends BaseAPI {
         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.

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

@@ -9,9 +9,9 @@
   import { fade } from 'svelte/transition';
   import SettingAccordion from '../setting-accordion.svelte';
   import SettingButtonsRow from '../setting-buttons-row.svelte';
-  import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
   import SettingSwitch from '../setting-switch.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 disabled = false;
@@ -34,7 +34,8 @@
           ...current,
           map: {
             enabled: config.map.enabled,
-            tileUrl: config.map.tileUrl,
+            lightStyle: config.map.lightStyle,
+            darkStyle: config.map.darkStyle,
           },
           reverseGeocoding: {
             enabled: config.reverseGeocoding.enabled,
@@ -95,12 +96,19 @@
 
               <SettingInputField
                 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}
-                isEdited={config.map.tileUrl !== savedConfig.map.tileUrl}
+                isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
               />
             </div></SettingAccordion
           >

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

@@ -1,10 +1,9 @@
 <script lang="ts">
   import { page } from '$app/stores';
   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 { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
-  import type { LatLngTuple } from 'leaflet';
   import { DateTime } from 'luxon';
   import { createEventDispatcher } from 'svelte';
   import { asByteUnitString } from '../../utils/byte-units';
@@ -12,6 +11,7 @@
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import { mdiCalendar, mdiCameraIris, mdiClose, mdiImageOutline, mdiMapMarkerOutline } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
+  import Map from '../shared-components/map/map.svelte';
 
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
@@ -36,20 +36,10 @@
     const lng = asset.exifInfo?.longitude;
 
     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 || [];
 
   const dispatch = createEventDispatcher();
@@ -297,24 +287,21 @@
 
 {#if latlng && $featureFlags.loaded && $featureFlags.map}
   <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
           </a>
-        </Marker>
-      </Map>
-    {/await}
+        </div>
+      </svelte:fragment>
+    </Map>
   </div>
 {/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>({
   loaded: false,
   oauthButtonText: '',
-  mapTileUrl: '',
   loginPageMessage: '',
   trashDays: 30,
   isInitialized: false,

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

@@ -7,29 +7,26 @@
   import { AppRoute } from '$lib/constants';
   import { assetViewingStore } from '$lib/stores/asset-viewing.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 { isEqual, omit } from 'lodash-es';
   import { DateTime, Duration } from 'luxon';
   import { onDestroy, onMount } from 'svelte';
-  import Icon from '$lib/components/elements/icon.svelte';
   import type { PageData } from './$types';
-  import { mdiCog } from '@mdi/js';
+  import Map from '$lib/components/shared-components/map/map.svelte';
 
   export let data: PageData;
 
   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
 
-  let leaflet: typeof import('$lib/components/shared-components/leaflet');
-  let mapMarkers: MapMarkerResponseDto[] = [];
   let abortController: AbortController;
+  let mapMarkers: MapMarkerResponseDto[] = [];
   let viewingAssets: string[] = [];
   let viewingAssetCursor = 0;
   let showSettingsModal = false;
 
   onMount(() => {
     loadMapMarkers().then((data) => (mapMarkers = data));
-    import('$lib/components/shared-components/leaflet').then((data) => (leaflet = data));
   });
 
   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;
-    viewingAssetCursor = activeAssetIndex;
+    viewingAssetCursor = 0;
   }
 
   function navigateNext() {
@@ -106,44 +103,9 @@
 {#if $featureFlags.loaded && $featureFlags.map}
   <UserPageLayout user={data.user} title={data.meta.title}>
     <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">
     {#if $showAssetViewer}
       <AssetViewer
@@ -152,6 +114,7 @@
         on:next={navigateNext}
         on:previous={navigatePrevious}
         on:close={() => assetViewingStore.showAssetViewer(false)}
+        isShared={false}
       />
     {/if}
   </Portal>

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels