From 8934c29fceb6f81ecae692ad8f682349566dd61c Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Sat, 6 Dec 2025 20:54:49 +0100 Subject: [PATCH] 0.36.2 (#2007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements --------- Co-authored-by: Robin Tuszik --- .app_version | 2 +- CHANGELOG.md | 21 +- README.md | 2 +- app/assets/builds/tailwind.css | 6 +- app/assets/stylesheets/maplibre-gl.css | 1 + app/assets/stylesheets/maps_maplibre.css | 187 + .../stylesheets/maps_maplibre_panel.css | 286 + .../svg/icons/lucide/outline/circle-plus.svg | 1 + .../svg/icons/lucide/outline/grid2x2.svg | 1 + app/assets/svg/icons/lucide/outline/layer.svg | 1 + .../icons/lucide/outline/map-pin-check.svg | 1 + .../svg/icons/lucide/outline/pocket-knife.svg | 1 + .../svg/icons/lucide/outline/rotate-ccw.svg | 1 + app/assets/svg/icons/lucide/outline/route.svg | 1 + app/assets/svg/icons/lucide/outline/save.svg | 1 + .../svg/icons/lucide/outline/settings.svg | 1 + app/assets/svg/icons/lucide/outline/x.svg | 1 + app/controllers/api/v1/areas_controller.rb | 6 +- app/controllers/api/v1/places_controller.rb | 24 +- app/controllers/api/v1/points_controller.rb | 17 + app/controllers/api/v1/settings_controller.rb | 5 +- app/controllers/api/v1/visits_controller.rb | 5 + app/controllers/home_controller.rb | 4 +- .../leaflet_controller.rb} | 2 +- app/controllers/map/maplibre_controller.rb | 33 + app/controllers/settings/maps_controller.rb | 2 +- app/helpers/application_helper.rb | 7 + app/javascript/README.md | 724 + .../area_creation_v2_controller.js | 161 + .../controllers/area_drawer_controller.js | 146 + .../controllers/area_selector_controller.js | 161 + .../controllers/map_panel_controller.js | 68 + .../maps/maplibre/area_selection_manager.js | 540 + .../controllers/maps/maplibre/data_loader.js | 225 + .../controllers/maps/maplibre/date_manager.js | 35 + .../maps/maplibre/event_handlers.js | 129 + .../maps/maplibre/filter_manager.js | 53 + .../maps/maplibre/layer_manager.js | 279 + .../maps/maplibre/map_data_manager.js | 131 + .../maps/maplibre/map_initializer.js | 66 + .../maps/maplibre/places_manager.js | 281 + .../maps/maplibre/routes_manager.js | 360 + .../maps/maplibre/settings_manager.js | 271 + .../maps/maplibre/visits_manager.js | 153 + .../controllers/maps/maplibre_controller.js | 543 + .../maps/maplibre_realtime_controller.js | 323 + .../controllers/public_stat_map_controller.js | 8 - .../speed_color_editor_controller.js | 184 + .../visit_creation_v2_controller.js | 255 + app/javascript/maps/vector_maps_config.js | 14 +- .../maps_maplibre/channels/map_channel.js | 118 + .../maps_maplibre/components/photo_popup.js | 100 + .../maps_maplibre/components/popup_factory.js | 114 + .../maps_maplibre/components/toast.js | 183 + .../maps_maplibre/components/visit_card.js | 156 + .../maps_maplibre/components/visit_popup.js | 138 + .../maps_maplibre/layers/areas_layer.js | 67 + .../maps_maplibre/layers/base_layer.js | 136 + .../maps_maplibre/layers/family_layer.js | 151 + .../maps_maplibre/layers/fog_layer.js | 140 + .../maps_maplibre/layers/heatmap_layer.js | 86 + .../maps_maplibre/layers/photos_layer.js | 220 + .../maps_maplibre/layers/places_layer.js | 66 + .../maps_maplibre/layers/points_layer.js | 37 + .../layers/recent_point_layer.js | 94 + .../maps_maplibre/layers/routes_layer.js | 145 + .../maps_maplibre/layers/scratch_layer.js | 178 + .../layers/selected_points_layer.js | 96 + .../maps_maplibre/layers/selection_layer.js | 200 + .../maps_maplibre/layers/tracks_layer.js | 39 + .../maps_maplibre/layers/visits_layer.js | 66 + .../maps_maplibre/services/api_client.js | 357 + .../services/location_search_service.js | 117 + .../maps_maplibre/utils/cleanup_helper.js | 49 + .../maps_maplibre/utils/fps_monitor.js | 49 + .../utils/geojson_transformers.js | 54 + .../maps_maplibre/utils/geometry.js | 69 + .../maps_maplibre/utils/lazy_loader.js | 76 + .../utils/performance_monitor.js | 108 + .../maps_maplibre/utils/popup_theme.js | 120 + .../maps_maplibre/utils/progressive_loader.js | 101 + .../maps_maplibre/utils/search_manager.js | 729 + .../maps_maplibre/utils/settings_manager.js | 296 + .../maps_maplibre/utils/speed_colors.js | 140 + .../maps_maplibre/utils/style_manager.js | 113 + .../maps_maplibre/utils/websocket_manager.js | 82 + app/models/concerns/taggable.rb | 10 + app/serializers/api/point_serializer.rb | 1 + app/services/users/safe_settings.rb | 12 +- .../{ => leaflet}/_settings_modals.html.erb | 0 app/views/map/leaflet/index.html.erb | 35 + .../maplibre/_area_creation_modal.html.erb | 67 + .../map/maplibre/_settings_panel.html.erb | 655 + .../maplibre/_visit_creation_modal.html.erb | 60 + app/views/map/maplibre/index.html.erb | 49 + app/views/settings/maps/index.html.erb | 17 + app/views/shared/_navbar.html.erb | 6 +- .../shared/_place_creation_modal.html.erb | 2 +- .../map/_date_navigation.html.erb} | 48 +- .../shared/map/_date_navigation_v2.html.erb | 71 + config/importmap.rb | 8 +- config/routes.rb | 16 +- ..._id_reverse_geocoded_at_index_to_points.rb | 12 + db/schema.rb | 3 +- docker/Dockerfile | 4 + docker/web-entrypoint.sh | 8 + e2e/README.md | 210 +- e2e/helpers/map.js | 13 + e2e/map/map-bulk-delete.spec.js | 4 +- e2e/map/map-controls.spec.js | 4 +- e2e/map/map-layers.spec.js | 5 +- e2e/map/map-selection-tool.spec.js | 4 +- e2e/setup/auth.setup.js | 4 +- e2e/v2/helpers/setup.js | 250 + e2e/v2/map/area-selection.spec.js | 326 + e2e/v2/map/core.spec.js | 137 + e2e/v2/map/interactions.spec.js | 64 + e2e/v2/map/layers/advanced.spec.js | 55 + e2e/v2/map/layers/areas.spec.js | 436 + e2e/v2/map/layers/heatmap.spec.js | 86 + e2e/v2/map/layers/photos.spec.js | 39 + e2e/v2/map/layers/places.spec.js | 334 + e2e/v2/map/layers/points.spec.js | 71 + e2e/v2/map/layers/routes.spec.js | 194 + e2e/v2/map/layers/visits.spec.js | 536 + e2e/v2/map/navigation.spec.js | 66 + e2e/v2/map/performance.spec.js | 34 + e2e/v2/map/search.spec.js | 333 + e2e/v2/map/settings.spec.js | 288 + e2e/v2/realtime/family.spec.js | 34 + e2e/v2/realtime/live-mode.spec.js | 461 + lib/tasks/demo.rake | 213 + package-lock.json | 503 + package.json | 10 +- playwright.config.js | 2 +- public/maps_maplibre/styles/black.json | 10940 ++++++++++++++ public/maps_maplibre/styles/dark.json | 12085 +++++++++++++++ public/maps_maplibre/styles/grayscale.json | 10940 ++++++++++++++ public/maps_maplibre/styles/light.json | 12088 ++++++++++++++++ public/maps_maplibre/styles/white.json | 10940 ++++++++++++++ spec/requests/authentication_spec.rb | 2 +- spec/serializers/api/point_serializer_spec.rb | 1 + spec/services/users/export_data_spec.rb | 2 +- spec/services/users/safe_settings_spec.rb | 17 +- .../api/v1/settings_controller_spec.rb | 56 +- swagger/v1/swagger.yaml | 45 +- vendor/javascript/maplibre-gl.js | 8 + 147 files changed, 73480 insertions(+), 164 deletions(-) create mode 100644 app/assets/stylesheets/maplibre-gl.css create mode 100644 app/assets/stylesheets/maps_maplibre.css create mode 100644 app/assets/stylesheets/maps_maplibre_panel.css create mode 100644 app/assets/svg/icons/lucide/outline/circle-plus.svg create mode 100644 app/assets/svg/icons/lucide/outline/grid2x2.svg create mode 100644 app/assets/svg/icons/lucide/outline/layer.svg create mode 100644 app/assets/svg/icons/lucide/outline/map-pin-check.svg create mode 100644 app/assets/svg/icons/lucide/outline/pocket-knife.svg create mode 100644 app/assets/svg/icons/lucide/outline/rotate-ccw.svg create mode 100644 app/assets/svg/icons/lucide/outline/route.svg create mode 100644 app/assets/svg/icons/lucide/outline/save.svg create mode 100644 app/assets/svg/icons/lucide/outline/settings.svg create mode 100644 app/assets/svg/icons/lucide/outline/x.svg rename app/controllers/{map_controller.rb => map/leaflet_controller.rb} (97%) create mode 100644 app/controllers/map/maplibre_controller.rb create mode 100644 app/javascript/README.md create mode 100644 app/javascript/controllers/area_creation_v2_controller.js create mode 100644 app/javascript/controllers/area_drawer_controller.js create mode 100644 app/javascript/controllers/area_selector_controller.js create mode 100644 app/javascript/controllers/map_panel_controller.js create mode 100644 app/javascript/controllers/maps/maplibre/area_selection_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/data_loader.js create mode 100644 app/javascript/controllers/maps/maplibre/date_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/event_handlers.js create mode 100644 app/javascript/controllers/maps/maplibre/filter_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/layer_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/map_data_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/map_initializer.js create mode 100644 app/javascript/controllers/maps/maplibre/places_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/routes_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/settings_manager.js create mode 100644 app/javascript/controllers/maps/maplibre/visits_manager.js create mode 100644 app/javascript/controllers/maps/maplibre_controller.js create mode 100644 app/javascript/controllers/maps/maplibre_realtime_controller.js create mode 100644 app/javascript/controllers/speed_color_editor_controller.js create mode 100644 app/javascript/controllers/visit_creation_v2_controller.js create mode 100644 app/javascript/maps_maplibre/channels/map_channel.js create mode 100644 app/javascript/maps_maplibre/components/photo_popup.js create mode 100644 app/javascript/maps_maplibre/components/popup_factory.js create mode 100644 app/javascript/maps_maplibre/components/toast.js create mode 100644 app/javascript/maps_maplibre/components/visit_card.js create mode 100644 app/javascript/maps_maplibre/components/visit_popup.js create mode 100644 app/javascript/maps_maplibre/layers/areas_layer.js create mode 100644 app/javascript/maps_maplibre/layers/base_layer.js create mode 100644 app/javascript/maps_maplibre/layers/family_layer.js create mode 100644 app/javascript/maps_maplibre/layers/fog_layer.js create mode 100644 app/javascript/maps_maplibre/layers/heatmap_layer.js create mode 100644 app/javascript/maps_maplibre/layers/photos_layer.js create mode 100644 app/javascript/maps_maplibre/layers/places_layer.js create mode 100644 app/javascript/maps_maplibre/layers/points_layer.js create mode 100644 app/javascript/maps_maplibre/layers/recent_point_layer.js create mode 100644 app/javascript/maps_maplibre/layers/routes_layer.js create mode 100644 app/javascript/maps_maplibre/layers/scratch_layer.js create mode 100644 app/javascript/maps_maplibre/layers/selected_points_layer.js create mode 100644 app/javascript/maps_maplibre/layers/selection_layer.js create mode 100644 app/javascript/maps_maplibre/layers/tracks_layer.js create mode 100644 app/javascript/maps_maplibre/layers/visits_layer.js create mode 100644 app/javascript/maps_maplibre/services/api_client.js create mode 100644 app/javascript/maps_maplibre/services/location_search_service.js create mode 100644 app/javascript/maps_maplibre/utils/cleanup_helper.js create mode 100644 app/javascript/maps_maplibre/utils/fps_monitor.js create mode 100644 app/javascript/maps_maplibre/utils/geojson_transformers.js create mode 100644 app/javascript/maps_maplibre/utils/geometry.js create mode 100644 app/javascript/maps_maplibre/utils/lazy_loader.js create mode 100644 app/javascript/maps_maplibre/utils/performance_monitor.js create mode 100644 app/javascript/maps_maplibre/utils/popup_theme.js create mode 100644 app/javascript/maps_maplibre/utils/progressive_loader.js create mode 100644 app/javascript/maps_maplibre/utils/search_manager.js create mode 100644 app/javascript/maps_maplibre/utils/settings_manager.js create mode 100644 app/javascript/maps_maplibre/utils/speed_colors.js create mode 100644 app/javascript/maps_maplibre/utils/style_manager.js create mode 100644 app/javascript/maps_maplibre/utils/websocket_manager.js rename app/views/map/{ => leaflet}/_settings_modals.html.erb (100%) create mode 100644 app/views/map/leaflet/index.html.erb create mode 100644 app/views/map/maplibre/_area_creation_modal.html.erb create mode 100644 app/views/map/maplibre/_settings_panel.html.erb create mode 100644 app/views/map/maplibre/_visit_creation_modal.html.erb create mode 100644 app/views/map/maplibre/index.html.erb rename app/views/{map/index.html.erb => shared/map/_date_navigation.html.erb} (59%) create mode 100644 app/views/shared/map/_date_navigation_v2.html.erb create mode 100644 db/migrate/20251201192510_add_user_id_reverse_geocoded_at_index_to_points.rb create mode 100644 e2e/v2/helpers/setup.js create mode 100644 e2e/v2/map/area-selection.spec.js create mode 100644 e2e/v2/map/core.spec.js create mode 100644 e2e/v2/map/interactions.spec.js create mode 100644 e2e/v2/map/layers/advanced.spec.js create mode 100644 e2e/v2/map/layers/areas.spec.js create mode 100644 e2e/v2/map/layers/heatmap.spec.js create mode 100644 e2e/v2/map/layers/photos.spec.js create mode 100644 e2e/v2/map/layers/places.spec.js create mode 100644 e2e/v2/map/layers/points.spec.js create mode 100644 e2e/v2/map/layers/routes.spec.js create mode 100644 e2e/v2/map/layers/visits.spec.js create mode 100644 e2e/v2/map/navigation.spec.js create mode 100644 e2e/v2/map/performance.spec.js create mode 100644 e2e/v2/map/search.spec.js create mode 100644 e2e/v2/map/settings.spec.js create mode 100644 e2e/v2/realtime/family.spec.js create mode 100644 e2e/v2/realtime/live-mode.spec.js create mode 100644 lib/tasks/demo.rake create mode 100644 public/maps_maplibre/styles/black.json create mode 100644 public/maps_maplibre/styles/dark.json create mode 100644 public/maps_maplibre/styles/grayscale.json create mode 100644 public/maps_maplibre/styles/light.json create mode 100644 public/maps_maplibre/styles/white.json create mode 100644 vendor/javascript/maplibre-gl.js diff --git a/.app_version b/.app_version index 19199bcc..6d59e656 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.36.1 +0.36.2 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e01da2..932373c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -[0.36.1] - 2025-11-29 + +# [0.36.2] - 2025-12-06 + +## The Map v2 release + +In this release we're introducing Map v2 based on MapLibre GL JS. It brings better performance, smoother interactions and more features in the future. User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the Settings -> Map Settings. New map features will be added to Map v2 only. + +## Added + +- User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the User Settings. + +## Fixed + +- Heatmap and Fog of War now are moving correctly during map interactions on v2 map. #1798 +- Polyline crossing international date line now are rendered correctly on v2 map. #1162 +- Place popup tags parsing (MapLibre GL JS compatibility) +- Stats calculation should be faster now. + + +# [0.36.1] - 2025-11-29 ## Fixed diff --git a/README.md b/README.md index 1f1af5ec..e61d84ae 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send 1. Clone the repository. 2. Run the following command to start the app: ```bash - docker-compose -f docker/docker-compose.yml up + docker compose -f docker/docker-compose.yml up ``` 3. Access the app at `http://localhost:3000`. diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 37161101..806b34f7 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1,6 +1,6 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar{display:inline-flex;position:relative}.avatar>div{aspect-ratio:1/1;display:block;overflow:hidden}.avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.collapse:not(td):not(tr):not(colgroup){visibility:visible}.collapse{border-radius:var(--rounded-box,1rem);display:grid;grid-template-rows:auto 0fr;overflow:hidden;position:relative;transition:grid-template-rows .2s;width:100%}.collapse-content,.collapse-title,.collapse>input[type=checkbox],.collapse>input[type=radio]{grid-column-start:1;grid-row-start:1}.collapse>input[type=checkbox],.collapse>input[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;opacity:0}.collapse-content{cursor:unset;grid-column-start:1;grid-row-start:2;min-height:0;padding-left:1rem;padding-right:1rem;transition:visibility .2s;transition:padding .2s ease-out,background-color .2s ease-out;visibility:hidden}.collapse-open,.collapse:focus:not(.collapse-close),.collapse[open]{grid-template-rows:auto 1fr}.collapse:not(.collapse-close):has(>input[type=checkbox]:checked),.collapse:not(.collapse-close):has(>input[type=radio]:checked){grid-template-rows:auto 1fr}.collapse-open>.collapse-content,.collapse:focus:not(.collapse-close)>.collapse-content,.collapse:not(.collapse-close)>input[type=checkbox]:checked~.collapse-content,.collapse:not(.collapse-close)>input[type=radio]:checked~.collapse-content,.collapse[open]>.collapse-content{min-height:-moz-fit-content;min-height:fit-content;visibility:visible}:root .countdown{line-height:1em}.countdown{display:inline-flex}.countdown>*{display:inline-block;height:1em;overflow-y:hidden}.countdown>:before{content:"00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";position:relative;text-align:center;top:calc(var(--value)*-1em);transition:all 1s cubic-bezier(1,0,0,1);white-space:pre}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.\!footer{-moz-column-gap:1rem!important;column-gap:1rem!important;display:grid!important;font-size:.875rem!important;grid-auto-flow:row!important;line-height:1.25rem!important;place-items:start!important;row-gap:2.5rem!important;width:100%!important}.footer{-moz-column-gap:1rem;column-gap:1rem;display:grid;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;place-items:start;row-gap:2.5rem;width:100%}.\!footer>*{display:grid!important;gap:.5rem!important;place-items:start!important}.footer>*{display:grid;gap:.5rem;place-items:start}@media (min-width:48rem){.\!footer{grid-auto-flow:column!important}.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.\!input{-webkit-appearance:none!important;-moz-appearance:none!important;appearance:none!important;border-color:transparent!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;flex-shrink:1!important;font-size:1rem!important;height:3rem!important;line-height:2!important;line-height:1.5rem!important;padding-left:1rem!important;padding-right:1rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.\!input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem!important;margin-top:-1rem!important;margin-inline-end:-1rem!important}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps{counter-reset:step;display:inline-grid;grid-auto-columns:1fr;grid-auto-flow:column;overflow:hidden;overflow-x:auto}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.textarea{border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:.875rem;line-height:1.25rem;line-height:2;min-height:3rem;padding:.5rem 1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar{display:inline-flex;position:relative}.avatar>div{aspect-ratio:1/1;display:block;overflow:hidden}.avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.link-hover:hover{text-decoration-line:underline}.checkbox-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.collapse:not(td):not(tr):not(colgroup){visibility:visible}.collapse{border-radius:var(--rounded-box,1rem);display:grid;grid-template-rows:auto 0fr;overflow:hidden;position:relative;transition:grid-template-rows .2s;width:100%}.collapse-content,.collapse-title,.collapse>input[type=checkbox],.collapse>input[type=radio]{grid-column-start:1;grid-row-start:1}.collapse>input[type=checkbox],.collapse>input[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;opacity:0}.collapse-content{cursor:unset;grid-column-start:1;grid-row-start:2;min-height:0;padding-left:1rem;padding-right:1rem;transition:visibility .2s;transition:padding .2s ease-out,background-color .2s ease-out;visibility:hidden}.collapse-open,.collapse:focus:not(.collapse-close),.collapse[open]{grid-template-rows:auto 1fr}.collapse:not(.collapse-close):has(>input[type=checkbox]:checked),.collapse:not(.collapse-close):has(>input[type=radio]:checked){grid-template-rows:auto 1fr}.collapse-open>.collapse-content,.collapse:focus:not(.collapse-close)>.collapse-content,.collapse:not(.collapse-close)>input[type=checkbox]:checked~.collapse-content,.collapse:not(.collapse-close)>input[type=radio]:checked~.collapse-content,.collapse[open]>.collapse-content{min-height:-moz-fit-content;min-height:fit-content;visibility:visible}:root .countdown{line-height:1em}.countdown{display:inline-flex}.countdown>*{display:inline-block;height:1em;overflow-y:hidden}.countdown>:before{content:"00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";position:relative;text-align:center;top:calc(var(--value)*-1em);transition:all 1s cubic-bezier(1,0,0,1);white-space:pre}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.\!footer{-moz-column-gap:1rem!important;column-gap:1rem!important;display:grid!important;font-size:.875rem!important;grid-auto-flow:row!important;line-height:1.25rem!important;place-items:start!important;row-gap:2.5rem!important;width:100%!important}.footer{-moz-column-gap:1rem;column-gap:1rem;display:grid;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;place-items:start;row-gap:2.5rem;width:100%}.\!footer>*{display:grid!important;gap:.5rem!important;place-items:start!important}.footer>*{display:grid;gap:.5rem;place-items:start}@media (min-width:48rem){.\!footer{grid-auto-flow:column!important}.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.\!input{-webkit-appearance:none!important;-moz-appearance:none!important;appearance:none!important;border-color:transparent!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;flex-shrink:1!important;font-size:1rem!important;height:3rem!important;line-height:2!important;line-height:1.5rem!important;padding-left:1rem!important;padding-right:1rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.\!input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem!important;margin-top:-1rem!important;margin-inline-end:-1rem!important}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.link-hover{text-decoration-line:none}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.\!modal{background-color:transparent!important;color:inherit!important;display:grid!important;height:100%!important;inset:0!important;justify-items:center!important;margin:0!important;max-height:none!important;max-width:none!important;opacity:0!important;overflow-y:hidden!important;overscroll-behavior:contain!important;padding:0!important;pointer-events:none!important;position:fixed!important;transition-duration:.2s!important;transition-property:transform,opacity,visibility!important;transition-timing-function:cubic-bezier(0,0,.2,1)!important;width:100%!important;z-index:999!important}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.\!modal){align-items:center!important}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.\!modal:target,.\!modal[open],.modal-toggle:checked+.\!modal{opacity:1!important;pointer-events:auto!important;visibility:visible!important}.modal-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}:root:has(:is(.modal-open,.\!modal:target,.modal-toggle:checked+.\!modal,.\!modal[open])){overflow:hidden!important}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps{counter-reset:step;display:inline-grid;grid-auto-columns:1fr;grid-auto-flow:column;overflow:hidden;overflow-x:auto}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.textarea{border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:.875rem;line-height:1.25rem;line-height:2;min-height:3rem;padding:.5rem 1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error{border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.\!input:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .\!input:before{aspect-ratio:1/1!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range-error{--range-shdw:var(--fallback-er,oklch(var(--er)/1))}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.toggle-error:focus-visible{outline-color:var(--fallback-er,oklch(var(--er)/1))}.toggle-error:checked,.toggle-error[aria-checked=true],.toggle-error[checked=true]{border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-\[4rem\]{min-height:4rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-primary{--tw-ring-opacity:1;--tw-ring-color:var(--fallback-p,oklch(var(--p)/var(--tw-ring-opacity,1)))}.ring-offset-2{--tw-ring-offset-width:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}em-emoji-picker{--color-border-over:rgba(0,0,0,.1);--color-border:rgba(0,0,0,.05);--font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--rgb-accent:96,165,250;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);max-width:400px;min-width:318px;overflow:auto;position:absolute;resize:horizontal;z-index:1000}[data-theme=dark] em-emoji-picker,html.dark em-emoji-picker{--color-border-over:hsla(0,0%,100%,.1);--color-border:hsla(0,0%,100%,.05);--rgb-accent:96,165,250}@media (max-width:768px){em-emoji-picker{max-width:90vw;min-width:280px}}.color-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:none;border-radius:.5rem}.color-input::-moz-color-swatch{border:none;border-radius:.5rem}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact -.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-110:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:\!block{display:block!important}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:items-end{align-items:flex-end}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}.lg\:text-left{text-align:left}} \ No newline at end of file + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error{border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox-primary{--chkbg:var(--fallback-p,oklch(var(--p)/1));--chkfg:var(--fallback-pc,oklch(var(--pc)/1));--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.checkbox-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.checkbox-primary:checked,.checkbox-primary[aria-checked=true],.checkbox-primary[checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.\!input:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .\!input:before{aspect-ratio:1/1!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.\!modal::backdrop,.\!modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out!important;background-color:#0006!important}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\!modal:target .modal-box,.\!modal[open] .modal-box,.modal-toggle:checked+.\!modal .modal-box{--tw-translate-y:0px!important;--tw-scale-x:1!important;--tw-scale-y:1!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range-error{--range-shdw:var(--fallback-er,oklch(var(--er)/1))}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.toggle-error:focus-visible{outline-color:var(--fallback-er,oklch(var(--er)/1))}.toggle-error:checked,.toggle-error[aria-checked=true],.toggle-error[checked=true]{border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-block{width:100%}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-xs{height:1rem;width:1rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.range-sm{height:1.25rem}.range-sm::-webkit-slider-runnable-track{height:.25rem}.range-sm::-moz-range-track{height:.25rem}.range-sm::-webkit-slider-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.range-sm::-moz-range-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[10000\]{z-index:10000}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.z-\[9999\]{z-index:9999}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-14{margin-left:3.5rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.min-h-80{min-height:20rem}.min-h-\[4rem\]{min-height:4rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-base-content\/20{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/40{color:var(--fallback-bc,oklch(var(--bc)/.4))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-primary{--tw-ring-opacity:1;--tw-ring-color:var(--fallback-p,oklch(var(--p)/var(--tw-ring-opacity,1)))}.ring-offset-2{--tw-ring-offset-width:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}em-emoji-picker{--color-border-over:rgba(0,0,0,.1);--color-border:rgba(0,0,0,.05);--font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--rgb-accent:96,165,250;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);max-width:400px;min-width:318px;overflow:auto;position:absolute;resize:horizontal;z-index:1000}[data-theme=dark] em-emoji-picker,html.dark em-emoji-picker{--color-border-over:hsla(0,0%,100%,.1);--color-border:hsla(0,0%,100%,.05);--rgb-accent:96,165,250}@media (max-width:768px){em-emoji-picker{max-width:90vw;min-width:280px}}.color-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:none;border-radius:.5rem}.color-input::-moz-color-swatch{border:none;border-radius:.5rem}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact +.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-110:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:\!block{display:block!important}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:items-end{align-items:flex-end}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/assets/stylesheets/maplibre-gl.css b/app/assets/stylesheets/maplibre-gl.css new file mode 100644 index 00000000..172ddf49 --- /dev/null +++ b/app/assets/stylesheets/maplibre-gl.css @@ -0,0 +1 @@ +.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0,0,0,0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}@media (hover:hover){.maplibregl-ctrl button:not(:disabled):hover{background-color:rgba(0,0,0,.05)}}.maplibregl-ctrl button:not(:disabled):active{background-color:rgba(0,0,0,.05)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-globe .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='none' stroke='%23333' viewBox='0 0 22 22'%3E%3Ccircle cx='11' cy='11' r='8.5'/%3E%3Cpath d='M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z'/%3E%3Cpath d='M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517s-.461-.118-.775-.517c-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11s.331-4.64.853-6.206c.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517s.461.118.775.517c.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z'/%3E%3Cpath d='M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138q.07-.058.224-.138c.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138q-.07.058-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138 1.3 1.3 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.3 1.3 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.5 1.5 0 0 1-.39-.272.3.3 0 0 1-.047-.064.3.3 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.3.3 0 0 1 .047.064.3.3 0 0 1-.048.064 1.4 1.4 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002zm0 .018v.002zm17.002.002v-.002zm0-.018v-.002z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-globe-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='none' stroke='%2333b5e5' viewBox='0 0 22 22'%3E%3Ccircle cx='11' cy='11' r='8.5'/%3E%3Cpath d='M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z'/%3E%3Cpath d='M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517s-.461-.118-.775-.517c-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11s.331-4.64.853-6.206c.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517s.461.118.775.517c.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z'/%3E%3Cpath d='M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138q.07-.058.224-.138c.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138q-.07.058-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.3 1.3 0 0 1-.224-.138 1.3 1.3 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5s3.622.166 4.845.428c.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.3 1.3 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.5 1.5 0 0 1-.39-.272.3.3 0 0 1-.047-.064.3.3 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.3.3 0 0 1 .047.064.3.3 0 0 1-.048.064 1.4 1.4 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002zm0 .018v.002zm17.002.002v-.002zm0-.018v-.002z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgba(0,0,0,.05)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgba(0,0,0,.05)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(pointer:coarse){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999} \ No newline at end of file diff --git a/app/assets/stylesheets/maps_maplibre.css b/app/assets/stylesheets/maps_maplibre.css new file mode 100644 index 00000000..5e6ef007 --- /dev/null +++ b/app/assets/stylesheets/maps_maplibre.css @@ -0,0 +1,187 @@ +/* Maps V2 Styles */ + +/* Loading Overlay */ +.loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.loading-overlay.hidden { + display: none; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-text { + margin-top: 16px; + font-size: 14px; + color: #6b7280; +} + +/* Popup Styles */ +.point-popup { + font-family: system-ui, -apple-system, sans-serif; +} + +.popup-header { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid #e5e7eb; +} + +.popup-body { + font-size: 13px; +} + +.popup-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 4px 0; +} + +.popup-row .label { + color: #6b7280; +} + +.popup-row .value { + font-weight: 500; + color: #111827; +} + +/* MapLibre Popup Theme Support */ +.maplibregl-popup-content { + padding: 16px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* Larger close button */ +.maplibregl-popup-close-button { + width: 32px; + height: 32px; + font-size: 24px; + line-height: 32px; + right: 4px; + top: 4px; + padding: 0; + border-radius: 4px; + transition: background-color 0.2s; +} + +.maplibregl-popup-close-button:hover { + background-color: rgba(0, 0, 0, 0.08); +} + +/* Light theme (default) */ +.maplibregl-popup-content { + background-color: #ffffff; + color: #111827; +} + +.maplibregl-popup-close-button { + color: #6b7280; +} + +.maplibregl-popup-close-button:hover { + background-color: #f3f4f6; + color: #111827; +} + +.maplibregl-popup-tip { + border-top-color: #ffffff; +} + +/* Dark theme */ +html[data-theme="dark"] .maplibregl-popup-content, +html.dark .maplibregl-popup-content { + background-color: #1f2937; + color: #f9fafb; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +html[data-theme="dark"] .maplibregl-popup-close-button, +html.dark .maplibregl-popup-close-button { + color: #d1d5db; +} + +html[data-theme="dark"] .maplibregl-popup-close-button:hover, +html.dark .maplibregl-popup-close-button:hover { + background-color: #374151; + color: #f9fafb; +} + +html[data-theme="dark"] .maplibregl-popup-tip, +html.dark .maplibregl-popup-tip { + border-top-color: #1f2937; +} + +/* Connection Indicator */ +.connection-indicator { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + padding: 8px 16px; + background: white; + border-radius: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: none; /* Hidden by default, shown when family sharing is active */ + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + z-index: 20; + transition: all 0.3s; +} + +/* Show connection indicator when family sharing is active */ +.connection-indicator.active { + display: flex; +} + +.indicator-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ef4444; + animation: pulse 2s ease-in-out infinite; +} + +.connection-indicator.connected .indicator-dot { + background: #22c55e; +} + +.connection-indicator.connected .indicator-text::before { + content: 'Connected'; +} + +.connection-indicator.disconnected .indicator-text::before { + content: 'Connecting...'; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/app/assets/stylesheets/maps_maplibre_panel.css b/app/assets/stylesheets/maps_maplibre_panel.css new file mode 100644 index 00000000..3a346578 --- /dev/null +++ b/app/assets/stylesheets/maps_maplibre_panel.css @@ -0,0 +1,286 @@ +/* Maps V2 Control Panel Styles */ + +.map-control-panel { + position: absolute; + top: 0; + right: -480px; /* Hidden by default */ + width: 480px; + height: 100%; + background: oklch(var(--b1)); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); + z-index: 9999; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + overflow: hidden; +} + +.map-control-panel.open { + right: 0; +} + +/* Vertical Tab Bar */ +.panel-tabs { + width: 64px; + background: oklch(var(--b2)); + border-right: 1px solid oklch(var(--bc) / 0.1); + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0; + gap: 8px; + flex-shrink: 0; +} + +.tab-btn { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: none; + background: transparent; + cursor: pointer; + transition: all 0.2s; + position: relative; + color: oklch(var(--bc) / 0.6); +} + +.tab-btn:hover { + background: oklch(var(--b3)); + color: oklch(var(--bc)); +} + +.tab-btn.active { + background: oklch(var(--p)); + color: oklch(var(--pc)); +} + +.tab-btn.active::after { + content: ''; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 24px; + background: oklch(var(--p)); + border-radius: 2px 0 0 2px; +} + +.tab-icon { + width: 24px; + height: 24px; +} + +/* Panel Content */ +.panel-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid oklch(var(--bc) / 0.1); + background: oklch(var(--b1)); + flex-shrink: 0; +} + +.panel-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: oklch(var(--bc)); +} + +.panel-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +/* Tab Content */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Custom Scrollbar */ +.panel-body::-webkit-scrollbar { + width: 8px; +} + +.panel-body::-webkit-scrollbar-track { + background: transparent; +} + +.panel-body::-webkit-scrollbar-thumb { + background: oklch(var(--bc) / 0.2); + border-radius: 4px; +} + +.panel-body::-webkit-scrollbar-thumb:hover { + background: oklch(var(--bc) / 0.3); +} + +/* Toggle Focus State - Remove all focus indicators */ +.toggle:focus, +.toggle:focus-visible, +.toggle:focus-within { + outline: none !important; + box-shadow: none !important; + border-color: inherit !important; +} + +/* Override DaisyUI toggle focus styles */ +.toggle:focus-visible:checked, +.toggle:checked:focus, +.toggle:checked:focus-visible { + outline: none !important; + box-shadow: none !important; +} + +/* Ensure no outline on the toggle container */ +.form-control .toggle:focus { + outline: none !important; +} + +/* Prevent indeterminate visual state on toggles */ +.toggle:indeterminate { + opacity: 1; +} + +/* Ensure smooth toggle transitions without intermediate states */ +.toggle { + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.toggle:checked { + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +/* Remove any active/pressed state that might cause intermediate appearance */ +.toggle:active, +.toggle:active:focus { + outline: none !important; + box-shadow: none !important; +} + +/* Responsive Breakpoints */ + +/* Large tablets and smaller desktops (1024px - 1280px) */ +@media (max-width: 1280px) { + .map-control-panel { + width: 420px; + right: -420px; + } +} + +/* Tablets (768px - 1024px) */ +@media (max-width: 1024px) { + .map-control-panel { + width: 380px; + right: -380px; + } + + .panel-body { + padding: 20px; + } +} + +/* Small tablets and large phones (640px - 768px) */ +@media (max-width: 768px) { + .map-control-panel { + width: 95%; + right: -95%; + max-width: 480px; + } + + .panel-header { + padding: 16px 20px; + } + + .panel-title { + font-size: 1.125rem; + } + + .panel-body { + padding: 16px 20px; + } +} + +/* Mobile phones (< 640px) */ +@media (max-width: 640px) { + .map-control-panel { + width: 100%; + right: -100%; + max-width: none; + } + + .panel-tabs { + width: 56px; + padding: 12px 0; + gap: 6px; + } + + .tab-btn { + width: 44px; + height: 44px; + } + + .tab-icon { + width: 20px; + height: 20px; + } + + .panel-header { + padding: 14px 16px; + } + + .panel-title { + font-size: 1rem; + } + + .panel-body { + padding: 16px; + } + + /* Reduce spacing on mobile */ + .space-y-4 > * + * { + margin-top: 0.75rem; + } + + .space-y-6 > * + * { + margin-top: 1rem; + } +} + +/* Very small phones (< 375px) */ +@media (max-width: 375px) { + .panel-tabs { + width: 52px; + padding: 10px 0; + } + + .tab-btn { + width: 40px; + height: 40px; + } + + .panel-header { + padding: 12px; + } + + .panel-body { + padding: 12px; + } +} diff --git a/app/assets/svg/icons/lucide/outline/circle-plus.svg b/app/assets/svg/icons/lucide/outline/circle-plus.svg new file mode 100644 index 00000000..92ef2e69 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-plus.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/grid2x2.svg b/app/assets/svg/icons/lucide/outline/grid2x2.svg new file mode 100644 index 00000000..349efba3 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/grid2x2.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/layer.svg b/app/assets/svg/icons/lucide/outline/layer.svg new file mode 100644 index 00000000..0ee810d9 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/layer.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/map-pin-check.svg b/app/assets/svg/icons/lucide/outline/map-pin-check.svg new file mode 100644 index 00000000..8be6065f --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/map-pin-check.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/pocket-knife.svg b/app/assets/svg/icons/lucide/outline/pocket-knife.svg new file mode 100644 index 00000000..5927a35b --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/pocket-knife.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/rotate-ccw.svg b/app/assets/svg/icons/lucide/outline/rotate-ccw.svg new file mode 100644 index 00000000..b8b9c76e --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/rotate-ccw.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/route.svg b/app/assets/svg/icons/lucide/outline/route.svg new file mode 100644 index 00000000..e76d5fbe --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/route.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/save.svg b/app/assets/svg/icons/lucide/outline/save.svg new file mode 100644 index 00000000..6d955b5a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/save.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/settings.svg b/app/assets/svg/icons/lucide/outline/settings.svg new file mode 100644 index 00000000..839ebd9e --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/settings.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/x.svg b/app/assets/svg/icons/lucide/outline/x.svg new file mode 100644 index 00000000..3995f61b --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/x.svg @@ -0,0 +1 @@ + diff --git a/app/controllers/api/v1/areas_controller.rb b/app/controllers/api/v1/areas_controller.rb index 81e20d17..de42c64e 100644 --- a/app/controllers/api/v1/areas_controller.rb +++ b/app/controllers/api/v1/areas_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::AreasController < ApiController - before_action :set_area, only: %i[update destroy] + before_action :set_area, only: %i[show update destroy] def index @areas = current_api_user.areas @@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController render json: @areas, status: :ok end + def show + render json: @area, status: :ok + end + def create @area = current_api_user.areas.build(area_params) diff --git a/app/controllers/api/v1/places_controller.rb b/app/controllers/api/v1/places_controller.rb index ae0c247b..97035526 100644 --- a/app/controllers/api/v1/places_controller.rb +++ b/app/controllers/api/v1/places_controller.rb @@ -7,8 +7,28 @@ module Api def index @places = current_api_user.places.includes(:tags, :visits) - @places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present? - @places = @places.without_tags if params[:untagged] == 'true' + + if params[:tag_ids].present? + tag_ids = Array(params[:tag_ids]) + + # Separate numeric tag IDs from "untagged" + numeric_tag_ids = tag_ids.reject { |id| id == 'untagged' }.map(&:to_i) + include_untagged = tag_ids.include?('untagged') + + if numeric_tag_ids.any? && include_untagged + # Both tagged and untagged: return union (OR logic) + tagged = current_api_user.places.includes(:tags, :visits).with_tags(numeric_tag_ids) + untagged = current_api_user.places.includes(:tags, :visits).without_tags + @places = Place.from("(#{tagged.to_sql} UNION #{untagged.to_sql}) AS places") + .includes(:tags, :visits) + elsif numeric_tag_ids.any? + # Only tagged places with ANY of the selected tags (OR logic) + @places = @places.with_tags(numeric_tag_ids) + elsif include_untagged + # Only untagged places + @places = @places.without_tags + end + end render json: @places.map { |place| serialize_place(place) } end diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 08f7097c..ad5dca57 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -12,6 +12,23 @@ class Api::V1::PointsController < ApiController points = current_api_user .points .where(timestamp: start_at..end_at) + + # Filter by geographic bounds if provided + if params[:min_longitude].present? && params[:max_longitude].present? && + params[:min_latitude].present? && params[:max_latitude].present? + min_lng = params[:min_longitude].to_f + max_lng = params[:max_longitude].to_f + min_lat = params[:min_latitude].to_f + max_lat = params[:max_latitude].to_f + + # Use PostGIS to filter points within bounding box + points = points.where( + 'ST_X(lonlat::geometry) BETWEEN ? AND ? AND ST_Y(lonlat::geometry) BETWEEN ? AND ?', + min_lng, max_lng, min_lat, max_lat + ) + end + + points = points .order(timestamp: order) .page(params[:page]) .per(params[:per_page] || 100) diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index 6d29bf18..f164bbe1 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController def index render json: { - settings: current_api_user.safe_settings, + settings: current_api_user.safe_settings.config, status: 'success' }, status: :ok end @@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController settings_params.each { |key, value| current_api_user.settings[key] = value } if current_api_user.save - render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' }, + render json: { message: 'Settings updated', settings: current_api_user.safe_settings.config, status: 'success' }, status: :ok else render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages }, @@ -31,6 +31,7 @@ class Api::V1::SettingsController < ApiController :preferred_map_layer, :points_rendering_mode, :live_map_enabled, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, :speed_colored_routes, :speed_color_scale, :fog_of_war_threshold, + :maps_v2_style, :maps_maplibre_style, enabled_map_layers: [] ) end diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 4ec4173b..1002536d 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -10,6 +10,11 @@ class Api::V1::VisitsController < ApiController render json: serialized_visits end + def show + visit = current_api_user.visits.find(params[:id]) + render json: Api::VisitSerializer.new(visit).call + end + def create service = Visits::Create.new(current_api_user, visit_params) diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 27453c76..ae3d4fbc 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class HomeController < ApplicationController + include ApplicationHelper + def index # redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED - redirect_to map_url if current_user + redirect_to preferred_map_path if current_user @points = current_user.points.without_raw_data if current_user end diff --git a/app/controllers/map_controller.rb b/app/controllers/map/leaflet_controller.rb similarity index 97% rename from app/controllers/map_controller.rb rename to app/controllers/map/leaflet_controller.rb index 622f8112..2c5e2672 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map/leaflet_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MapController < ApplicationController +class Map::LeafletController < ApplicationController before_action :authenticate_user! layout 'map', only: :index diff --git a/app/controllers/map/maplibre_controller.rb b/app/controllers/map/maplibre_controller.rb new file mode 100644 index 00000000..529242d5 --- /dev/null +++ b/app/controllers/map/maplibre_controller.rb @@ -0,0 +1,33 @@ +module Map + class MaplibreController < ApplicationController + before_action :authenticate_user! + layout 'map' + + def index + @start_at = parsed_start_at + @end_at = parsed_end_at + end + + private + + def start_at + return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present? + + Time.zone.today.beginning_of_day.to_i + end + + def end_at + return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present? + + Time.zone.today.end_of_day.to_i + end + + def parsed_start_at + Time.zone.at(start_at) + end + + def parsed_end_at + Time.zone.at(end_at) + end + end +end diff --git a/app/controllers/settings/maps_controller.rb b/app/controllers/settings/maps_controller.rb index 3cee8e0e..f4275f70 100644 --- a/app/controllers/settings/maps_controller.rb +++ b/app/controllers/settings/maps_controller.rb @@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController private def settings_params - params.require(:maps).permit(:name, :url, :distance_unit) + params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 970a549b..3f5bc50a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -142,4 +142,11 @@ module ApplicationHelper ALLOW_EMAIL_PASSWORD_REGISTRATION end + + def preferred_map_path + return map_v1_path unless user_signed_in? + + preferred_version = current_user.safe_settings.maps&.dig('preferred_version') + preferred_version == 'v2' ? map_v2_path : map_v1_path + end end diff --git a/app/javascript/README.md b/app/javascript/README.md new file mode 100644 index 00000000..743dc02c --- /dev/null +++ b/app/javascript/README.md @@ -0,0 +1,724 @@ +# Dawarich JavaScript Architecture + +This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation. + +## Table of Contents + +- [Overview](#overview) +- [Technology Stack](#technology-stack) +- [Architecture Patterns](#architecture-patterns) +- [Directory Structure](#directory-structure) +- [Core Concepts](#core-concepts) +- [Maps (MapLibre) Architecture](#maps-maplibre-architecture) +- [Creating New Features](#creating-new-features) +- [Best Practices](#best-practices) + +## Overview + +Dawarich uses a modern JavaScript architecture built on **Hotwire (Turbo + Stimulus)** for page interactions and **MapLibre GL JS** for map rendering. The Maps (MapLibre) implementation follows object-oriented principles with clear separation of concerns. + +## Technology Stack + +- **Stimulus** - Modest JavaScript framework for sprinkles of interactivity +- **Turbo Rails** - SPA-like page navigation without building an SPA +- **MapLibre GL JS** - Open-source map rendering engine +- **ES6 Modules** - Modern JavaScript module system +- **Tailwind CSS + DaisyUI** - Utility-first CSS framework + +## Architecture Patterns + +### 1. Stimulus Controllers + +**Purpose:** Connect DOM elements to JavaScript behavior + +**Location:** `app/javascript/controllers/` + +**Pattern:** +```javascript +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['element'] + static values = { apiKey: String } + + connect() { + // Initialize when element appears in DOM + } + + disconnect() { + // Cleanup when element is removed + } +} +``` + +**Key Principles:** +- Controllers should be stateless when possible +- Use `targets` for DOM element references +- Use `values` for passing data from HTML +- Always cleanup in `disconnect()` + +### 2. Service Classes + +**Purpose:** Encapsulate business logic and API communication + +**Location:** `app/javascript/maps_maplibre/services/` + +**Pattern:** +```javascript +export class ApiClient { + constructor(apiKey) { + this.apiKey = apiKey + } + + async fetchData() { + const response = await fetch(url, { + headers: this.getHeaders() + }) + return response.json() + } +} +``` + +**Key Principles:** +- Single responsibility - one service per concern +- Consistent error handling +- Return promises for async operations +- Use constructor injection for dependencies + +### 3. Layer Classes (Map Layers) + +**Purpose:** Manage map visualization layers + +**Location:** `app/javascript/maps_maplibre/layers/` + +**Pattern:** +```javascript +import { BaseLayer } from './base_layer' + +export class CustomLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'custom', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data + } + } + + getLayerConfigs() { + return [{ + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { /* ... */ } + }] + } +} +``` + +**Key Principles:** +- All layers extend `BaseLayer` +- Implement `getSourceConfig()` and `getLayerConfigs()` +- Store data in `this.data` +- Use `this.visible` for visibility state +- Inherit common methods: `add()`, `update()`, `show()`, `hide()`, `toggle()` + +### 4. Utility Modules + +**Purpose:** Provide reusable helper functions + +**Location:** `app/javascript/maps_maplibre/utils/` + +**Pattern:** +```javascript +export class UtilityClass { + static helperMethod(param) { + // Static methods for stateless utilities + } +} + +// Or singleton pattern +export const utilityInstance = new UtilityClass() +``` + +### 5. Component Classes + +**Purpose:** Reusable UI components + +**Location:** `app/javascript/maps_maplibre/components/` + +**Pattern:** +```javascript +export class PopupFactory { + static createPopup(data) { + return `
${data.name}
` + } +} +``` + +## Directory Structure + +``` +app/javascript/ +├── application.js # Entry point +├── controllers/ # Stimulus controllers +│ ├── maps/maplibre_controller.js # Main map controller +│ ├── maps_maplibre/ # Controller modules +│ │ ├── layer_manager.js # Layer lifecycle management +│ │ ├── data_loader.js # API data fetching +│ │ ├── event_handlers.js # Map event handling +│ │ ├── filter_manager.js # Data filtering +│ │ └── date_manager.js # Date range management +│ └── ... # Other controllers +├── maps_maplibre/ # Maps (MapLibre) implementation +│ ├── layers/ # Map layer classes +│ │ ├── base_layer.js # Abstract base class +│ │ ├── points_layer.js # Point markers +│ │ ├── routes_layer.js # Route lines +│ │ ├── heatmap_layer.js # Heatmap visualization +│ │ ├── visits_layer.js # Visit markers +│ │ ├── photos_layer.js # Photo markers +│ │ ├── places_layer.js # Places markers +│ │ ├── areas_layer.js # User-defined areas +│ │ ├── fog_layer.js # Fog of war overlay +│ │ └── scratch_layer.js # Scratch map +│ ├── services/ # API and external services +│ │ ├── api_client.js # REST API wrapper +│ │ └── location_search_service.js +│ ├── utils/ # Helper utilities +│ │ ├── settings_manager.js # User preferences +│ │ ├── geojson_transformers.js +│ │ ├── performance_monitor.js +│ │ ├── lazy_loader.js # Code splitting +│ │ └── ... +│ ├── components/ # Reusable UI components +│ │ ├── popup_factory.js # Map popup generator +│ │ ├── toast.js # Toast notifications +│ │ └── ... +│ └── channels/ # ActionCable channels +│ └── map_channel.js # Real-time updates +└── maps/ # Legacy Maps V1 (being phased out) +``` + +## Core Concepts + +### Manager Pattern + +The Maps (MapLibre) controller delegates responsibilities to specialized managers: + +1. **LayerManager** - Layer lifecycle (add/remove/toggle/update) +2. **DataLoader** - API data fetching and transformation +3. **EventHandlers** - Map interaction events +4. **FilterManager** - Data filtering and searching +5. **DateManager** - Date range calculations +6. **SettingsManager** - User preferences persistence + +**Benefits:** +- Single Responsibility Principle +- Easier testing +- Improved code organization +- Better reusability + +### Data Flow + +``` +User Action + ↓ +Stimulus Controller Method + ↓ +Manager (e.g., DataLoader) + ↓ +Service (e.g., ApiClient) + ↓ +API Endpoint + ↓ +Transform to GeoJSON + ↓ +Update Layer + ↓ +MapLibre Renders +``` + +### State Management + +**Settings Persistence:** +- Primary: Backend API (`/api/v1/settings`) +- Fallback: localStorage +- Sync on initialization +- Save on every change (debounced) + +**Layer State:** +- Stored in layer instances (`this.visible`, `this.data`) +- Synced with SettingsManager +- Persisted across sessions + +### Event System + +**Custom Events:** +```javascript +// Dispatch +document.dispatchEvent(new CustomEvent('visit:created', { + detail: { visitId: 123 } +})) + +// Listen +document.addEventListener('visit:created', (event) => { + console.log(event.detail.visitId) +}) +``` + +**Map Events:** +```javascript +map.on('click', 'layer-id', (e) => { + const feature = e.features[0] + // Handle click +}) +``` + +## Maps (MapLibre) Architecture + +### Layer Hierarchy + +Layers are rendered in specific order (bottom to top): + +1. **Scratch Layer** - Visited countries/regions overlay +2. **Heatmap Layer** - Point density visualization +3. **Areas Layer** - User-defined circular areas +4. **Tracks Layer** - Imported GPS tracks +5. **Routes Layer** - Generated routes from points +6. **Visits Layer** - Detected visits to places +7. **Places Layer** - Named locations +8. **Photos Layer** - Photos with geolocation +9. **Family Layer** - Real-time family member locations +10. **Points Layer** - Individual location points +11. **Fog Layer** - Canvas overlay showing unexplored areas + +### BaseLayer Pattern + +All layers extend `BaseLayer` which provides: + +**Methods:** +- `add(data)` - Add layer to map +- `update(data)` - Update layer data +- `remove()` - Remove layer from map +- `show()` / `hide()` - Toggle visibility +- `toggle(visible)` - Set visibility state + +**Abstract Methods (must implement):** +- `getSourceConfig()` - MapLibre source configuration +- `getLayerConfigs()` - Array of MapLibre layer configurations + +**Example Implementation:** +```javascript +export class PointsLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'points', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { type: 'FeatureCollection', features: [] } + } + } + + getLayerConfigs() { + return [{ + id: 'points', + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': 4, + 'circle-color': '#3b82f6' + } + }] + } +} +``` + +### Lazy Loading + +Heavy layers are lazy-loaded to reduce initial bundle size: + +```javascript +// In lazy_loader.js +const paths = { + 'fog': () => import('../layers/fog_layer.js'), + 'scratch': () => import('../layers/scratch_layer.js') +} + +// Usage +const ScratchLayer = await lazyLoader.loadLayer('scratch') +const layer = new ScratchLayer(map, options) +``` + +**When to use:** +- Large dependencies (e.g., canvas-based rendering) +- Rarely-used features +- Heavy computations + +### GeoJSON Transformations + +All data is transformed to GeoJSON before rendering: + +```javascript +// Points +{ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [longitude, latitude] + }, + properties: { + id: 1, + timestamp: '2024-01-01T12:00:00Z', + // ... other properties + } + }] +} +``` + +**Key Functions:** +- `pointsToGeoJSON(points)` - Convert points array +- `visitsToGeoJSON(visits)` - Convert visits +- `photosToGeoJSON(photos)` - Convert photos +- `placesToGeoJSON(places)` - Convert places +- `areasToGeoJSON(areas)` - Convert circular areas to polygons + +## Creating New Features + +### Adding a New Layer + +1. **Create layer class** in `app/javascript/maps_maplibre/layers/`: + +```javascript +import { BaseLayer } from './base_layer' + +export class NewLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'new-layer', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { type: 'FeatureCollection', features: [] } + } + } + + getLayerConfigs() { + return [{ + id: this.id, + type: 'symbol', // or 'circle', 'line', 'fill', 'heatmap' + source: this.sourceId, + paint: { /* styling */ }, + layout: { /* layout */ } + }] + } +} +``` + +2. **Register in LayerManager** (`controllers/maps_maplibre/layer_manager.js`): + +```javascript +import { NewLayer } from 'maps_maplibre/layers/new_layer' + +// In addAllLayers method +_addNewLayer(dataGeoJSON) { + if (!this.layers.newLayer) { + this.layers.newLayer = new NewLayer(this.map, { + visible: this.settings.newLayerEnabled || false + }) + this.layers.newLayer.add(dataGeoJSON) + } else { + this.layers.newLayer.update(dataGeoJSON) + } +} +``` + +3. **Add to settings** (`utils/settings_manager.js`): + +```javascript +const DEFAULT_SETTINGS = { + // ... + newLayerEnabled: false +} + +const LAYER_NAME_MAP = { + // ... + 'New Layer': 'newLayerEnabled' +} +``` + +4. **Add UI controls** in view template. + +### Adding a New API Endpoint + +1. **Add method to ApiClient** (`services/api_client.js`): + +```javascript +async fetchNewData({ param1, param2 }) { + const params = new URLSearchParams({ param1, param2 }) + + const response = await fetch(`${this.baseURL}/new-endpoint?${params}`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`) + } + + return response.json() +} +``` + +2. **Add transformation** in DataLoader: + +```javascript +newDataToGeoJSON(data) { + return { + type: 'FeatureCollection', + features: data.map(item => ({ + type: 'Feature', + geometry: { /* ... */ }, + properties: { /* ... */ } + })) + } +} +``` + +3. **Use in controller:** + +```javascript +const data = await this.api.fetchNewData({ param1, param2 }) +const geojson = this.dataLoader.newDataToGeoJSON(data) +this.layerManager.updateLayer('new-layer', geojson) +``` + +### Adding a New Utility + +1. **Create utility file** in `utils/`: + +```javascript +export class NewUtility { + static calculate(input) { + // Pure function - no side effects + return result + } +} + +// Or singleton for stateful utilities +class NewManager { + constructor() { + this.state = {} + } + + doSomething() { + // Stateful operation + } +} + +export const newManager = new NewManager() +``` + +2. **Import and use:** + +```javascript +import { NewUtility } from 'maps_maplibre/utils/new_utility' + +const result = NewUtility.calculate(input) +``` + +## Best Practices + +### Code Style + +1. **Use ES6+ features:** + - Arrow functions + - Template literals + - Destructuring + - Async/await + - Classes + +2. **Naming conventions:** + - Classes: `PascalCase` + - Methods/variables: `camelCase` + - Constants: `UPPER_SNAKE_CASE` + - Files: `snake_case.js` + +3. **Always use semicolons** for statement termination + +4. **Prefer `const` over `let`**, avoid `var` + +### Performance + +1. **Lazy load heavy features:** + ```javascript + const Layer = await lazyLoader.loadLayer('name') + ``` + +2. **Debounce frequent operations:** + ```javascript + let timeout + function onInput(e) { + clearTimeout(timeout) + timeout = setTimeout(() => actualWork(e), 300) + } + ``` + +3. **Use performance monitoring:** + ```javascript + performanceMonitor.mark('operation') + // ... do work + performanceMonitor.measure('operation') + ``` + +4. **Minimize DOM manipulations** - batch updates when possible + +### Error Handling + +1. **Always handle promise rejections:** + ```javascript + try { + const data = await fetchData() + } catch (error) { + console.error('Failed:', error) + Toast.error('Operation failed') + } + ``` + +2. **Provide user feedback:** + ```javascript + Toast.success('Data loaded') + Toast.error('Failed to load data') + Toast.info('Click map to add point') + ``` + +3. **Log errors for debugging:** + ```javascript + console.error('[Component] Error details:', error) + ``` + +### Memory Management + +1. **Always cleanup in disconnect():** + ```javascript + disconnect() { + this.searchManager?.destroy() + this.cleanup.cleanup() + this.map?.remove() + } + ``` + +2. **Use CleanupHelper for event listeners:** + ```javascript + this.cleanup = new CleanupHelper() + this.cleanup.addEventListener(element, 'click', handler) + + // In disconnect(): + this.cleanup.cleanup() // Removes all listeners + ``` + +3. **Remove map layers and sources:** + ```javascript + remove() { + this.getLayerIds().forEach(id => { + if (this.map.getLayer(id)) { + this.map.removeLayer(id) + } + }) + if (this.map.getSource(this.sourceId)) { + this.map.removeSource(this.sourceId) + } + } + ``` + +### Testing Considerations + +1. **Keep methods small and focused** - easier to test +2. **Avoid tight coupling** - use dependency injection +3. **Separate pure functions** from side effects +4. **Use static methods** for stateless utilities + +### State Management + +1. **Single source of truth:** + - Settings: `SettingsManager` + - Layer data: Layer instances + - UI state: Controller properties + +2. **Sync state with backend:** + ```javascript + SettingsManager.updateSetting('key', value) + // Saves to both localStorage and backend + ``` + +3. **Restore state on load:** + ```javascript + async connect() { + this.settings = await SettingsManager.sync() + this.syncToggleStates() + } + ``` + +### Documentation + +1. **Add JSDoc comments for public APIs:** + ```javascript + /** + * Fetch all points for date range + * @param {Object} options - { start_at, end_at, onProgress } + * @returns {Promise} All points + */ + async fetchAllPoints({ start_at, end_at, onProgress }) { + // ... + } + ``` + +2. **Document complex logic with inline comments** + +3. **Keep this README updated** when adding major features + +### Code Organization + +1. **One class per file** - easier to find and maintain +2. **Group related functionality** in directories +3. **Use index files** for barrel exports when needed +4. **Avoid circular dependencies** - use dependency injection + +### Migration from Maps V1 to V2 + +When updating features, follow this pattern: + +1. **Keep V1 working** - V2 is opt-in +2. **Share utilities** where possible (e.g., color calculations) +3. **Use same API endpoints** - maintain compatibility +4. **Document differences** in code comments + +--- + +## Examples + +### Complete Layer Implementation + +See `app/javascript/maps_maplibre/layers/heatmap_layer.js` for a simple example. + +### Complete Utility Implementation + +See `app/javascript/maps_maplibre/utils/settings_manager.js` for state management. + +### Complete Service Implementation + +See `app/javascript/maps_maplibre/services/api_client.js` for API communication. + +### Complete Controller Implementation + +See `app/javascript/controllers/maps/maplibre_controller.js` for orchestration. + +--- + +**Questions or need help?** Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8 diff --git a/app/javascript/controllers/area_creation_v2_controller.js b/app/javascript/controllers/area_creation_v2_controller.js new file mode 100644 index 00000000..fc4502bd --- /dev/null +++ b/app/javascript/controllers/area_creation_v2_controller.js @@ -0,0 +1,161 @@ +import { Controller } from '@hotwired/stimulus' + +/** + * Area creation controller + * Handles the area creation modal and form submission + */ +export default class extends Controller { + static targets = [ + 'modal', + 'form', + 'nameInput', + 'latitudeInput', + 'longitudeInput', + 'radiusInput', + 'radiusDisplay', + 'submitButton', + 'submitSpinner', + 'submitText' + ] + + static values = { + apiKey: String + } + + connect() { + this.area = null + this.setupEventListeners() + console.log('[Area Creation V2] Controller connected') + } + + /** + * Setup event listeners for area drawing + */ + setupEventListeners() { + document.addEventListener('area:drawn', (e) => { + this.open(e.detail.center, e.detail.radius) + }) + } + + /** + * Open the modal with area data + */ + open(center, radius) { + // Store area data + this.area = { center, radius } + + // Update form fields + this.latitudeInputTarget.value = center[1] + this.longitudeInputTarget.value = center[0] + this.radiusInputTarget.value = Math.round(radius) + this.radiusDisplayTarget.textContent = Math.round(radius) + + // Show modal + this.modalTarget.classList.add('modal-open') + this.nameInputTarget.focus() + } + + /** + * Close the modal + */ + close() { + this.modalTarget.classList.remove('modal-open') + this.resetForm() + } + + /** + * Submit the form + */ + async submit(event) { + event.preventDefault() + + if (!this.area) { + console.error('No area data available') + return + } + + const formData = new FormData(this.formTarget) + const name = formData.get('name') + const latitude = parseFloat(formData.get('latitude')) + const longitude = parseFloat(formData.get('longitude')) + const radius = parseFloat(formData.get('radius')) + + if (!name || !latitude || !longitude || !radius) { + alert('Please fill in all required fields') + return + } + + this.setLoading(true) + + try { + const response = await fetch('/api/v1/areas', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKeyValue}` + }, + body: JSON.stringify({ + name, + latitude, + longitude, + radius + }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || 'Failed to create area') + } + + const area = await response.json() + + // Close modal + this.close() + + // Dispatch document event for area created + document.dispatchEvent(new CustomEvent('area:created', { + detail: { area } + })) + + } catch (error) { + console.error('Error creating area:', error) + alert(`Error creating area: ${error.message}`) + } finally { + this.setLoading(false) + } + } + + /** + * Set loading state + */ + setLoading(loading) { + this.submitButtonTarget.disabled = loading + + if (loading) { + this.submitSpinnerTarget.classList.remove('hidden') + this.submitTextTarget.textContent = 'Creating...' + } else { + this.submitSpinnerTarget.classList.add('hidden') + this.submitTextTarget.textContent = 'Create Area' + } + } + + /** + * Reset form + */ + resetForm() { + this.formTarget.reset() + this.area = null + this.radiusDisplayTarget.textContent = '0' + } + + /** + * Show success message + */ + showSuccess(message) { + // Try to use the Toast component if available + if (window.Toast) { + window.Toast.show(message, 'success') + } + } +} diff --git a/app/javascript/controllers/area_drawer_controller.js b/app/javascript/controllers/area_drawer_controller.js new file mode 100644 index 00000000..e52d60cf --- /dev/null +++ b/app/javascript/controllers/area_drawer_controller.js @@ -0,0 +1,146 @@ +import { Controller } from '@hotwired/stimulus' +import { createCircle, calculateDistance } from 'maps_maplibre/utils/geometry' + +/** + * Area drawer controller + * Draw circular areas on map + */ +export default class extends Controller { + connect() { + this.isDrawing = false + this.center = null + this.radius = 0 + this.map = null + + // Bind event handlers to maintain context + this.onClick = this.onClick.bind(this) + this.onMouseMove = this.onMouseMove.bind(this) + } + + /** + * Start drawing mode + * @param {maplibregl.Map} map - The MapLibre map instance + */ + startDrawing(map) { + if (!map) { + console.error('[Area Drawer] Map instance not provided') + return + } + + this.isDrawing = true + this.map = map + map.getCanvas().style.cursor = 'crosshair' + + // Add temporary layer + if (!map.getSource('draw-source')) { + map.addSource('draw-source', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }) + + map.addLayer({ + id: 'draw-fill', + type: 'fill', + source: 'draw-source', + paint: { + 'fill-color': '#22c55e', + 'fill-opacity': 0.2 + } + }) + + map.addLayer({ + id: 'draw-outline', + type: 'line', + source: 'draw-source', + paint: { + 'line-color': '#22c55e', + 'line-width': 2 + } + }) + } + + // Add event listeners + map.on('click', this.onClick) + map.on('mousemove', this.onMouseMove) + } + + /** + * Cancel drawing mode + */ + cancelDrawing() { + if (!this.map) return + + this.isDrawing = false + this.center = null + this.radius = 0 + + this.map.getCanvas().style.cursor = '' + + // Clear drawing + const source = this.map.getSource('draw-source') + if (source) { + source.setData({ type: 'FeatureCollection', features: [] }) + } + + // Remove event listeners + this.map.off('click', this.onClick) + this.map.off('mousemove', this.onMouseMove) + } + + /** + * Click handler + */ + onClick(e) { + if (!this.isDrawing || !this.map) return + + if (!this.center) { + // First click - set center + this.center = [e.lngLat.lng, e.lngLat.lat] + } else { + // Second click - finish drawing + document.dispatchEvent(new CustomEvent('area:drawn', { + detail: { + center: this.center, + radius: this.radius + } + })) + + this.cancelDrawing() + } + } + + /** + * Mouse move handler + */ + onMouseMove(e) { + if (!this.isDrawing || !this.center || !this.map) return + + const currentPoint = [e.lngLat.lng, e.lngLat.lat] + this.radius = calculateDistance(this.center, currentPoint) + + this.updateDrawing() + } + + /** + * Update drawing visualization + */ + updateDrawing() { + if (!this.center || this.radius === 0 || !this.map) return + + const coordinates = createCircle(this.center, this.radius) + + const source = this.map.getSource('draw-source') + if (source) { + source.setData({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [coordinates] + } + }] + }) + } + } +} diff --git a/app/javascript/controllers/area_selector_controller.js b/app/javascript/controllers/area_selector_controller.js new file mode 100644 index 00000000..433ca71d --- /dev/null +++ b/app/javascript/controllers/area_selector_controller.js @@ -0,0 +1,161 @@ +import { Controller } from '@hotwired/stimulus' +import { createRectangle } from 'maps_maplibre/utils/geometry' + +/** + * Area selector controller + * Draw rectangle selection on map + */ +export default class extends Controller { + static outlets = ['mapsV2'] + + connect() { + this.isSelecting = false + this.startPoint = null + this.currentPoint = null + } + + /** + * Start rectangle selection mode + */ + startSelection() { + if (!this.hasMapsV2Outlet) { + console.error('Maps V2 outlet not found') + return + } + + this.isSelecting = true + const map = this.mapsV2Outlet.map + map.getCanvas().style.cursor = 'crosshair' + + // Add temporary layer for selection + if (!map.getSource('selection-source')) { + map.addSource('selection-source', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }) + + map.addLayer({ + id: 'selection-fill', + type: 'fill', + source: 'selection-source', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.2 + } + }) + + map.addLayer({ + id: 'selection-outline', + type: 'line', + source: 'selection-source', + paint: { + 'line-color': '#3b82f6', + 'line-width': 2, + 'line-dasharray': [2, 2] + } + }) + } + + // Add event listeners + map.on('mousedown', this.onMouseDown) + map.on('mousemove', this.onMouseMove) + map.on('mouseup', this.onMouseUp) + } + + /** + * Cancel selection mode + */ + cancelSelection() { + if (!this.hasMapsV2Outlet) return + + this.isSelecting = false + this.startPoint = null + this.currentPoint = null + + const map = this.mapsV2Outlet.map + map.getCanvas().style.cursor = '' + + // Clear selection + const source = map.getSource('selection-source') + if (source) { + source.setData({ type: 'FeatureCollection', features: [] }) + } + + // Remove event listeners + map.off('mousedown', this.onMouseDown) + map.off('mousemove', this.onMouseMove) + map.off('mouseup', this.onMouseUp) + } + + /** + * Mouse down handler + */ + onMouseDown = (e) => { + if (!this.isSelecting || !this.hasMapsV2Outlet) return + + this.startPoint = [e.lngLat.lng, e.lngLat.lat] + this.mapsV2Outlet.map.dragPan.disable() + } + + /** + * Mouse move handler + */ + onMouseMove = (e) => { + if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return + + this.currentPoint = [e.lngLat.lng, e.lngLat.lat] + this.updateSelection() + } + + /** + * Mouse up handler + */ + onMouseUp = (e) => { + if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return + + this.currentPoint = [e.lngLat.lng, e.lngLat.lat] + this.mapsV2Outlet.map.dragPan.enable() + + // Emit selection event + const bounds = this.getSelectionBounds() + this.dispatch('selected', { detail: { bounds } }) + + this.cancelSelection() + } + + /** + * Update selection visualization + */ + updateSelection() { + if (!this.startPoint || !this.currentPoint || !this.hasMapsV2Outlet) return + + const bounds = this.getSelectionBounds() + const rectangle = createRectangle(bounds) + + const source = this.mapsV2Outlet.map.getSource('selection-source') + if (source) { + source.setData({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: rectangle + } + }] + }) + } + } + + /** + * Get selection bounds + */ + getSelectionBounds() { + return { + minLng: Math.min(this.startPoint[0], this.currentPoint[0]), + minLat: Math.min(this.startPoint[1], this.currentPoint[1]), + maxLng: Math.max(this.startPoint[0], this.currentPoint[0]), + maxLat: Math.max(this.startPoint[1], this.currentPoint[1]) + } + } +} diff --git a/app/javascript/controllers/map_panel_controller.js b/app/javascript/controllers/map_panel_controller.js new file mode 100644 index 00000000..21103495 --- /dev/null +++ b/app/javascript/controllers/map_panel_controller.js @@ -0,0 +1,68 @@ +import { Controller } from '@hotwired/stimulus' + +/** + * Map Panel Controller + * Handles tab switching in the map control panel + */ +export default class extends Controller { + static targets = ['tabButton', 'tabContent', 'title'] + + // Tab title mappings + static titles = { + search: 'Search', + layers: 'Map Layers', + tools: 'Tools', + links: 'Links', + settings: 'Settings' + } + + connect() { + console.log('[Map Panel] Connected') + } + + /** + * Switch to a different tab + */ + switchTab(event) { + const button = event.currentTarget + const tabName = button.dataset.tab + + this.activateTab(tabName) + } + + /** + * Programmatically switch to a tab by name + */ + switchToTab(tabName) { + this.activateTab(tabName) + } + + /** + * Internal method to activate a tab + */ + activateTab(tabName) { + // Find the button for this tab + const button = this.tabButtonTargets.find(btn => btn.dataset.tab === tabName) + + // Update active button + this.tabButtonTargets.forEach(btn => { + btn.classList.remove('active') + }) + if (button) { + button.classList.add('active') + } + + // Update tab content + this.tabContentTargets.forEach(content => { + const contentTab = content.dataset.tabContent + if (contentTab === tabName) { + content.classList.add('active') + } else { + content.classList.remove('active') + } + }) + + // Update title + this.titleTarget.textContent = this.constructor.titles[tabName] || tabName + } +} diff --git a/app/javascript/controllers/maps/maplibre/area_selection_manager.js b/app/javascript/controllers/maps/maplibre/area_selection_manager.js new file mode 100644 index 00000000..027689ca --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/area_selection_manager.js @@ -0,0 +1,540 @@ +import { SelectionLayer } from 'maps_maplibre/layers/selection_layer' +import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer' +import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers' +import { VisitCard } from 'maps_maplibre/components/visit_card' +import { Toast } from 'maps_maplibre/components/toast' + +/** + * Manages area selection and bulk operations for Maps V2 + * Handles selection mode, visit cards, and bulk actions (merge, confirm, decline) + */ +export class AreaSelectionManager { + constructor(controller) { + this.controller = controller + this.map = controller.map + this.api = controller.api + this.selectionLayer = null + this.selectedPointsLayer = null + this.selectedVisits = [] + this.selectedVisitIds = new Set() + } + + /** + * Start area selection mode + */ + async startSelectArea() { + console.log('[Maps V2] Starting area selection mode') + + // Initialize selection layer if not exists + if (!this.selectionLayer) { + this.selectionLayer = new SelectionLayer(this.map, { + visible: true, + onSelectionComplete: this.handleAreaSelected.bind(this) + }) + + this.selectionLayer.add({ + type: 'FeatureCollection', + features: [] + }) + + console.log('[Maps V2] Selection layer initialized') + } + + // Initialize selected points layer if not exists + if (!this.selectedPointsLayer) { + this.selectedPointsLayer = new SelectedPointsLayer(this.map, { + visible: true + }) + + this.selectedPointsLayer.add({ + type: 'FeatureCollection', + features: [] + }) + + console.log('[Maps V2] Selected points layer initialized') + } + + // Enable selection mode + this.selectionLayer.enableSelectionMode() + + // Update UI - replace Select Area button with Cancel Selection button + if (this.controller.hasSelectAreaButtonTarget) { + this.controller.selectAreaButtonTarget.innerHTML = ` + + + + + Cancel Selection + ` + this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection' + } + + Toast.info('Draw a rectangle on the map to select points') + } + + /** + * Handle area selection completion + */ + async handleAreaSelected(bounds) { + console.log('[Maps V2] Area selected:', bounds) + + try { + Toast.info('Fetching data in selected area...') + + const [points, visits] = await Promise.all([ + this.api.fetchPointsInArea({ + start_at: this.controller.startDateValue, + end_at: this.controller.endDateValue, + min_longitude: bounds.minLng, + max_longitude: bounds.maxLng, + min_latitude: bounds.minLat, + max_latitude: bounds.maxLat + }), + this.api.fetchVisitsInArea({ + start_at: this.controller.startDateValue, + end_at: this.controller.endDateValue, + sw_lat: bounds.minLat, + sw_lng: bounds.minLng, + ne_lat: bounds.maxLat, + ne_lng: bounds.maxLng + }) + ]) + + console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area') + + if (points.length === 0 && visits.length === 0) { + Toast.info('No data found in selected area') + this.cancelAreaSelection() + return + } + + // Convert points to GeoJSON and display + if (points.length > 0) { + const geojson = pointsToGeoJSON(points) + this.selectedPointsLayer.updateSelectedPoints(geojson) + this.selectedPointsLayer.show() + } + + // Display visits in side panel and on map + if (visits.length > 0) { + this.displaySelectedVisits(visits) + } + + // Update UI - show action buttons + if (this.controller.hasSelectionActionsTarget) { + this.controller.selectionActionsTarget.classList.remove('hidden') + } + + // Update delete button text with count + if (this.controller.hasDeleteButtonTextTarget) { + this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}` + } + + // Disable selection mode + this.selectionLayer.disableSelectionMode() + + const messages = [] + if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`) + if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`) + + Toast.success(`Selected ${messages.join(' and ')}`) + } catch (error) { + console.error('[Maps V2] Failed to fetch data in area:', error) + Toast.error('Failed to fetch data in selected area') + this.cancelAreaSelection() + } + } + + /** + * Display selected visits in side panel + */ + displaySelectedVisits(visits) { + if (!this.controller.hasSelectedVisitsContainerTarget) return + + this.selectedVisits = visits + this.selectedVisitIds = new Set() + + const cardsHTML = visits.map(visit => + VisitCard.create(visit, { isSelected: false }) + ).join('') + + this.controller.selectedVisitsContainerTarget.innerHTML = ` +
+
+ + + + +

Visits in Area (${visits.length})

+
+ ${cardsHTML} +
+ ` + + this.controller.selectedVisitsContainerTarget.classList.remove('hidden') + this.attachVisitCardListeners() + + requestAnimationFrame(() => { + this.updateBulkActions() + }) + } + + /** + * Attach event listeners to visit cards + */ + attachVisitCardListeners() { + this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + const visitId = parseInt(e.target.dataset.visitSelect) + if (e.target.checked) { + this.selectedVisitIds.add(visitId) + } else { + this.selectedVisitIds.delete(visitId) + } + this.updateBulkActions() + }) + }) + + this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const visitId = parseInt(e.currentTarget.dataset.visitConfirm) + await this.confirmVisit(visitId) + }) + }) + + this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const visitId = parseInt(e.currentTarget.dataset.visitDecline) + await this.declineVisit(visitId) + }) + }) + } + + /** + * Update bulk action buttons visibility and attach listeners + */ + updateBulkActions() { + const selectedCount = this.selectedVisitIds.size + + const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline') + existingBulkActions.forEach(el => el.remove()) + + if (selectedCount >= 2) { + const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card')) + .filter(card => { + const visitId = parseInt(card.dataset.visitId) + return this.selectedVisitIds.has(visitId) + }) + + if (selectedVisitCards.length > 0) { + const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1] + + const bulkActionsDiv = document.createElement('div') + bulkActionsDiv.className = 'bulk-actions-inline mb-2' + bulkActionsDiv.innerHTML = ` +
+
+ + + + ${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected +
+
+ + + +
+
+ ` + + lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv) + + const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]') + const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]') + const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]') + + if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits()) + if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits()) + if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits()) + } + } + } + + /** + * Confirm a single visit + */ + async confirmVisit(visitId) { + try { + await this.api.updateVisitStatus(visitId, 'confirmed') + Toast.success('Visit confirmed') + await this.refreshSelectedVisits() + } catch (error) { + console.error('[Maps V2] Failed to confirm visit:', error) + Toast.error('Failed to confirm visit') + } + } + + /** + * Decline a single visit + */ + async declineVisit(visitId) { + try { + await this.api.updateVisitStatus(visitId, 'declined') + Toast.success('Visit declined') + await this.refreshSelectedVisits() + } catch (error) { + console.error('[Maps V2] Failed to decline visit:', error) + Toast.error('Failed to decline visit') + } + } + + /** + * Bulk merge selected visits + */ + async bulkMergeVisits() { + const visitIds = Array.from(this.selectedVisitIds) + + if (visitIds.length < 2) { + Toast.error('Select at least 2 visits to merge') + return + } + + if (!confirm(`Merge ${visitIds.length} visits into one?`)) { + return + } + + try { + Toast.info('Merging visits...') + const mergedVisit = await this.api.mergeVisits(visitIds) + Toast.success('Visits merged successfully') + + this.selectedVisitIds.clear() + this.replaceVisitsWithMerged(visitIds, mergedVisit) + this.updateBulkActions() + } catch (error) { + console.error('[Maps V2] Failed to merge visits:', error) + Toast.error('Failed to merge visits') + } + } + + /** + * Bulk confirm selected visits + */ + async bulkConfirmVisits() { + const visitIds = Array.from(this.selectedVisitIds) + + try { + Toast.info('Confirming visits...') + await this.api.bulkUpdateVisits(visitIds, 'confirmed') + Toast.success(`Confirmed ${visitIds.length} visits`) + + this.selectedVisitIds.clear() + await this.refreshSelectedVisits() + } catch (error) { + console.error('[Maps V2] Failed to confirm visits:', error) + Toast.error('Failed to confirm visits') + } + } + + /** + * Bulk decline selected visits + */ + async bulkDeclineVisits() { + const visitIds = Array.from(this.selectedVisitIds) + + if (!confirm(`Decline ${visitIds.length} visits?`)) { + return + } + + try { + Toast.info('Declining visits...') + await this.api.bulkUpdateVisits(visitIds, 'declined') + Toast.success(`Declined ${visitIds.length} visits`) + + this.selectedVisitIds.clear() + await this.refreshSelectedVisits() + } catch (error) { + console.error('[Maps V2] Failed to decline visits:', error) + Toast.error('Failed to decline visits') + } + } + + /** + * Replace merged visit cards with the new merged visit + */ + replaceVisitsWithMerged(oldVisitIds, mergedVisit) { + const container = this.controller.element.querySelector('.selected-visits-list') + if (!container) return + + const mergedStartTime = new Date(mergedVisit.started_at).getTime() + const allCards = Array.from(container.querySelectorAll('.visit-card')) + + let insertBeforeCard = null + for (const card of allCards) { + const cardId = parseInt(card.dataset.visitId) + if (oldVisitIds.includes(cardId)) continue + + const cardVisit = this.selectedVisits.find(v => v.id === cardId) + if (cardVisit) { + const cardStartTime = new Date(cardVisit.started_at).getTime() + if (cardStartTime > mergedStartTime) { + insertBeforeCard = card + break + } + } + } + + oldVisitIds.forEach(id => { + const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`) + if (card) card.remove() + }) + + this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id)) + this.selectedVisits.push(mergedVisit) + this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at)) + + const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false }) + + if (insertBeforeCard) { + insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML) + } else { + container.insertAdjacentHTML('beforeend', newCardHTML) + } + + const header = container.querySelector('h3') + if (header) { + header.textContent = `Visits in Area (${this.selectedVisits.length})` + } + + this.attachVisitCardListeners() + } + + /** + * Refresh selected visits after changes + */ + async refreshSelectedVisits() { + const bounds = this.selectionLayer.currentRect + if (!bounds) return + + try { + const visits = await this.api.fetchVisitsInArea({ + start_at: this.controller.startDateValue, + end_at: this.controller.endDateValue, + sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat, + sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng, + ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat, + ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng + }) + + this.displaySelectedVisits(visits) + } catch (error) { + console.error('[Maps V2] Failed to refresh visits:', error) + } + } + + /** + * Cancel area selection + */ + cancelAreaSelection() { + console.log('[Maps V2] Cancelling area selection') + + if (this.selectionLayer) { + this.selectionLayer.disableSelectionMode() + this.selectionLayer.clearSelection() + } + + if (this.selectedPointsLayer) { + this.selectedPointsLayer.clearSelection() + } + + if (this.controller.hasSelectedVisitsContainerTarget) { + this.controller.selectedVisitsContainerTarget.classList.add('hidden') + this.controller.selectedVisitsContainerTarget.innerHTML = '' + } + + if (this.controller.hasSelectedVisitsBulkActionsTarget) { + this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden') + } + + this.selectedVisits = [] + this.selectedVisitIds = new Set() + + if (this.controller.hasSelectAreaButtonTarget) { + this.controller.selectAreaButtonTarget.innerHTML = ` + + + + + + + + Select Area + ` + this.controller.selectAreaButtonTarget.classList.remove('btn-error') + this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline') + this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea' + } + + if (this.controller.hasSelectionActionsTarget) { + this.controller.selectionActionsTarget.classList.add('hidden') + } + + Toast.info('Selection cancelled') + } + + /** + * Delete selected points + */ + async deleteSelectedPoints() { + const pointCount = this.selectedPointsLayer.getCount() + const pointIds = this.selectedPointsLayer.getSelectedPointIds() + + if (pointIds.length === 0) { + Toast.error('No points selected') + return + } + + const confirmed = confirm( + `Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.` + ) + + if (!confirmed) return + + console.log('[Maps V2] Deleting', pointIds.length, 'points') + + try { + Toast.info('Deleting points...') + const result = await this.api.bulkDeletePoints(pointIds) + + console.log('[Maps V2] Deleted', result.count, 'points') + + this.cancelAreaSelection() + + await this.controller.loadMapData({ + showLoading: false, + fitBounds: false, + showToast: false + }) + + Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`) + } catch (error) { + console.error('[Maps V2] Failed to delete points:', error) + Toast.error('Failed to delete points. Please try again.') + } + } +} diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js new file mode 100644 index 00000000..8b583cc0 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/data_loader.js @@ -0,0 +1,225 @@ +import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers' +import { RoutesLayer } from 'maps_maplibre/layers/routes_layer' +import { createCircle } from 'maps_maplibre/utils/geometry' +import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor' + +/** + * Handles loading and transforming data from API + */ +export class DataLoader { + constructor(api, apiKey) { + this.api = api + this.apiKey = apiKey + } + + /** + * Fetch all map data (points, visits, photos, areas, tracks) + */ + async fetchMapData(startDate, endDate, onProgress) { + const data = {} + + // Fetch points + performanceMonitor.mark('fetch-points') + data.points = await this.api.fetchAllPoints({ + start_at: startDate, + end_at: endDate, + onProgress: onProgress + }) + performanceMonitor.measure('fetch-points') + + // Transform points to GeoJSON + performanceMonitor.mark('transform-geojson') + data.pointsGeoJSON = pointsToGeoJSON(data.points) + data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points) + performanceMonitor.measure('transform-geojson') + + // Fetch visits + try { + data.visits = await this.api.fetchVisits({ + start_at: startDate, + end_at: endDate + }) + } catch (error) { + console.warn('Failed to fetch visits:', error) + data.visits = [] + } + data.visitsGeoJSON = this.visitsToGeoJSON(data.visits) + + // Fetch photos + try { + console.log('[Photos] Fetching photos from:', startDate, 'to', endDate) + data.photos = await this.api.fetchPhotos({ + start_at: startDate, + end_at: endDate + }) + console.log('[Photos] Fetched photos:', data.photos.length, 'photos') + console.log('[Photos] Sample photo:', data.photos[0]) + } catch (error) { + console.error('[Photos] Failed to fetch photos:', error) + data.photos = [] + } + data.photosGeoJSON = this.photosToGeoJSON(data.photos) + console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features') + console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0]) + + // Fetch areas + try { + data.areas = await this.api.fetchAreas() + } catch (error) { + console.warn('Failed to fetch areas:', error) + data.areas = [] + } + data.areasGeoJSON = this.areasToGeoJSON(data.areas) + + // Fetch places (no date filtering) + try { + data.places = await this.api.fetchPlaces() + } catch (error) { + console.warn('Failed to fetch places:', error) + data.places = [] + } + data.placesGeoJSON = this.placesToGeoJSON(data.places) + + // Tracks - DISABLED: Backend API not yet implemented + // TODO: Re-enable when /api/v1/tracks endpoint is created + data.tracks = [] + data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks) + + return data + } + + /** + * Convert visits to GeoJSON + */ + visitsToGeoJSON(visits) { + return { + type: 'FeatureCollection', + features: visits.map(visit => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [visit.place.longitude, visit.place.latitude] + }, + properties: { + id: visit.id, + name: visit.name, + place_name: visit.place?.name, + status: visit.status, + started_at: visit.started_at, + ended_at: visit.ended_at, + duration: visit.duration + } + })) + } + } + + /** + * Convert photos to GeoJSON + */ + photosToGeoJSON(photos) { + return { + type: 'FeatureCollection', + features: photos.map(photo => { + // Construct thumbnail URL + const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}` + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [photo.longitude, photo.latitude] + }, + properties: { + id: photo.id, + thumbnail_url: thumbnailUrl, + taken_at: photo.localDateTime, + filename: photo.originalFileName, + city: photo.city, + state: photo.state, + country: photo.country, + type: photo.type, + source: photo.source + } + } + }) + } + } + + /** + * Convert places to GeoJSON + */ + placesToGeoJSON(places) { + return { + type: 'FeatureCollection', + features: places.map(place => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [place.longitude, place.latitude] + }, + properties: { + id: place.id, + name: place.name, + latitude: place.latitude, + longitude: place.longitude, + note: place.note, + // Stringify tags for MapLibre GL JS compatibility + tags: JSON.stringify(place.tags || []), + // Use first tag's color if available + color: place.tags?.[0]?.color || '#6366f1' + } + })) + } + } + + /** + * Convert areas to GeoJSON + * Backend returns circular areas with latitude, longitude, radius + */ + areasToGeoJSON(areas) { + return { + type: 'FeatureCollection', + features: areas.map(area => { + // Create circle polygon from center and radius + // Parse as floats since API returns strings + const center = [parseFloat(area.longitude), parseFloat(area.latitude)] + const coordinates = createCircle(center, area.radius) + + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [coordinates] + }, + properties: { + id: area.id, + name: area.name, + color: area.color || '#ef4444', + radius: area.radius + } + } + }) + } + } + + /** + * Convert tracks to GeoJSON + */ + tracksToGeoJSON(tracks) { + return { + type: 'FeatureCollection', + features: tracks.map(track => ({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: track.coordinates + }, + properties: { + id: track.id, + name: track.name, + color: track.color || '#8b5cf6' + } + })) + } + } +} diff --git a/app/javascript/controllers/maps/maplibre/date_manager.js b/app/javascript/controllers/maps/maplibre/date_manager.js new file mode 100644 index 00000000..2aac97b8 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/date_manager.js @@ -0,0 +1,35 @@ +/** + * Manages date formatting and range calculations + */ +export class DateManager { + /** + * Format date for API requests (matching V1 format) + * Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59") + */ + static formatDateForAPI(date) { + const pad = (n) => String(n).padStart(2, '0') + const year = date.getFullYear() + const month = pad(date.getMonth() + 1) + const day = pad(date.getDate()) + const hours = pad(date.getHours()) + const minutes = pad(date.getMinutes()) + + return `${year}-${month}-${day}T${hours}:${minutes}` + } + + /** + * Parse month selector value to date range + */ + static parseMonthSelector(value) { + const [year, month] = value.split('-') + + const startDate = new Date(year, month - 1, 1, 0, 0, 0) + const lastDay = new Date(year, month, 0).getDate() + const endDate = new Date(year, month - 1, lastDay, 23, 59, 0) + + return { + startDate: this.formatDateForAPI(startDate), + endDate: this.formatDateForAPI(endDate) + } + } +} diff --git a/app/javascript/controllers/maps/maplibre/event_handlers.js b/app/javascript/controllers/maps/maplibre/event_handlers.js new file mode 100644 index 00000000..812635d6 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/event_handlers.js @@ -0,0 +1,129 @@ +import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers' + +/** + * Handles map interaction events (clicks, info display) + */ +export class EventHandlers { + constructor(map, controller) { + this.map = map + this.controller = controller + } + + /** + * Handle point click + */ + handlePointClick(e) { + const feature = e.features[0] + const properties = feature.properties + + const content = ` +
+
Time: ${formatTimestamp(properties.timestamp)}
+ ${properties.battery ? `
Battery: ${properties.battery}%
` : ''} + ${properties.altitude ? `
Altitude: ${Math.round(properties.altitude)}m
` : ''} + ${properties.velocity ? `
Speed: ${Math.round(properties.velocity)} km/h
` : ''} +
+ ` + + this.controller.showInfo('Location Point', content) + } + + /** + * Handle visit click + */ + handleVisitClick(e) { + const feature = e.features[0] + const properties = feature.properties + + const startTime = formatTimestamp(properties.started_at) + const endTime = formatTimestamp(properties.ended_at) + const durationHours = Math.round(properties.duration / 3600) + const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m` + + const content = ` +
+
${properties.status}
+
Arrived: ${startTime}
+
Left: ${endTime}
+
Duration: ${durationDisplay}
+
+ ` + + const actions = [{ + type: 'button', + handler: 'handleEdit', + id: properties.id, + entityType: 'visit', + label: 'Edit' + }] + + this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions) + } + + /** + * Handle photo click + */ + handlePhotoClick(e) { + const feature = e.features[0] + const properties = feature.properties + + const content = ` +
+ ${properties.photo_url ? `Photo` : ''} + ${properties.taken_at ? `
Taken: ${formatTimestamp(properties.taken_at)}
` : ''} +
+ ` + + this.controller.showInfo('Photo', content) + } + + /** + * Handle place click + */ + handlePlaceClick(e) { + const feature = e.features[0] + const properties = feature.properties + + const content = ` +
+ ${properties.tag ? `
${properties.tag}
` : ''} + ${properties.description ? `
${properties.description}
` : ''} +
+ ` + + const actions = properties.id ? [{ + type: 'button', + handler: 'handleEdit', + id: properties.id, + entityType: 'place', + label: 'Edit' + }] : [] + + this.controller.showInfo(properties.name || 'Place', content, actions) + } + + /** + * Handle area click + */ + handleAreaClick(e) { + const feature = e.features[0] + const properties = feature.properties + + const content = ` +
+ ${properties.radius ? `
Radius: ${Math.round(properties.radius)}m
` : ''} + ${properties.latitude && properties.longitude ? `
Center: ${properties.latitude.toFixed(6)}, ${properties.longitude.toFixed(6)}
` : ''} +
+ ` + + const actions = properties.id ? [{ + type: 'button', + handler: 'handleDelete', + id: properties.id, + entityType: 'area', + label: 'Delete' + }] : [] + + this.controller.showInfo(properties.name || 'Area', content, actions) + } +} diff --git a/app/javascript/controllers/maps/maplibre/filter_manager.js b/app/javascript/controllers/maps/maplibre/filter_manager.js new file mode 100644 index 00000000..70e9afc3 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/filter_manager.js @@ -0,0 +1,53 @@ +/** + * Manages filtering and searching of map data + */ +export class FilterManager { + constructor(dataLoader) { + this.dataLoader = dataLoader + this.currentVisitFilter = 'all' + this.allVisits = [] + } + + /** + * Store all visits for filtering + */ + setAllVisits(visits) { + this.allVisits = visits + } + + /** + * Filter and update visits display + */ + filterAndUpdateVisits(searchTerm, statusFilter, visitsLayer) { + if (!this.allVisits || !visitsLayer) return + + const filtered = this.allVisits.filter(visit => { + // Apply search + const matchesSearch = !searchTerm || + visit.name?.toLowerCase().includes(searchTerm) || + visit.place?.name?.toLowerCase().includes(searchTerm) + + // Apply status filter + const matchesStatus = statusFilter === 'all' || visit.status === statusFilter + + return matchesSearch && matchesStatus + }) + + const geojson = this.dataLoader.visitsToGeoJSON(filtered) + visitsLayer.update(geojson) + } + + /** + * Get current visit filter + */ + getCurrentVisitFilter() { + return this.currentVisitFilter + } + + /** + * Set current visit filter + */ + setCurrentVisitFilter(filter) { + this.currentVisitFilter = filter + } +} diff --git a/app/javascript/controllers/maps/maplibre/layer_manager.js b/app/javascript/controllers/maps/maplibre/layer_manager.js new file mode 100644 index 00000000..36105e95 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/layer_manager.js @@ -0,0 +1,279 @@ +import { PointsLayer } from 'maps_maplibre/layers/points_layer' +import { RoutesLayer } from 'maps_maplibre/layers/routes_layer' +import { HeatmapLayer } from 'maps_maplibre/layers/heatmap_layer' +import { VisitsLayer } from 'maps_maplibre/layers/visits_layer' +import { PhotosLayer } from 'maps_maplibre/layers/photos_layer' +import { AreasLayer } from 'maps_maplibre/layers/areas_layer' +import { TracksLayer } from 'maps_maplibre/layers/tracks_layer' +import { PlacesLayer } from 'maps_maplibre/layers/places_layer' +import { FogLayer } from 'maps_maplibre/layers/fog_layer' +import { FamilyLayer } from 'maps_maplibre/layers/family_layer' +import { RecentPointLayer } from 'maps_maplibre/layers/recent_point_layer' +import { lazyLoader } from 'maps_maplibre/utils/lazy_loader' +import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor' + +/** + * Manages all map layers lifecycle and visibility + */ +export class LayerManager { + constructor(map, settings, api) { + this.map = map + this.settings = settings + this.api = api + this.layers = {} + } + + /** + * Add or update all layers with provided data + */ + async addAllLayers(pointsGeoJSON, routesGeoJSON, visitsGeoJSON, photosGeoJSON, areasGeoJSON, tracksGeoJSON, placesGeoJSON) { + performanceMonitor.mark('add-layers') + + // Layer order matters - layers added first render below layers added later + // Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points -> recent-point (top) -> fog (canvas overlay) + + await this._addScratchLayer(pointsGeoJSON) + this._addHeatmapLayer(pointsGeoJSON) + this._addAreasLayer(areasGeoJSON) + this._addTracksLayer(tracksGeoJSON) + this._addRoutesLayer(routesGeoJSON) + this._addVisitsLayer(visitsGeoJSON) + this._addPlacesLayer(placesGeoJSON) + + // Add photos layer with error handling (async, might fail loading images) + try { + await this._addPhotosLayer(photosGeoJSON) + } catch (error) { + console.warn('Failed to add photos layer:', error) + } + + this._addFamilyLayer() + this._addPointsLayer(pointsGeoJSON) + this._addRecentPointLayer() + this._addFogLayer(pointsGeoJSON) + + performanceMonitor.measure('add-layers') + } + + /** + * Setup event handlers for layer interactions + */ + setupLayerEventHandlers(handlers) { + // Click handlers + this.map.on('click', 'points', handlers.handlePointClick) + this.map.on('click', 'visits', handlers.handleVisitClick) + this.map.on('click', 'photos', handlers.handlePhotoClick) + this.map.on('click', 'places', handlers.handlePlaceClick) + // Areas have multiple layers (fill, outline, labels) + this.map.on('click', 'areas-fill', handlers.handleAreaClick) + this.map.on('click', 'areas-outline', handlers.handleAreaClick) + this.map.on('click', 'areas-labels', handlers.handleAreaClick) + + // Cursor change on hover + this.map.on('mouseenter', 'points', () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', 'points', () => { + this.map.getCanvas().style.cursor = '' + }) + this.map.on('mouseenter', 'visits', () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', 'visits', () => { + this.map.getCanvas().style.cursor = '' + }) + this.map.on('mouseenter', 'photos', () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', 'photos', () => { + this.map.getCanvas().style.cursor = '' + }) + this.map.on('mouseenter', 'places', () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', 'places', () => { + this.map.getCanvas().style.cursor = '' + }) + // Areas hover handlers for all sub-layers + const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels'] + areaLayers.forEach(layerId => { + // Only add handlers if layer exists + if (this.map.getLayer(layerId)) { + this.map.on('mouseenter', layerId, () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', layerId, () => { + this.map.getCanvas().style.cursor = '' + }) + } + }) + } + + /** + * Toggle layer visibility + */ + toggleLayer(layerName) { + const layer = this.layers[`${layerName}Layer`] + if (!layer) return null + + layer.toggle() + return layer.visible + } + + /** + * Get layer instance + */ + getLayer(layerName) { + return this.layers[`${layerName}Layer`] + } + + /** + * Clear all layer references (for style changes) + */ + clearLayerReferences() { + this.layers = {} + } + + // Private methods for individual layer management + + async _addScratchLayer(pointsGeoJSON) { + try { + if (!this.layers.scratchLayer && this.settings.scratchEnabled) { + const ScratchLayer = await lazyLoader.loadLayer('scratch') + this.layers.scratchLayer = new ScratchLayer(this.map, { + visible: true, + apiClient: this.api + }) + await this.layers.scratchLayer.add(pointsGeoJSON) + } else if (this.layers.scratchLayer) { + await this.layers.scratchLayer.update(pointsGeoJSON) + } + } catch (error) { + console.warn('Failed to load scratch layer:', error) + } + } + + _addHeatmapLayer(pointsGeoJSON) { + if (!this.layers.heatmapLayer) { + this.layers.heatmapLayer = new HeatmapLayer(this.map, { + visible: this.settings.heatmapEnabled + }) + this.layers.heatmapLayer.add(pointsGeoJSON) + } else { + this.layers.heatmapLayer.update(pointsGeoJSON) + } + } + + _addAreasLayer(areasGeoJSON) { + if (!this.layers.areasLayer) { + this.layers.areasLayer = new AreasLayer(this.map, { + visible: this.settings.areasEnabled || false + }) + this.layers.areasLayer.add(areasGeoJSON) + } else { + this.layers.areasLayer.update(areasGeoJSON) + } + } + + _addTracksLayer(tracksGeoJSON) { + if (!this.layers.tracksLayer) { + this.layers.tracksLayer = new TracksLayer(this.map, { + visible: this.settings.tracksEnabled || false + }) + this.layers.tracksLayer.add(tracksGeoJSON) + } else { + this.layers.tracksLayer.update(tracksGeoJSON) + } + } + + _addRoutesLayer(routesGeoJSON) { + if (!this.layers.routesLayer) { + this.layers.routesLayer = new RoutesLayer(this.map, { + visible: this.settings.routesVisible !== false // Default true unless explicitly false + }) + this.layers.routesLayer.add(routesGeoJSON) + } else { + this.layers.routesLayer.update(routesGeoJSON) + } + } + + _addVisitsLayer(visitsGeoJSON) { + if (!this.layers.visitsLayer) { + this.layers.visitsLayer = new VisitsLayer(this.map, { + visible: this.settings.visitsEnabled || false + }) + this.layers.visitsLayer.add(visitsGeoJSON) + } else { + this.layers.visitsLayer.update(visitsGeoJSON) + } + } + + _addPlacesLayer(placesGeoJSON) { + if (!this.layers.placesLayer) { + this.layers.placesLayer = new PlacesLayer(this.map, { + visible: this.settings.placesEnabled || false + }) + this.layers.placesLayer.add(placesGeoJSON) + } else { + this.layers.placesLayer.update(placesGeoJSON) + } + } + + async _addPhotosLayer(photosGeoJSON) { + console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled) + if (!this.layers.photosLayer) { + this.layers.photosLayer = new PhotosLayer(this.map, { + visible: this.settings.photosEnabled || false + }) + console.log('[Photos] Created new PhotosLayer instance') + await this.layers.photosLayer.add(photosGeoJSON) + console.log('[Photos] Added photos to layer') + } else { + console.log('[Photos] Updating existing PhotosLayer') + await this.layers.photosLayer.update(photosGeoJSON) + console.log('[Photos] Updated photos layer') + } + } + + _addFamilyLayer() { + if (!this.layers.familyLayer) { + this.layers.familyLayer = new FamilyLayer(this.map, { + visible: false // Initially hidden, shown when family locations arrive via ActionCable + }) + this.layers.familyLayer.add({ type: 'FeatureCollection', features: [] }) + } + } + + _addPointsLayer(pointsGeoJSON) { + if (!this.layers.pointsLayer) { + this.layers.pointsLayer = new PointsLayer(this.map, { + visible: this.settings.pointsVisible !== false // Default true unless explicitly false + }) + this.layers.pointsLayer.add(pointsGeoJSON) + } else { + this.layers.pointsLayer.update(pointsGeoJSON) + } + } + + _addRecentPointLayer() { + if (!this.layers.recentPointLayer) { + this.layers.recentPointLayer = new RecentPointLayer(this.map, { + visible: false // Initially hidden, shown only when live mode is enabled + }) + this.layers.recentPointLayer.add({ type: 'FeatureCollection', features: [] }) + } + } + + _addFogLayer(pointsGeoJSON) { + // Always create fog layer for backward compatibility + if (!this.layers.fogLayer) { + this.layers.fogLayer = new FogLayer(this.map, { + clearRadius: 1000, + visible: this.settings.fogEnabled || false + }) + this.layers.fogLayer.add(pointsGeoJSON) + } else { + this.layers.fogLayer.update(pointsGeoJSON) + } + } +} diff --git a/app/javascript/controllers/maps/maplibre/map_data_manager.js b/app/javascript/controllers/maps/maplibre/map_data_manager.js new file mode 100644 index 00000000..88d13462 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/map_data_manager.js @@ -0,0 +1,131 @@ +import maplibregl from 'maplibre-gl' +import { Toast } from 'maps_maplibre/components/toast' +import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor' + +/** + * Manages data loading and layer setup for the map + */ +export class MapDataManager { + constructor(controller) { + this.controller = controller + this.map = controller.map + this.dataLoader = controller.dataLoader + this.layerManager = controller.layerManager + this.filterManager = controller.filterManager + this.eventHandlers = controller.eventHandlers + } + + /** + * Load map data from API and setup layers + * @param {string} startDate - Start date for data range + * @param {string} endDate - End date for data range + * @param {Object} options - Loading options + */ + async loadMapData(startDate, endDate, options = {}) { + const { + showLoading = true, + fitBounds = true, + showToast = true, + onProgress = null + } = options + + performanceMonitor.mark('load-map-data') + + if (showLoading) { + this.controller.showLoading() + } + + try { + // Fetch data from API + const data = await this.dataLoader.fetchMapData( + startDate, + endDate, + showLoading ? onProgress : null + ) + + // Store visits for filtering + this.filterManager.setAllVisits(data.visits) + + // Setup layers + await this._setupLayers(data) + + // Fit bounds if requested + if (fitBounds && data.points.length > 0) { + this._fitMapToBounds(data.pointsGeoJSON) + } + + // Show success message + if (showToast) { + const pointText = data.points.length === 1 ? 'point' : 'points' + Toast.success(`Loaded ${data.points.length} location ${pointText}`) + } + + return data + } catch (error) { + console.error('[MapDataManager] Failed to load map data:', error) + Toast.error('Failed to load location data. Please try again.') + throw error + } finally { + if (showLoading) { + this.controller.hideLoading() + } + const duration = performanceMonitor.measure('load-map-data') + console.log(`[Performance] Map data loaded in ${duration}ms`) + } + } + + /** + * Setup all map layers with loaded data + * @private + */ + async _setupLayers(data) { + const addAllLayers = async () => { + await this.layerManager.addAllLayers( + data.pointsGeoJSON, + data.routesGeoJSON, + data.visitsGeoJSON, + data.photosGeoJSON, + data.areasGeoJSON, + data.tracksGeoJSON, + data.placesGeoJSON + ) + + this.layerManager.setupLayerEventHandlers({ + handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers), + handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers), + handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers), + handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers), + handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers) + }) + } + + if (this.map.loaded()) { + await addAllLayers() + } else { + this.map.once('load', async () => { + await addAllLayers() + }) + } + } + + /** + * Fit map to data bounds + * @private + */ + _fitMapToBounds(geojson) { + if (!geojson?.features?.length) { + return + } + + const coordinates = geojson.features.map(f => f.geometry.coordinates) + + const bounds = coordinates.reduce((bounds, coord) => { + return bounds.extend(coord) + }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) + + this.map.fitBounds(bounds, { + padding: 50, + maxZoom: 15 + }) + } +} diff --git a/app/javascript/controllers/maps/maplibre/map_initializer.js b/app/javascript/controllers/maps/maplibre/map_initializer.js new file mode 100644 index 00000000..b253135e --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/map_initializer.js @@ -0,0 +1,66 @@ +import maplibregl from 'maplibre-gl' +import { getMapStyle } from 'maps_maplibre/utils/style_manager' + +/** + * Handles map initialization for Maps V2 + */ +export class MapInitializer { + /** + * Initialize MapLibre map instance + * @param {HTMLElement} container - The container element for the map + * @param {Object} settings - Map settings (style, center, zoom) + * @returns {Promise} The initialized map instance + */ + static async initialize(container, settings = {}) { + const { + mapStyle = 'streets', + center = [0, 0], + zoom = 2, + showControls = true + } = settings + + const style = await getMapStyle(mapStyle) + + const map = new maplibregl.Map({ + container, + style, + center, + zoom + }) + + if (showControls) { + map.addControl(new maplibregl.NavigationControl(), 'top-right') + } + + return map + } + + /** + * Fit map to bounds of GeoJSON features + * @param {maplibregl.Map} map - The map instance + * @param {Object} geojson - GeoJSON FeatureCollection + * @param {Object} options - Fit bounds options + */ + static fitToBounds(map, geojson, options = {}) { + const { + padding = 50, + maxZoom = 15 + } = options + + if (!geojson?.features?.length) { + console.warn('[MapInitializer] No features to fit bounds to') + return + } + + const coordinates = geojson.features.map(f => f.geometry.coordinates) + + const bounds = coordinates.reduce((bounds, coord) => { + return bounds.extend(coord) + }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) + + map.fitBounds(bounds, { + padding, + maxZoom + }) + } +} diff --git a/app/javascript/controllers/maps/maplibre/places_manager.js b/app/javascript/controllers/maps/maplibre/places_manager.js new file mode 100644 index 00000000..fd33c0a8 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/places_manager.js @@ -0,0 +1,281 @@ +import { SettingsManager } from 'maps_maplibre/utils/settings_manager' +import { Toast } from 'maps_maplibre/components/toast' + +/** + * Manages places-related operations for Maps V2 + * Including place creation, tag filtering, and layer management + */ +export class PlacesManager { + constructor(controller) { + this.controller = controller + this.layerManager = controller.layerManager + this.api = controller.api + this.dataLoader = controller.dataLoader + this.settings = controller.settings + } + + /** + * Toggle places layer + */ + togglePlaces(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('placesEnabled', enabled) + + const placesLayer = this.layerManager.getLayer('places') + if (placesLayer) { + if (enabled) { + placesLayer.show() + if (this.controller.hasPlacesFiltersTarget) { + this.controller.placesFiltersTarget.style.display = 'block' + } + this.initializePlaceTagFilters() + } else { + placesLayer.hide() + if (this.controller.hasPlacesFiltersTarget) { + this.controller.placesFiltersTarget.style.display = 'none' + } + } + } + } + + /** + * Initialize place tag filters (enable all by default or restore saved state) + */ + initializePlaceTagFilters() { + const savedFilters = this.settings.placesTagFilters + + if (savedFilters && savedFilters.length > 0) { + this.restoreSavedTagFilters(savedFilters) + } else { + this.enableAllTagsInitial() + } + } + + /** + * Restore saved tag filters + */ + restoreSavedTagFilters(savedFilters) { + const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') + + tagCheckboxes.forEach(checkbox => { + const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value) + const shouldBeChecked = savedFilters.includes(value) + + if (checkbox.checked !== shouldBeChecked) { + checkbox.checked = shouldBeChecked + + const badge = checkbox.nextElementSibling + const color = badge.style.borderColor + + if (shouldBeChecked) { + badge.classList.remove('badge-outline') + badge.style.backgroundColor = color + badge.style.color = 'white' + } else { + badge.classList.add('badge-outline') + badge.style.backgroundColor = 'transparent' + badge.style.color = color + } + } + }) + + this.syncEnableAllTagsToggle() + this.loadPlacesWithTags(savedFilters) + } + + /** + * Enable all tags initially + */ + enableAllTagsInitial() { + if (this.controller.hasEnableAllPlaceTagsToggleTarget) { + this.controller.enableAllPlaceTagsToggleTarget.checked = true + } + + const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') + const allTagIds = [] + + tagCheckboxes.forEach(checkbox => { + checkbox.checked = true + + const badge = checkbox.nextElementSibling + const color = badge.style.borderColor + badge.classList.remove('badge-outline') + badge.style.backgroundColor = color + badge.style.color = 'white' + + const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value) + allTagIds.push(value) + }) + + SettingsManager.updateSetting('placesTagFilters', allTagIds) + this.loadPlacesWithTags(allTagIds) + } + + /** + * Get selected place tag IDs + */ + getSelectedPlaceTags() { + return Array.from( + document.querySelectorAll('input[name="place_tag_ids[]"]:checked') + ).map(cb => { + const value = cb.value + return value === 'untagged' ? value : parseInt(value) + }) + } + + /** + * Filter places by selected tags + */ + filterPlacesByTags(event) { + const badge = event.target.nextElementSibling + const color = badge.style.borderColor + + if (event.target.checked) { + badge.classList.remove('badge-outline') + badge.style.backgroundColor = color + badge.style.color = 'white' + } else { + badge.classList.add('badge-outline') + badge.style.backgroundColor = 'transparent' + badge.style.color = color + } + + this.syncEnableAllTagsToggle() + + const checkedTags = this.getSelectedPlaceTags() + SettingsManager.updateSetting('placesTagFilters', checkedTags) + this.loadPlacesWithTags(checkedTags) + } + + /** + * Sync "Enable All Tags" toggle with individual tag states + */ + syncEnableAllTagsToggle() { + if (!this.controller.hasEnableAllPlaceTagsToggleTarget) return + + const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') + const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked) + + this.controller.enableAllPlaceTagsToggleTarget.checked = allChecked + } + + /** + * Load places filtered by tags + */ + async loadPlacesWithTags(tagIds = []) { + try { + let places = [] + + if (tagIds.length > 0) { + places = await this.api.fetchPlaces({ tag_ids: tagIds }) + } + + const placesGeoJSON = this.dataLoader.placesToGeoJSON(places) + + const placesLayer = this.layerManager.getLayer('places') + if (placesLayer) { + placesLayer.update(placesGeoJSON) + } + } catch (error) { + console.error('[Maps V2] Failed to load places:', error) + } + } + + /** + * Toggle all place tags on/off + */ + toggleAllPlaceTags(event) { + const enableAll = event.target.checked + const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]') + + tagCheckboxes.forEach(checkbox => { + if (checkbox.checked !== enableAll) { + checkbox.checked = enableAll + + const badge = checkbox.nextElementSibling + const color = badge.style.borderColor + + if (enableAll) { + badge.classList.remove('badge-outline') + badge.style.backgroundColor = color + badge.style.color = 'white' + } else { + badge.classList.add('badge-outline') + badge.style.backgroundColor = 'transparent' + badge.style.color = color + } + } + }) + + const selectedTags = this.getSelectedPlaceTags() + SettingsManager.updateSetting('placesTagFilters', selectedTags) + this.loadPlacesWithTags(selectedTags) + } + + /** + * Start create place mode + */ + startCreatePlace() { + console.log('[Maps V2] Starting create place mode') + + if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) { + this.controller.toggleSettings() + } + + this.controller.map.getCanvas().style.cursor = 'crosshair' + Toast.info('Click on the map to place a place') + + this.handleCreatePlaceClick = (e) => { + const { lng, lat } = e.lngLat + + document.dispatchEvent(new CustomEvent('place:create', { + detail: { latitude: lat, longitude: lng } + })) + + this.controller.map.getCanvas().style.cursor = '' + } + + this.controller.map.once('click', this.handleCreatePlaceClick) + } + + /** + * Handle place creation event - reload places and update layer + */ + async handlePlaceCreated(event) { + console.log('[Maps V2] Place created, reloading places...', event.detail) + + try { + const selectedTags = this.getSelectedPlaceTags() + + const places = await this.api.fetchPlaces({ + tag_ids: selectedTags + }) + + console.log('[Maps V2] Fetched places:', places.length) + + const placesGeoJSON = this.dataLoader.placesToGeoJSON(places) + + console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features') + + const placesLayer = this.layerManager.getLayer('places') + if (placesLayer) { + placesLayer.update(placesGeoJSON) + console.log('[Maps V2] Places layer updated successfully') + } else { + console.warn('[Maps V2] Places layer not found, cannot update') + } + } catch (error) { + console.error('[Maps V2] Failed to reload places:', error) + } + } + + /** + * Handle place update event - reload places and update layer + */ + async handlePlaceUpdated(event) { + console.log('[Maps V2] Place updated, reloading places...', event.detail) + + // Reuse the same logic as creation + await this.handlePlaceCreated(event) + } +} diff --git a/app/javascript/controllers/maps/maplibre/routes_manager.js b/app/javascript/controllers/maps/maplibre/routes_manager.js new file mode 100644 index 00000000..bf9fc76c --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/routes_manager.js @@ -0,0 +1,360 @@ +import { SettingsManager } from 'maps_maplibre/utils/settings_manager' +import { Toast } from 'maps_maplibre/components/toast' +import { lazyLoader } from 'maps_maplibre/utils/lazy_loader' + +/** + * Manages routes-related operations for Maps V2 + * Including speed-colored routes, route generation, and layer management + */ +export class RoutesManager { + constructor(controller) { + this.controller = controller + this.map = controller.map + this.layerManager = controller.layerManager + this.settings = controller.settings + } + + /** + * Toggle routes layer visibility + */ + toggleRoutes(event) { + const element = event.currentTarget + const visible = element.checked + + const routesLayer = this.layerManager.getLayer('routes') + if (routesLayer) { + routesLayer.toggle(visible) + } + + if (this.controller.hasRoutesOptionsTarget) { + this.controller.routesOptionsTarget.style.display = visible ? 'block' : 'none' + } + + SettingsManager.updateSetting('routesVisible', visible) + } + + /** + * Toggle speed-colored routes + */ + async toggleSpeedColoredRoutes(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled) + + if (this.controller.hasSpeedColorScaleContainerTarget) { + this.controller.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled) + } + + await this.reloadRoutes() + } + + /** + * Open speed color editor modal + */ + openSpeedColorEditor() { + const currentScale = this.controller.speedColorScaleInputTarget.value || + '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' + + let modal = document.getElementById('speed-color-editor-modal') + if (!modal) { + modal = this.createSpeedColorEditorModal(currentScale) + document.body.appendChild(modal) + } else { + const controller = this.controller.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor') + if (controller) { + controller.colorStopsValue = currentScale + controller.loadColorStops() + } + } + + const checkbox = modal.querySelector('.modal-toggle') + if (checkbox) { + checkbox.checked = true + } + } + + /** + * Create speed color editor modal element + */ + createSpeedColorEditorModal(currentScale) { + const modal = document.createElement('div') + modal.id = 'speed-color-editor-modal' + modal.setAttribute('data-controller', 'speed-color-editor') + modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale) + modal.setAttribute('data-action', 'speed-color-editor:save->maps--maplibre#handleSpeedColorSave') + + modal.innerHTML = ` + + + ` + + return modal + } + + /** + * Handle speed color save event from editor + */ + handleSpeedColorSave(event) { + const newScale = event.detail.colorStops + + this.controller.speedColorScaleInputTarget.value = newScale + SettingsManager.updateSetting('speedColorScale', newScale) + + if (this.controller.speedColoredToggleTarget.checked) { + this.reloadRoutes() + } + } + + /** + * Reload routes layer + */ + async reloadRoutes() { + this.controller.showLoading('Reloading routes...') + + try { + const pointsLayer = this.layerManager.getLayer('points') + const points = pointsLayer?.data?.features?.map(f => ({ + latitude: f.geometry.coordinates[1], + longitude: f.geometry.coordinates[0], + timestamp: f.properties.timestamp + })) || [] + + const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500 + const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60 + + const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors') + + const routesGeoJSON = await this.generateRoutesWithSpeedColors( + points, + { distanceThresholdMeters, timeThresholdMinutes }, + calculateSpeed, + getSpeedColor + ) + + this.layerManager.updateLayer('routes', routesGeoJSON) + + } catch (error) { + console.error('Failed to reload routes:', error) + Toast.error('Failed to reload routes') + } finally { + this.controller.hideLoading() + } + } + + /** + * Generate routes with speed coloring + */ + async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) { + const { RoutesLayer } = await import('maps_maplibre/layers/routes_layer') + const useSpeedColors = this.settings.speedColoredRoutesEnabled || false + const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' + + const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options) + + if (!useSpeedColors) { + return routesGeoJSON + } + + routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => { + const segment = points.slice( + points.findIndex(p => p.timestamp === feature.properties.startTime), + points.findIndex(p => p.timestamp === feature.properties.endTime) + 1 + ) + + if (segment.length >= 2) { + const speed = calculateSpeed(segment[0], segment[segment.length - 1]) + const color = getSpeedColor(speed, useSpeedColors, speedColorScale) + feature.properties.speed = speed + feature.properties.color = color + } + + return feature + }) + + return routesGeoJSON + } + + /** + * Toggle heatmap visibility + */ + toggleHeatmap(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('heatmapEnabled', enabled) + + const heatmapLayer = this.layerManager.getLayer('heatmap') + if (heatmapLayer) { + if (enabled) { + heatmapLayer.show() + } else { + heatmapLayer.hide() + } + } + } + + /** + * Toggle fog of war layer + */ + toggleFog(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('fogEnabled', enabled) + + const fogLayer = this.layerManager.getLayer('fog') + if (fogLayer) { + fogLayer.toggle(enabled) + } else { + console.warn('Fog layer not yet initialized') + } + } + + /** + * Toggle scratch map layer + */ + async toggleScratch(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('scratchEnabled', enabled) + + try { + const scratchLayer = this.layerManager.getLayer('scratch') + if (!scratchLayer && enabled) { + const ScratchLayer = await lazyLoader.loadLayer('scratch') + const newScratchLayer = new ScratchLayer(this.map, { + visible: true, + apiClient: this.controller.api + }) + const pointsLayer = this.layerManager.getLayer('points') + const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] } + await newScratchLayer.add(pointsData) + this.layerManager.layers.scratchLayer = newScratchLayer + } else if (scratchLayer) { + if (enabled) { + scratchLayer.show() + } else { + scratchLayer.hide() + } + } + } catch (error) { + console.error('Failed to toggle scratch layer:', error) + Toast.error('Failed to load scratch layer') + } + } + + /** + * Toggle photos layer + */ + togglePhotos(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('photosEnabled', enabled) + + const photosLayer = this.layerManager.getLayer('photos') + if (photosLayer) { + if (enabled) { + photosLayer.show() + } else { + photosLayer.hide() + } + } + } + + /** + * Toggle areas layer + */ + toggleAreas(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('areasEnabled', enabled) + + const areasLayer = this.layerManager.getLayer('areas') + if (areasLayer) { + if (enabled) { + areasLayer.show() + } else { + areasLayer.hide() + } + } + } + + /** + * Toggle tracks layer + */ + toggleTracks(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('tracksEnabled', enabled) + + const tracksLayer = this.layerManager.getLayer('tracks') + if (tracksLayer) { + if (enabled) { + tracksLayer.show() + } else { + tracksLayer.hide() + } + } + } + + /** + * Toggle points layer visibility + */ + togglePoints(event) { + const element = event.currentTarget + const visible = element.checked + + const pointsLayer = this.layerManager.getLayer('points') + if (pointsLayer) { + pointsLayer.toggle(visible) + } + + SettingsManager.updateSetting('pointsVisible', visible) + } +} diff --git a/app/javascript/controllers/maps/maplibre/settings_manager.js b/app/javascript/controllers/maps/maplibre/settings_manager.js new file mode 100644 index 00000000..d3b70b27 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/settings_manager.js @@ -0,0 +1,271 @@ +import { SettingsManager } from 'maps_maplibre/utils/settings_manager' +import { getMapStyle } from 'maps_maplibre/utils/style_manager' +import { Toast } from 'maps_maplibre/components/toast' + +/** + * Handles all settings-related operations for Maps V2 + * Including toggles, advanced settings, and UI synchronization + */ +export class SettingsController { + constructor(controller) { + this.controller = controller + this.settings = controller.settings + } + + // Lazy getters for properties that may not be initialized yet + get map() { + return this.controller.map + } + + get layerManager() { + return this.controller.layerManager + } + + /** + * Load settings (sync from backend and localStorage) + */ + async loadSettings() { + this.settings = await SettingsManager.sync() + this.controller.settings = this.settings + console.log('[Maps V2] Settings loaded:', this.settings) + return this.settings + } + + /** + * Sync UI controls with loaded settings + */ + syncToggleStates() { + const controller = this.controller + + // Sync layer toggles + const toggleMap = { + pointsToggle: 'pointsVisible', + routesToggle: 'routesVisible', + heatmapToggle: 'heatmapEnabled', + visitsToggle: 'visitsEnabled', + photosToggle: 'photosEnabled', + areasToggle: 'areasEnabled', + placesToggle: 'placesEnabled', + fogToggle: 'fogEnabled', + scratchToggle: 'scratchEnabled', + speedColoredToggle: 'speedColoredRoutesEnabled' + } + + Object.entries(toggleMap).forEach(([targetName, settingKey]) => { + const target = `${targetName}Target` + if (controller[target]) { + controller[target].checked = this.settings[settingKey] + } + }) + + // Show/hide visits search based on initial toggle state + if (controller.hasVisitsToggleTarget && controller.hasVisitsSearchTarget) { + controller.visitsSearchTarget.style.display = controller.visitsToggleTarget.checked ? 'block' : 'none' + } + + // Show/hide places filters based on initial toggle state + if (controller.hasPlacesToggleTarget && controller.hasPlacesFiltersTarget) { + controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none' + } + + // Sync route opacity slider + if (controller.hasRouteOpacityRangeTarget) { + controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100 + } + + // Sync map style dropdown + const mapStyleSelect = controller.element.querySelector('select[name="mapStyle"]') + if (mapStyleSelect) { + mapStyleSelect.value = this.settings.mapStyle || 'light' + } + + // Sync fog of war settings + const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]') + if (fogRadiusInput) { + fogRadiusInput.value = this.settings.fogOfWarRadius || 1000 + if (controller.hasFogRadiusValueTarget) { + controller.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m` + } + } + + const fogThresholdInput = controller.element.querySelector('input[name="fogOfWarThreshold"]') + if (fogThresholdInput) { + fogThresholdInput.value = this.settings.fogOfWarThreshold || 1 + if (controller.hasFogThresholdValueTarget) { + controller.fogThresholdValueTarget.textContent = fogThresholdInput.value + } + } + + // Sync route generation settings + const metersBetweenInput = controller.element.querySelector('input[name="metersBetweenRoutes"]') + if (metersBetweenInput) { + metersBetweenInput.value = this.settings.metersBetweenRoutes || 500 + if (controller.hasMetersBetweenValueTarget) { + controller.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m` + } + } + + const minutesBetweenInput = controller.element.querySelector('input[name="minutesBetweenRoutes"]') + if (minutesBetweenInput) { + minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60 + if (controller.hasMinutesBetweenValueTarget) { + controller.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min` + } + } + + // Sync speed-colored routes settings + if (controller.hasSpeedColorScaleInputTarget) { + const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' + controller.speedColorScaleInputTarget.value = colorScale + } + if (controller.hasSpeedColorScaleContainerTarget && controller.hasSpeedColoredToggleTarget) { + const isEnabled = controller.speedColoredToggleTarget.checked + controller.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled) + } + + // Sync points rendering mode radio buttons + const pointsRenderingRadios = controller.element.querySelectorAll('input[name="pointsRenderingMode"]') + pointsRenderingRadios.forEach(radio => { + radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw') + }) + + // Sync speed-colored routes toggle + const speedColoredRoutesToggle = controller.element.querySelector('input[name="speedColoredRoutes"]') + if (speedColoredRoutesToggle) { + speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false + } + + console.log('[Maps V2] UI controls synced with settings') + } + + /** + * Update map style from settings + */ + async updateMapStyle(event) { + const styleName = event.target.value + SettingsManager.updateSetting('mapStyle', styleName) + + const style = await getMapStyle(styleName) + + // Clear layer references + this.layerManager.clearLayerReferences() + + this.map.setStyle(style) + + // Reload layers after style change + this.map.once('style.load', () => { + console.log('Style loaded, reloading map data') + this.controller.loadMapData() + }) + } + + /** + * Reset settings to defaults + */ + resetSettings() { + if (confirm('Reset all settings to defaults? This will reload the page.')) { + SettingsManager.resetToDefaults() + window.location.reload() + } + } + + /** + * Update route opacity in real-time + */ + updateRouteOpacity(event) { + const opacity = parseInt(event.target.value) / 100 + + const routesLayer = this.layerManager.getLayer('routes') + if (routesLayer && this.map.getLayer('routes')) { + this.map.setPaintProperty('routes', 'line-opacity', opacity) + } + + SettingsManager.updateSetting('routeOpacity', opacity) + } + + /** + * Update advanced settings from form submission + */ + async updateAdvancedSettings(event) { + event.preventDefault() + + const formData = new FormData(event.target) + const settings = { + routeOpacity: parseFloat(formData.get('routeOpacity')) / 100, + fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')), + fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')), + metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')), + minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')), + pointsRenderingMode: formData.get('pointsRenderingMode'), + speedColoredRoutes: formData.get('speedColoredRoutes') === 'on' + } + + // Apply settings to current map + await this.applySettingsToMap(settings) + + // Save to backend and localStorage + for (const [key, value] of Object.entries(settings)) { + await SettingsManager.updateSetting(key, value) + } + + Toast.success('Settings updated successfully') + } + + /** + * Apply settings to map without reload + */ + async applySettingsToMap(settings) { + // Update route opacity + if (settings.routeOpacity !== undefined) { + const routesLayer = this.layerManager.getLayer('routes') + if (routesLayer && this.map.getLayer('routes')) { + this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity) + } + } + + // Update fog of war settings + if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) { + const fogLayer = this.layerManager.getLayer('fog') + if (fogLayer) { + if (settings.fogOfWarRadius) { + fogLayer.clearRadius = settings.fogOfWarRadius + } + // Redraw fog layer + if (fogLayer.visible) { + await fogLayer.update(fogLayer.data) + } + } + } + + // For settings that require data reload + if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) { + Toast.info('Reloading map data with new settings...') + await this.controller.loadMapData() + } + } + + // Display value update methods + updateFogRadiusDisplay(event) { + if (this.controller.hasFogRadiusValueTarget) { + this.controller.fogRadiusValueTarget.textContent = `${event.target.value}m` + } + } + + updateFogThresholdDisplay(event) { + if (this.controller.hasFogThresholdValueTarget) { + this.controller.fogThresholdValueTarget.textContent = event.target.value + } + } + + updateMetersBetweenDisplay(event) { + if (this.controller.hasMetersBetweenValueTarget) { + this.controller.metersBetweenValueTarget.textContent = `${event.target.value}m` + } + } + + updateMinutesBetweenDisplay(event) { + if (this.controller.hasMinutesBetweenValueTarget) { + this.controller.minutesBetweenValueTarget.textContent = `${event.target.value}min` + } + } +} diff --git a/app/javascript/controllers/maps/maplibre/visits_manager.js b/app/javascript/controllers/maps/maplibre/visits_manager.js new file mode 100644 index 00000000..82120584 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre/visits_manager.js @@ -0,0 +1,153 @@ +import { SettingsManager } from 'maps_maplibre/utils/settings_manager' +import { Toast } from 'maps_maplibre/components/toast' + +/** + * Manages visits-related operations for Maps V2 + * Including visit creation, filtering, and layer management + */ +export class VisitsManager { + constructor(controller) { + this.controller = controller + this.layerManager = controller.layerManager + this.filterManager = controller.filterManager + this.api = controller.api + this.dataLoader = controller.dataLoader + } + + /** + * Toggle visits layer + */ + toggleVisits(event) { + const enabled = event.target.checked + SettingsManager.updateSetting('visitsEnabled', enabled) + + const visitsLayer = this.layerManager.getLayer('visits') + if (visitsLayer) { + if (enabled) { + visitsLayer.show() + if (this.controller.hasVisitsSearchTarget) { + this.controller.visitsSearchTarget.style.display = 'block' + } + } else { + visitsLayer.hide() + if (this.controller.hasVisitsSearchTarget) { + this.controller.visitsSearchTarget.style.display = 'none' + } + } + } + } + + /** + * Search visits + */ + searchVisits(event) { + const searchTerm = event.target.value.toLowerCase() + const visitsLayer = this.layerManager.getLayer('visits') + this.filterManager.filterAndUpdateVisits( + searchTerm, + this.filterManager.getCurrentVisitFilter(), + visitsLayer + ) + } + + /** + * Filter visits by status + */ + filterVisits(event) { + const filter = event.target.value + this.filterManager.setCurrentVisitFilter(filter) + const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || '' + const visitsLayer = this.layerManager.getLayer('visits') + this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer) + } + + /** + * Start create visit mode + */ + startCreateVisit() { + console.log('[Maps V2] Starting create visit mode') + + if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) { + this.controller.toggleSettings() + } + + this.controller.map.getCanvas().style.cursor = 'crosshair' + Toast.info('Click on the map to place a visit') + + this.handleCreateVisitClick = (e) => { + const { lng, lat } = e.lngLat + this.openVisitCreationModal(lat, lng) + this.controller.map.getCanvas().style.cursor = '' + } + + this.controller.map.once('click', this.handleCreateVisitClick) + } + + /** + * Open visit creation modal + */ + openVisitCreationModal(lat, lng) { + console.log('[Maps V2] Opening visit creation modal', { lat, lng }) + + const modalElement = document.querySelector('[data-controller="visit-creation-v2"]') + + if (!modalElement) { + console.error('[Maps V2] Visit creation modal not found') + Toast.error('Visit creation modal not available') + return + } + + const controller = this.controller.application.getControllerForElementAndIdentifier( + modalElement, + 'visit-creation-v2' + ) + + if (controller) { + controller.open(lat, lng, this.controller) + } else { + console.error('[Maps V2] Visit creation controller not found') + Toast.error('Visit creation controller not available') + } + } + + /** + * Handle visit creation event - reload visits and update layer + */ + async handleVisitCreated(event) { + console.log('[Maps V2] Visit created, reloading visits...', event.detail) + + try { + const visits = await this.api.fetchVisits({ + start_at: this.controller.startDateValue, + end_at: this.controller.endDateValue + }) + + console.log('[Maps V2] Fetched visits:', visits.length) + + this.filterManager.setAllVisits(visits) + const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits) + + console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features') + + const visitsLayer = this.layerManager.getLayer('visits') + if (visitsLayer) { + visitsLayer.update(visitsGeoJSON) + console.log('[Maps V2] Visits layer updated successfully') + } else { + console.warn('[Maps V2] Visits layer not found, cannot update') + } + } catch (error) { + console.error('[Maps V2] Failed to reload visits:', error) + } + } + + /** + * Handle visit update event - reload visits and update layer + */ + async handleVisitUpdated(event) { + console.log('[Maps V2] Visit updated, reloading visits...', event.detail) + + // Reuse the same logic as creation + await this.handleVisitCreated(event) + } +} diff --git a/app/javascript/controllers/maps/maplibre_controller.js b/app/javascript/controllers/maps/maplibre_controller.js new file mode 100644 index 00000000..5e416623 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre_controller.js @@ -0,0 +1,543 @@ +import { Controller } from '@hotwired/stimulus' +import { ApiClient } from 'maps_maplibre/services/api_client' +import { SettingsManager } from 'maps_maplibre/utils/settings_manager' +import { SearchManager } from 'maps_maplibre/utils/search_manager' +import { Toast } from 'maps_maplibre/components/toast' +import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor' +import { CleanupHelper } from 'maps_maplibre/utils/cleanup_helper' +import { MapInitializer } from './maplibre/map_initializer' +import { MapDataManager } from './maplibre/map_data_manager' +import { LayerManager } from './maplibre/layer_manager' +import { DataLoader } from './maplibre/data_loader' +import { EventHandlers } from './maplibre/event_handlers' +import { FilterManager } from './maplibre/filter_manager' +import { DateManager } from './maplibre/date_manager' +import { SettingsController } from './maplibre/settings_manager' +import { AreaSelectionManager } from './maplibre/area_selection_manager' +import { VisitsManager } from './maplibre/visits_manager' +import { PlacesManager } from './maplibre/places_manager' +import { RoutesManager } from './maplibre/routes_manager' + +/** + * Main map controller for Maps V2 + * Coordinates between different managers and handles UI interactions + */ +export default class extends Controller { + static values = { + apiKey: String, + startDate: String, + endDate: String + } + + static targets = [ + 'container', + 'loading', + 'loadingText', + 'monthSelect', + 'clusterToggle', + 'settingsPanel', + 'visitsSearch', + 'routeOpacityRange', + 'placesFilters', + 'enableAllPlaceTagsToggle', + 'fogRadiusValue', + 'fogThresholdValue', + 'metersBetweenValue', + 'minutesBetweenValue', + // Search + 'searchInput', + 'searchResults', + // Layer toggles + 'pointsToggle', + 'routesToggle', + 'heatmapToggle', + 'visitsToggle', + 'photosToggle', + 'areasToggle', + 'placesToggle', + 'fogToggle', + 'scratchToggle', + // Speed-colored routes + 'routesOptions', + 'speedColoredToggle', + 'speedColorScaleContainer', + 'speedColorScaleInput', + // Area selection + 'selectAreaButton', + 'selectionActions', + 'deleteButtonText', + 'selectedVisitsContainer', + 'selectedVisitsBulkActions', + // Info display + 'infoDisplay', + 'infoTitle', + 'infoContent', + 'infoActions' + ] + + async connect() { + this.cleanup = new CleanupHelper() + + // Initialize API and settings + SettingsManager.initialize(this.apiKeyValue) + this.settingsController = new SettingsController(this) + await this.settingsController.loadSettings() + this.settings = this.settingsController.settings + + // Sync toggle states with loaded settings + this.settingsController.syncToggleStates() + + await this.initializeMap() + this.initializeAPI() + + // Initialize managers + this.layerManager = new LayerManager(this.map, this.settings, this.api) + this.dataLoader = new DataLoader(this.api, this.apiKeyValue) + this.eventHandlers = new EventHandlers(this.map, this) + this.filterManager = new FilterManager(this.dataLoader) + this.mapDataManager = new MapDataManager(this) + + // Initialize feature managers + this.areaSelectionManager = new AreaSelectionManager(this) + this.visitsManager = new VisitsManager(this) + this.placesManager = new PlacesManager(this) + this.routesManager = new RoutesManager(this) + + // Initialize search manager + this.initializeSearch() + + // Listen for visit and place creation/update events + this.boundHandleVisitCreated = this.visitsManager.handleVisitCreated.bind(this.visitsManager) + this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated) + + this.boundHandleVisitUpdated = this.visitsManager.handleVisitUpdated.bind(this.visitsManager) + this.cleanup.addEventListener(document, 'visit:updated', this.boundHandleVisitUpdated) + + this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager) + this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated) + + this.boundHandlePlaceUpdated = this.placesManager.handlePlaceUpdated.bind(this.placesManager) + this.cleanup.addEventListener(document, 'place:updated', this.boundHandlePlaceUpdated) + + this.boundHandleAreaCreated = this.handleAreaCreated.bind(this) + this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated) + + // Format initial dates + this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue)) + this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue)) + console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue) + + this.loadMapData() + } + + disconnect() { + this.searchManager?.destroy() + this.cleanup.cleanup() + this.map?.remove() + performanceMonitor.logReport() + } + + /** + * Initialize MapLibre map + */ + async initializeMap() { + this.map = await MapInitializer.initialize(this.containerTarget, { + mapStyle: this.settings.mapStyle + }) + } + + /** + * Initialize API client + */ + initializeAPI() { + this.api = new ApiClient(this.apiKeyValue) + } + + /** + * Initialize location search + */ + initializeSearch() { + if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) { + console.warn('[Maps V2] Search targets not found, search functionality disabled') + return + } + + this.searchManager = new SearchManager(this.map, this.apiKeyValue) + this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget) + + console.log('[Maps V2] Search manager initialized') + } + + /** + * Load map data from API + */ + async loadMapData(options = {}) { + return this.mapDataManager.loadMapData( + this.startDateValue, + this.endDateValue, + { + ...options, + onProgress: this.updateLoadingProgress.bind(this) + } + ) + } + + /** + * Month selector changed + */ + monthChanged(event) { + const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value) + this.startDateValue = startDate + this.endDateValue = endDate + + console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue) + this.loadMapData() + } + + /** + * Show loading indicator + */ + showLoading() { + this.loadingTarget.classList.remove('hidden') + } + + /** + * Hide loading indicator + */ + hideLoading() { + this.loadingTarget.classList.add('hidden') + } + + /** + * Update loading progress + */ + updateLoadingProgress({ loaded, totalPages, progress }) { + if (this.hasLoadingTextTarget) { + const percentage = Math.round(progress * 100) + this.loadingTextTarget.textContent = `Loading... ${percentage}%` + } + } + + /** + * Toggle settings panel + */ + toggleSettings() { + if (this.hasSettingsPanelTarget) { + this.settingsPanelTarget.classList.toggle('open') + } + } + + // ===== Delegated Methods to Managers ===== + + // Settings Controller methods + updateMapStyle(event) { return this.settingsController.updateMapStyle(event) } + resetSettings() { return this.settingsController.resetSettings() } + updateRouteOpacity(event) { return this.settingsController.updateRouteOpacity(event) } + updateAdvancedSettings(event) { return this.settingsController.updateAdvancedSettings(event) } + updateFogRadiusDisplay(event) { return this.settingsController.updateFogRadiusDisplay(event) } + updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) } + updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) } + updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) } + + // Area Selection Manager methods + startSelectArea() { return this.areaSelectionManager.startSelectArea() } + cancelAreaSelection() { return this.areaSelectionManager.cancelAreaSelection() } + deleteSelectedPoints() { return this.areaSelectionManager.deleteSelectedPoints() } + + // Visits Manager methods + toggleVisits(event) { return this.visitsManager.toggleVisits(event) } + searchVisits(event) { return this.visitsManager.searchVisits(event) } + filterVisits(event) { return this.visitsManager.filterVisits(event) } + startCreateVisit() { return this.visitsManager.startCreateVisit() } + + // Places Manager methods + togglePlaces(event) { return this.placesManager.togglePlaces(event) } + filterPlacesByTags(event) { return this.placesManager.filterPlacesByTags(event) } + toggleAllPlaceTags(event) { return this.placesManager.toggleAllPlaceTags(event) } + startCreatePlace() { return this.placesManager.startCreatePlace() } + + // Area creation + startCreateArea() { + console.log('[Maps V2] Starting create area mode') + + if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) { + this.toggleSettings() + } + + // Find area drawer controller on the same element + const drawerController = this.application.getControllerForElementAndIdentifier( + this.element, + 'area-drawer' + ) + + if (drawerController) { + console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map) + drawerController.startDrawing(this.map) + } else { + console.error('[Maps V2] Area drawer controller not found') + Toast.error('Area drawer controller not available') + } + } + + async handleAreaCreated(event) { + console.log('[Maps V2] Area created:', event.detail.area) + + try { + // Fetch all areas from API + const areas = await this.api.fetchAreas() + console.log('[Maps V2] Fetched areas:', areas.length) + + // Convert to GeoJSON + const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas) + console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features') + if (areasGeoJSON.features.length > 0) { + console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2)) + } + + // Get or create the areas layer + let areasLayer = this.layerManager.getLayer('areas') + console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible) + + if (areasLayer) { + // Update existing layer + areasLayer.update(areasGeoJSON) + console.log('[Maps V2] Areas layer updated') + } else { + // Create the layer if it doesn't exist yet + console.log('[Maps V2] Creating areas layer') + this.layerManager._addAreasLayer(areasGeoJSON) + areasLayer = this.layerManager.getLayer('areas') + console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible) + } + + // Enable the layer if it wasn't already + if (areasLayer) { + if (!areasLayer.visible) { + console.log('[Maps V2] Showing areas layer') + areasLayer.show() + this.settings.layers.areas = true + this.settingsController.saveSetting('layers.areas', true) + + // Update toggle state + if (this.hasAreasToggleTarget) { + this.areasToggleTarget.checked = true + } + } else { + console.log('[Maps V2] Areas layer already visible') + } + } + + Toast.success('Area created successfully!') + } catch (error) { + console.error('[Maps V2] Failed to reload areas:', error) + Toast.error('Failed to reload areas') + } + } + + // Routes Manager methods + togglePoints(event) { return this.routesManager.togglePoints(event) } + toggleRoutes(event) { return this.routesManager.toggleRoutes(event) } + toggleHeatmap(event) { return this.routesManager.toggleHeatmap(event) } + toggleFog(event) { return this.routesManager.toggleFog(event) } + toggleScratch(event) { return this.routesManager.toggleScratch(event) } + togglePhotos(event) { return this.routesManager.togglePhotos(event) } + toggleAreas(event) { return this.routesManager.toggleAreas(event) } + toggleTracks(event) { return this.routesManager.toggleTracks(event) } + toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) } + openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() } + handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) } + + // Info Display methods + showInfo(title, content, actions = []) { + if (!this.hasInfoDisplayTarget) return + + // Set title + this.infoTitleTarget.textContent = title + + // Set content + this.infoContentTarget.innerHTML = content + + // Set actions + if (actions.length > 0) { + this.infoActionsTarget.innerHTML = actions.map(action => { + if (action.type === 'button') { + // For button actions (modals, etc.), create a button with data-action + // Use error styling for delete buttons + const buttonClass = action.label === 'Delete' ? 'btn btn-sm btn-error' : 'btn btn-sm btn-primary' + return `` + } else { + // For link actions, keep the original behavior + return `${action.label}` + } + }).join('') + } else { + this.infoActionsTarget.innerHTML = '' + } + + // Show info display + this.infoDisplayTarget.classList.remove('hidden') + + // Switch to tools tab and open panel + this.switchToToolsTab() + } + + closeInfo() { + if (!this.hasInfoDisplayTarget) return + this.infoDisplayTarget.classList.add('hidden') + } + + /** + * Handle edit action from info display + */ + handleEdit(event) { + const button = event.currentTarget + const id = button.dataset.id + const entityType = button.dataset.entityType + + console.log('[Maps V2] Opening edit for', entityType, id) + + switch (entityType) { + case 'visit': + this.openVisitModal(id) + break + case 'place': + this.openPlaceEditModal(id) + break + default: + console.warn('[Maps V2] Unknown entity type:', entityType) + } + } + + /** + * Handle delete action from info display + */ + handleDelete(event) { + const button = event.currentTarget + const id = button.dataset.id + const entityType = button.dataset.entityType + + console.log('[Maps V2] Deleting', entityType, id) + + switch (entityType) { + case 'area': + this.deleteArea(id) + break + default: + console.warn('[Maps V2] Unknown entity type for delete:', entityType) + } + } + + /** + * Open visit edit modal + */ + async openVisitModal(visitId) { + try { + // Fetch visit details + const response = await fetch(`/api/v1/visits/${visitId}`, { + headers: { + 'Authorization': `Bearer ${this.apiKeyValue}`, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch visit: ${response.status}`) + } + + const visit = await response.json() + + // Trigger visit edit event + const event = new CustomEvent('visit:edit', { + detail: { visit }, + bubbles: true + }) + document.dispatchEvent(event) + } catch (error) { + console.error('[Maps V2] Failed to load visit:', error) + Toast.error('Failed to load visit details') + } + } + + /** + * Delete area with confirmation + */ + async deleteArea(areaId) { + try { + // Fetch area details + const area = await this.api.fetchArea(areaId) + + // Show delete confirmation + const confirmed = confirm(`Delete area "${area.name}"?\n\nThis action cannot be undone.`) + + if (!confirmed) return + + Toast.info('Deleting area...') + + // Delete the area + await this.api.deleteArea(areaId) + + // Reload areas + const areas = await this.api.fetchAreas() + const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas) + + const areasLayer = this.layerManager.getLayer('areas') + if (areasLayer) { + areasLayer.update(areasGeoJSON) + } + + // Close info display + this.closeInfo() + + Toast.success('Area deleted successfully') + } catch (error) { + console.error('[Maps V2] Failed to delete area:', error) + Toast.error('Failed to delete area') + } + } + + /** + * Open place edit modal + */ + async openPlaceEditModal(placeId) { + try { + // Fetch place details + const response = await fetch(`/api/v1/places/${placeId}`, { + headers: { + 'Authorization': `Bearer ${this.apiKeyValue}`, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch place: ${response.status}`) + } + + const place = await response.json() + + // Trigger place edit event + const event = new CustomEvent('place:edit', { + detail: { place }, + bubbles: true + }) + document.dispatchEvent(event) + } catch (error) { + console.error('[Maps V2] Failed to load place:', error) + Toast.error('Failed to load place details') + } + } + + switchToToolsTab() { + // Open the panel if it's not already open + if (!this.settingsPanelTarget.classList.contains('open')) { + this.toggleSettings() + } + + // Find the map-panel controller and switch to tools tab + const panelElement = this.settingsPanelTarget + const panelController = this.application.getControllerForElementAndIdentifier(panelElement, 'map-panel') + + if (panelController && panelController.switchToTab) { + panelController.switchToTab('tools') + } + } +} diff --git a/app/javascript/controllers/maps/maplibre_realtime_controller.js b/app/javascript/controllers/maps/maplibre_realtime_controller.js new file mode 100644 index 00000000..84b75c71 --- /dev/null +++ b/app/javascript/controllers/maps/maplibre_realtime_controller.js @@ -0,0 +1,323 @@ +import { Controller } from '@hotwired/stimulus' +import { createMapChannel } from 'maps_maplibre/channels/map_channel' +import { WebSocketManager } from 'maps_maplibre/utils/websocket_manager' +import { Toast } from 'maps_maplibre/components/toast' + +/** + * Real-time controller + * Manages ActionCable connection and real-time updates + */ +export default class extends Controller { + static targets = ['liveModeToggle'] + + static values = { + enabled: { type: Boolean, default: true }, + liveMode: { type: Boolean, default: false } + } + + connect() { + console.log('[Realtime Controller] Connecting...') + + if (!this.enabledValue) { + console.log('[Realtime Controller] Disabled, skipping setup') + return + } + + try { + this.connectedChannels = new Set() + this.liveModeEnabled = false // Start with live mode disabled + + // Delay channel setup to ensure ActionCable is ready + // This prevents race condition with page initialization + setTimeout(() => { + try { + this.setupChannels() + } catch (error) { + console.error('[Realtime Controller] Failed to setup channels in setTimeout:', error) + this.updateConnectionIndicator(false) + } + }, 1000) + + // Initialize toggle state from settings + if (this.hasLiveModeToggleTarget) { + this.liveModeToggleTarget.checked = this.liveModeEnabled + } + } catch (error) { + console.error('[Realtime Controller] Failed to initialize:', error) + // Don't throw - allow page to continue loading + } + } + + disconnect() { + this.channels?.unsubscribeAll() + } + + /** + * Setup ActionCable channels + * Family channel is always enabled when family feature is on + * Points channel (live mode) is controlled by user toggle + */ + setupChannels() { + try { + console.log('[Realtime Controller] Setting up channels...') + this.channels = createMapChannel({ + connected: this.handleConnected.bind(this), + disconnected: this.handleDisconnected.bind(this), + received: this.handleReceived.bind(this), + enableLiveMode: this.liveModeEnabled // Control points channel + }) + console.log('[Realtime Controller] Channels setup complete') + } catch (error) { + console.error('[Realtime Controller] Failed to setup channels:', error) + console.error('[Realtime Controller] Error stack:', error.stack) + this.updateConnectionIndicator(false) + // Don't throw - page should continue to work + } + } + + /** + * Toggle live mode (new points appearing in real-time) + */ + toggleLiveMode(event) { + this.liveModeEnabled = event.target.checked + + // Update recent point layer visibility + this.updateRecentPointLayerVisibility() + + // Reconnect channels with new settings + if (this.channels) { + this.channels.unsubscribeAll() + } + this.setupChannels() + + const message = this.liveModeEnabled ? 'Live mode enabled' : 'Live mode disabled' + Toast.info(message) + } + + /** + * Update recent point layer visibility based on live mode state + */ + updateRecentPointLayerVisibility() { + const mapsController = this.mapsV2Controller + if (!mapsController) { + return + } + + const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint') + if (!recentPointLayer) { + return + } + + if (this.liveModeEnabled) { + recentPointLayer.show() + } else { + recentPointLayer.hide() + recentPointLayer.clear() + } + } + + /** + * Handle connection + */ + handleConnected(channelName) { + this.connectedChannels.add(channelName) + + // Only show toast when at least one channel is connected + if (this.connectedChannels.size === 1) { + Toast.success('Connected to real-time updates') + this.updateConnectionIndicator(true) + } + } + + /** + * Handle disconnection + */ + handleDisconnected(channelName) { + this.connectedChannels.delete(channelName) + + // Show warning only when all channels are disconnected + if (this.connectedChannels.size === 0) { + Toast.warning('Disconnected from real-time updates') + this.updateConnectionIndicator(false) + } + } + + /** + * Handle received data + */ + handleReceived(data) { + switch (data.type) { + case 'new_point': + this.handleNewPoint(data.point) + break + + case 'family_location': + this.handleFamilyLocation(data.member) + break + + case 'notification': + this.handleNotification(data.notification) + break + } + } + + /** + * Get the maps--maplibre controller (on same element) + */ + get mapsV2Controller() { + const element = this.element + const app = this.application + return app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + } + + /** + * Handle new point + * Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name] + */ + handleNewPoint(pointData) { + const mapsController = this.mapsV2Controller + if (!mapsController) { + console.warn('[Realtime Controller] Maps controller not found') + return + } + + console.log('[Realtime Controller] Received point data:', pointData) + + // Parse point data from array format + const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData + + // Get points layer from layer manager + const pointsLayer = mapsController.layerManager?.getLayer('points') + if (!pointsLayer) { + console.warn('[Realtime Controller] Points layer not found') + return + } + + // Get current data + const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] } + const features = [...(currentData.features || [])] + + // Add new point + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [parseFloat(lon), parseFloat(lat)] + }, + properties: { + id: parseInt(id), + latitude: parseFloat(lat), + longitude: parseFloat(lon), + battery: parseFloat(battery) || null, + altitude: parseFloat(altitude) || null, + timestamp: timestamp, + velocity: parseFloat(velocity) || null, + country_name: countryName || null + } + }) + + // Update layer with new data + pointsLayer.update({ + type: 'FeatureCollection', + features + }) + + console.log('[Realtime Controller] Added new point to map:', id) + + // Update recent point marker (always visible in live mode) + this.updateRecentPoint(parseFloat(lon), parseFloat(lat), { + id: parseInt(id), + battery: parseFloat(battery) || null, + altitude: parseFloat(altitude) || null, + timestamp: timestamp, + velocity: parseFloat(velocity) || null, + country_name: countryName || null + }) + + // Zoom to the new point + this.zoomToPoint(parseFloat(lon), parseFloat(lat)) + + Toast.info('New location recorded') + } + + /** + * Handle family member location update + */ + handleFamilyLocation(member) { + const mapsController = this.mapsV2Controller + if (!mapsController) return + + const familyLayer = mapsController.familyLayer + if (familyLayer) { + familyLayer.updateMember(member) + } + } + + /** + * Handle notification + */ + handleNotification(notification) { + Toast.info(notification.message || 'New notification') + } + + /** + * Update the recent point marker + * This marker is always visible in live mode, independent of points layer visibility + */ + updateRecentPoint(longitude, latitude, properties = {}) { + const mapsController = this.mapsV2Controller + if (!mapsController) { + console.warn('[Realtime Controller] Maps controller not found') + return + } + + const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint') + if (!recentPointLayer) { + console.warn('[Realtime Controller] Recent point layer not found') + return + } + + // Show the layer if live mode is enabled and update with new point + if (this.liveModeEnabled) { + recentPointLayer.show() + recentPointLayer.updateRecentPoint(longitude, latitude, properties) + console.log('[Realtime Controller] Updated recent point marker:', longitude, latitude) + } + } + + /** + * Zoom map to a specific point + */ + zoomToPoint(longitude, latitude) { + const mapsController = this.mapsV2Controller + if (!mapsController || !mapsController.map) { + console.warn('[Realtime Controller] Map not available for zooming') + return + } + + const map = mapsController.map + + // Fly to the new point with a smooth animation + map.flyTo({ + center: [longitude, latitude], + zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher + duration: 2000, // 2 second animation + essential: true // This animation is considered essential with respect to prefers-reduced-motion + }) + + console.log('[Realtime Controller] Zoomed to point:', longitude, latitude) + } + + /** + * Update connection indicator + */ + updateConnectionIndicator(connected) { + const indicator = document.querySelector('.connection-indicator') + if (indicator) { + // Show the indicator when connection is attempted + indicator.classList.add('active') + indicator.classList.toggle('connected', connected) + indicator.classList.toggle('disconnected', !connected) + } + } +} diff --git a/app/javascript/controllers/public_stat_map_controller.js b/app/javascript/controllers/public_stat_map_controller.js index b0d9918a..c218fd0c 100644 --- a/app/javascript/controllers/public_stat_map_controller.js +++ b/app/javascript/controllers/public_stat_map_controller.js @@ -72,9 +72,7 @@ export default class extends BaseController { } async loadHexagons() { - console.log('🎯 loadHexagons started - checking overlay state'); const initialLoadingElement = document.getElementById('map-loading'); - console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default'); try { // Use server-provided data bounds @@ -94,9 +92,6 @@ export default class extends BaseController { // Fallback timeout in case moveend doesn't fire setTimeout(resolve, 1000); }); - console.log('✅ Map fitBounds complete - checking overlay state'); - const afterFitBoundsElement = document.getElementById('map-loading'); - console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default'); } // Load hexagons only if they are pre-calculated and data exists @@ -138,7 +133,6 @@ export default class extends BaseController { loadingElement.style.display = 'flex'; loadingElement.style.visibility = 'visible'; loadingElement.style.zIndex = '9999'; - console.log('👁️ Loading overlay ENSURED visible - should be visible now'); } // Disable map interaction during loading @@ -187,7 +181,6 @@ export default class extends BaseController { } const geojsonData = await response.json(); - console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`); // Add hexagons directly to map as a static layer if (geojsonData.features && geojsonData.features.length > 0) { @@ -210,7 +203,6 @@ export default class extends BaseController { const loadingElement = document.getElementById('map-loading'); if (loadingElement) { loadingElement.style.display = 'none'; - console.log('🚫 Loading overlay hidden - hexagons are fully loaded'); } } } diff --git a/app/javascript/controllers/speed_color_editor_controller.js b/app/javascript/controllers/speed_color_editor_controller.js new file mode 100644 index 00000000..5dfc573b --- /dev/null +++ b/app/javascript/controllers/speed_color_editor_controller.js @@ -0,0 +1,184 @@ +import { Controller } from '@hotwired/stimulus' + +/** + * Speed Color Editor Controller + * Manages the gradient editor modal for speed-colored routes + */ +export default class extends Controller { + static targets = ['modal', 'stopsList', 'preview'] + static values = { + colorStops: String + } + + connect() { + this.loadColorStops() + } + + loadColorStops() { + const stopsString = this.colorStopsValue || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' + this.stops = this.parseColorStops(stopsString) + this.renderStops() + this.updatePreview() + } + + parseColorStops(stopsString) { + return stopsString.split('|').map(segment => { + const [speed, color] = segment.split(':') + return { speed: Number(speed), color } + }) + } + + serializeColorStops() { + return this.stops.map(stop => `${stop.speed}:${stop.color}`).join('|') + } + + renderStops() { + if (!this.hasStopsListTarget) return + + this.stopsListTarget.innerHTML = this.stops.map((stop, index) => ` +
+
+ + +
+ +
+ +
+ + +
+
+ + +
+ `).join('') + } + + updateSpeed(event) { + const index = parseInt(event.target.dataset.index) + this.stops[index].speed = Number(event.target.value) + this.updatePreview() + } + + updateColor(event) { + const index = parseInt(event.target.dataset.index) + const color = event.target.value + this.stops[index].color = color + + // Update text input + const textInput = event.target.parentElement.querySelector('input[type="text"]') + if (textInput) { + textInput.value = color + } + + this.updatePreview() + } + + updateColorText(event) { + const index = parseInt(event.target.dataset.index) + const color = event.target.value + + if (/^#[0-9A-Fa-f]{6}$/.test(color)) { + this.stops[index].color = color + + // Update color picker + const colorInput = event.target.parentElement.querySelector('input[type="color"]') + if (colorInput) { + colorInput.value = color + } + + this.updatePreview() + } + } + + addStop() { + // Find a good speed value between existing stops + const lastStop = this.stops[this.stops.length - 1] + const newSpeed = lastStop.speed + 10 + + this.stops.push({ + speed: newSpeed, + color: '#ff0000' + }) + + // Sort by speed + this.stops.sort((a, b) => a.speed - b.speed) + + this.renderStops() + this.updatePreview() + } + + removeStop(event) { + const index = parseInt(event.target.dataset.index) + + if (this.stops.length > 2) { + this.stops.splice(index, 1) + this.renderStops() + this.updatePreview() + } + } + + updatePreview() { + if (!this.hasPreviewTarget) return + + const gradient = this.stops.map((stop, index) => { + const percentage = (index / (this.stops.length - 1)) * 100 + return `${stop.color} ${percentage}%` + }).join(', ') + + this.previewTarget.style.background = `linear-gradient(to right, ${gradient})` + } + + save() { + const serialized = this.serializeColorStops() + + // Dispatch event with the new color stops + this.dispatch('save', { + detail: { colorStops: serialized } + }) + + this.close() + } + + close() { + if (this.hasModalTarget) { + const checkbox = this.modalTarget.querySelector('.modal-toggle') + if (checkbox) { + checkbox.checked = false + } + } + } + + resetToDefault() { + this.colorStopsValue = '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' + this.loadColorStops() + } +} diff --git a/app/javascript/controllers/visit_creation_v2_controller.js b/app/javascript/controllers/visit_creation_v2_controller.js new file mode 100644 index 00000000..ad53c945 --- /dev/null +++ b/app/javascript/controllers/visit_creation_v2_controller.js @@ -0,0 +1,255 @@ +import { Controller } from '@hotwired/stimulus' +import { Toast } from 'maps_maplibre/components/toast' + +/** + * Controller for visit creation modal in Maps V2 + */ +export default class extends Controller { + static targets = [ + 'modal', + 'form', + 'modalTitle', + 'nameInput', + 'startTimeInput', + 'endTimeInput', + 'latitudeInput', + 'longitudeInput', + 'submitButton' + ] + + static values = { + apiKey: String + } + + connect() { + console.log('[Visit Creation V2] Controller connected') + this.marker = null + this.mapController = null + this.editingVisitId = null + this.setupEventListeners() + } + + setupEventListeners() { + document.addEventListener('visit:edit', (e) => { + this.openForEdit(e.detail.visit) + }) + } + + disconnect() { + this.cleanup() + } + + /** + * Open the modal with coordinates + */ + open(lat, lng, mapController) { + console.log('[Visit Creation V2] Opening modal', { lat, lng }) + + this.editingVisitId = null + this.mapController = mapController + this.latitudeInputTarget.value = lat + this.longitudeInputTarget.value = lng + + // Set modal title and button for creation + if (this.hasModalTitleTarget) { + this.modalTitleTarget.textContent = 'Create New Visit' + } + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.textContent = 'Create Visit' + } + + // Set default times + const now = new Date() + const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)) + + this.startTimeInputTarget.value = this.formatDateTime(now) + this.endTimeInputTarget.value = this.formatDateTime(oneHourLater) + + // Show modal + this.modalTarget.classList.add('modal-open') + + // Focus on name input + setTimeout(() => this.nameInputTarget.focus(), 100) + + // Add marker to map + this.addMarker(lat, lng) + } + + /** + * Open the modal for editing an existing visit + */ + openForEdit(visit) { + console.log('[Visit Creation V2] Opening modal for edit', visit) + + this.editingVisitId = visit.id + + // Set modal title and button for editing + if (this.hasModalTitleTarget) { + this.modalTitleTarget.textContent = 'Edit Visit' + } + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.textContent = 'Update Visit' + } + + // Fill form with visit data + this.nameInputTarget.value = visit.name || '' + this.latitudeInputTarget.value = visit.latitude + this.longitudeInputTarget.value = visit.longitude + + // Convert timestamps to datetime-local format + this.startTimeInputTarget.value = this.formatDateTime(new Date(visit.started_at)) + this.endTimeInputTarget.value = this.formatDateTime(new Date(visit.ended_at)) + + // Show modal + this.modalTarget.classList.add('modal-open') + + // Focus on name input + setTimeout(() => this.nameInputTarget.focus(), 100) + + // Try to get map controller from the maps--maplibre controller + const mapElement = document.querySelector('[data-controller*="maps--maplibre"]') + if (mapElement) { + const app = window.Stimulus || window.Application + this.mapController = app?.getControllerForElementAndIdentifier(mapElement, 'maps--maplibre') + } + + // Add marker to map + this.addMarker(visit.latitude, visit.longitude) + } + + /** + * Close the modal + */ + close() { + console.log('[Visit Creation V2] Closing modal') + + // Hide modal + this.modalTarget.classList.remove('modal-open') + + // Reset form + this.formTarget.reset() + + // Reset editing state + this.editingVisitId = null + + // Remove marker + this.removeMarker() + } + + /** + * Handle form submission + */ + async submit(event) { + event.preventDefault() + + const isEdit = this.editingVisitId !== null + console.log(`[Visit Creation V2] Submitting form (${isEdit ? 'edit' : 'create'})`) + + const formData = new FormData(this.formTarget) + + const visitData = { + visit: { + name: formData.get('name'), + started_at: formData.get('started_at'), + ended_at: formData.get('ended_at'), + latitude: parseFloat(formData.get('latitude')), + longitude: parseFloat(formData.get('longitude')), + status: 'confirmed' + } + } + + try { + const url = isEdit ? `/api/v1/visits/${this.editingVisitId}` : '/api/v1/visits' + const method = isEdit ? 'PATCH' : 'POST' + + const response = await fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKeyValue}`, + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' + }, + body: JSON.stringify(visitData) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || `Failed to ${isEdit ? 'update' : 'create'} visit`) + } + + const visit = await response.json() + + console.log(`[Visit Creation V2] Visit ${isEdit ? 'updated' : 'created'} successfully`, visit) + + // Show success message + this.showToast(`Visit ${isEdit ? 'updated' : 'created'} successfully`, 'success') + + // Close modal + this.close() + + // Dispatch event to notify map controller + const eventName = isEdit ? 'visit:updated' : 'visit:created' + document.dispatchEvent(new CustomEvent(eventName, { + detail: { visit } + })) + } catch (error) { + console.error(`[Visit Creation V2] Error ${isEdit ? 'updating' : 'creating'} visit:`, error) + this.showToast(error.message || `Failed to ${isEdit ? 'update' : 'create'} visit`, 'error') + } + } + + /** + * Add marker to map + */ + addMarker(lat, lng) { + if (!this.mapController) return + + // Remove existing marker if any + this.removeMarker() + + // Create marker element + const el = document.createElement('div') + el.className = 'visit-creation-marker' + el.innerHTML = '📍' + el.style.fontSize = '30px' + + // Use maplibregl if available (from mapController) + const maplibregl = window.maplibregl + if (maplibregl) { + this.marker = new maplibregl.Marker({ element: el }) + .setLngLat([lng, lat]) + .addTo(this.mapController.map) + } + } + + /** + * Remove marker from map + */ + removeMarker() { + if (this.marker) { + this.marker.remove() + this.marker = null + } + } + + /** + * Clean up resources + */ + cleanup() { + this.removeMarker() + } + + /** + * Format date for datetime-local input + */ + formatDateTime(date) { + return date.toISOString().slice(0, 16) + } + + /** + * Show toast notification + */ + showToast(message, type = 'info') { + Toast[type](message) + } +} diff --git a/app/javascript/maps/vector_maps_config.js b/app/javascript/maps/vector_maps_config.js index 46a3e3d2..11e2cf05 100644 --- a/app/javascript/maps/vector_maps_config.js +++ b/app/javascript/maps/vector_maps_config.js @@ -1,32 +1,36 @@ +/** + * Vector maps configuration for Maps V1 (legacy) + * For Maps V2, use style_manager.js instead + */ export const mapsConfig = { "Light": { url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", flavor: "light", - maxZoom: 16, + maxZoom: 14, attribution: "Protomaps, © OpenStreetMap" }, "Dark": { url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", flavor: "dark", - maxZoom: 16, + maxZoom: 14, attribution: "Protomaps, © OpenStreetMap" }, "White": { url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", flavor: "white", - maxZoom: 16, + maxZoom: 14, attribution: "Protomaps, © OpenStreetMap" }, "Grayscale": { url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", flavor: "grayscale", - maxZoom: 16, + maxZoom: 14, attribution: "Protomaps, © OpenStreetMap" }, "Black": { url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt", flavor: "black", - maxZoom: 16, + maxZoom: 14, attribution: "Protomaps, © OpenStreetMap" }, }; diff --git a/app/javascript/maps_maplibre/channels/map_channel.js b/app/javascript/maps_maplibre/channels/map_channel.js new file mode 100644 index 00000000..7a2e9d38 --- /dev/null +++ b/app/javascript/maps_maplibre/channels/map_channel.js @@ -0,0 +1,118 @@ +import consumer from '../../channels/consumer' + +/** + * Create map channel subscription for maps_maplibre + * Wraps the existing FamilyLocationsChannel and other channels for real-time updates + * @param {Object} options - { received, connected, disconnected, enableLiveMode } + * @returns {Object} Subscriptions object with multiple channels + */ +export function createMapChannel(options = {}) { + const { enableLiveMode = false, ...callbacks } = options + const subscriptions = { + family: null, + points: null, + notifications: null + } + + console.log('[MapChannel] Creating channels with enableLiveMode:', enableLiveMode) + + // Defensive check - consumer might not be available + if (!consumer) { + console.warn('[MapChannel] ActionCable consumer not available') + return { + subscriptions, + unsubscribeAll() {} + } + } + + // Subscribe to family locations if family feature is enabled + try { + const familyFeaturesElement = document.querySelector('[data-family-members-features-value]') + const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {} + + if (features.family) { + subscriptions.family = consumer.subscriptions.create('FamilyLocationsChannel', { + connected() { + console.log('FamilyLocationsChannel connected') + callbacks.connected?.('family') + }, + + disconnected() { + console.log('FamilyLocationsChannel disconnected') + callbacks.disconnected?.('family') + }, + + received(data) { + console.log('FamilyLocationsChannel received:', data) + callbacks.received?.({ + type: 'family_location', + member: data + }) + } + }) + } + } catch (error) { + console.warn('[MapChannel] Failed to subscribe to family channel:', error) + } + + // Subscribe to points channel for real-time point updates (only if live mode is enabled) + if (enableLiveMode) { + try { + subscriptions.points = consumer.subscriptions.create('PointsChannel', { + connected() { + console.log('PointsChannel connected') + callbacks.connected?.('points') + }, + + disconnected() { + console.log('PointsChannel disconnected') + callbacks.disconnected?.('points') + }, + + received(data) { + console.log('PointsChannel received:', data) + callbacks.received?.({ + type: 'new_point', + point: data + }) + } + }) + } catch (error) { + console.warn('[MapChannel] Failed to subscribe to points channel:', error) + } + } else { + console.log('[MapChannel] Live mode disabled, not subscribing to PointsChannel') + } + + // Subscribe to notifications channel + try { + subscriptions.notifications = consumer.subscriptions.create('NotificationsChannel', { + connected() { + console.log('NotificationsChannel connected') + callbacks.connected?.('notifications') + }, + + disconnected() { + console.log('NotificationsChannel disconnected') + callbacks.disconnected?.('notifications') + }, + + received(data) { + console.log('NotificationsChannel received:', data) + callbacks.received?.({ + type: 'notification', + notification: data + }) + } + }) + } catch (error) { + console.warn('[MapChannel] Failed to subscribe to notifications channel:', error) + } + + return { + subscriptions, + unsubscribeAll() { + Object.values(subscriptions).forEach(sub => sub?.unsubscribe()) + } + } +} diff --git a/app/javascript/maps_maplibre/components/photo_popup.js b/app/javascript/maps_maplibre/components/photo_popup.js new file mode 100644 index 00000000..d1791b1b --- /dev/null +++ b/app/javascript/maps_maplibre/components/photo_popup.js @@ -0,0 +1,100 @@ +/** + * Factory for creating photo popups + */ +export class PhotoPopupFactory { + /** + * Create popup for a photo + * @param {Object} properties - Photo properties + * @returns {string} HTML for popup + */ + static createPhotoPopup(properties) { + const { + id, + thumbnail_url, + taken_at, + filename, + city, + state, + country, + type, + source + } = properties + + const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown' + const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location' + const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo' + + return ` +
+
+ ${filename} +
+
+
${filename}
+
Taken: ${takenDate}
+
Location: ${location}
+
Source: ${source}
+
${mediaType}
+
+
+ + + ` + } +} diff --git a/app/javascript/maps_maplibre/components/popup_factory.js b/app/javascript/maps_maplibre/components/popup_factory.js new file mode 100644 index 00000000..4a6e9a47 --- /dev/null +++ b/app/javascript/maps_maplibre/components/popup_factory.js @@ -0,0 +1,114 @@ +import { formatTimestamp } from '../utils/geojson_transformers' +import { getCurrentTheme, getThemeColors } from '../utils/popup_theme' + +/** + * Factory for creating map popups + */ +export class PopupFactory { + /** + * Create popup for a point + * @param {Object} properties - Point properties + * @returns {string} HTML for popup + */ + static createPointPopup(properties) { + const { id, timestamp, altitude, battery, accuracy, velocity } = properties + + // Get theme colors + const theme = getCurrentTheme() + const colors = getThemeColors(theme) + + return ` +
+ + +
+ ` + } + + /** + * Create popup for a place + * @param {Object} properties - Place properties + * @returns {string} HTML for popup + */ + static createPlacePopup(properties) { + const { id, name, latitude, longitude, note, tags } = properties + + // Get theme colors + const theme = getCurrentTheme() + const colors = getThemeColors(theme) + + // Parse tags if they're stringified + let parsedTags = tags + if (typeof tags === 'string') { + try { + parsedTags = JSON.parse(tags) + } catch (e) { + parsedTags = [] + } + } + + // Format tags as badges + const tagsHtml = parsedTags && Array.isArray(parsedTags) && parsedTags.length > 0 + ? parsedTags.map(tag => ` + + ${tag.icon} #${tag.name} + + `).join(' ') + : `Untagged` + + return ` +
+ + +
+ ` + } +} diff --git a/app/javascript/maps_maplibre/components/toast.js b/app/javascript/maps_maplibre/components/toast.js new file mode 100644 index 00000000..24b5901e --- /dev/null +++ b/app/javascript/maps_maplibre/components/toast.js @@ -0,0 +1,183 @@ +/** + * Toast notification system + * Displays temporary notifications in the top-right corner + */ +export class Toast { + static container = null + + /** + * Initialize toast container + */ + static init() { + if (this.container) return + + this.container = document.createElement('div') + this.container.className = 'toast-container' + this.container.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; + ` + document.body.appendChild(this.container) + + // Add CSS animations + this.addStyles() + } + + /** + * Add CSS animations for toasts + */ + static addStyles() { + if (document.getElementById('toast-styles')) return + + const style = document.createElement('style') + style.id = 'toast-styles' + style.textContent = ` + @keyframes toast-slide-in { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + @keyframes toast-slide-out { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(400px); + opacity: 0; + } + } + + .toast { + pointer-events: auto; + animation: toast-slide-in 0.3s ease-out; + } + + .toast.removing { + animation: toast-slide-out 0.3s ease-out; + } + ` + document.head.appendChild(style) + } + + /** + * Show toast notification + * @param {string} message - Message to display + * @param {string} type - Toast type: 'success', 'error', 'info', 'warning' + * @param {number} duration - Duration in milliseconds (default 3000) + */ + static show(message, type = 'info', duration = 3000) { + this.init() + + const toast = document.createElement('div') + toast.className = `toast toast-${type}` + toast.textContent = message + + toast.style.cssText = ` + padding: 12px 20px; + background: ${this.getBackgroundColor(type)}; + color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-size: 14px; + font-weight: 500; + max-width: 300px; + line-height: 1.4; + ` + + this.container.appendChild(toast) + + // Auto dismiss after duration + if (duration > 0) { + setTimeout(() => { + this.dismiss(toast) + }, duration) + } + + return toast + } + + /** + * Dismiss a toast + * @param {HTMLElement} toast - Toast element to dismiss + */ + static dismiss(toast) { + toast.classList.add('removing') + setTimeout(() => { + toast.remove() + }, 300) + } + + /** + * Get background color for toast type + * @param {string} type - Toast type + * @returns {string} CSS color + */ + static getBackgroundColor(type) { + const colors = { + success: '#22c55e', + error: '#ef4444', + warning: '#f59e0b', + info: '#3b82f6' + } + return colors[type] || colors.info + } + + /** + * Show success toast + * @param {string} message + * @param {number} duration + */ + static success(message, duration = 3000) { + return this.show(message, 'success', duration) + } + + /** + * Show error toast + * @param {string} message + * @param {number} duration + */ + static error(message, duration = 4000) { + return this.show(message, 'error', duration) + } + + /** + * Show warning toast + * @param {string} message + * @param {number} duration + */ + static warning(message, duration = 3500) { + return this.show(message, 'warning', duration) + } + + /** + * Show info toast + * @param {string} message + * @param {number} duration + */ + static info(message, duration = 3000) { + return this.show(message, 'info', duration) + } + + /** + * Clear all toasts + */ + static clearAll() { + if (!this.container) return + + const toasts = this.container.querySelectorAll('.toast') + toasts.forEach(toast => this.dismiss(toast)) + } +} diff --git a/app/javascript/maps_maplibre/components/visit_card.js b/app/javascript/maps_maplibre/components/visit_card.js new file mode 100644 index 00000000..5b62e4ba --- /dev/null +++ b/app/javascript/maps_maplibre/components/visit_card.js @@ -0,0 +1,156 @@ +/** + * Visit card component for rendering individual visit cards in the side panel + */ +export class VisitCard { + /** + * Create HTML for a visit card + * @param {Object} visit - Visit object with id, name, status, started_at, ended_at, duration, place + * @param {Object} options - { isSelected, onSelect, onConfirm, onDecline, onHover } + * @returns {string} HTML string + */ + static create(visit, options = {}) { + const { isSelected = false, onSelect, onConfirm, onDecline, onHover } = options + const isSuggested = visit.status === 'suggested' + const isConfirmed = visit.status === 'confirmed' + const isDeclined = visit.status === 'declined' + + // Format date and time + const startDate = new Date(visit.started_at) + const endDate = new Date(visit.ended_at) + const dateStr = startDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + const timeRange = `${startDate.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + })} - ${endDate.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + })}` + + // Format duration (duration is in minutes from the backend) + const hours = Math.floor(visit.duration / 60) + const minutes = visit.duration % 60 + const durationStr = hours > 0 + ? `${hours}h ${minutes}m` + : `${minutes}m` + + // Border style based on status + const borderClass = isSuggested ? 'border-dashed' : '' + const bgClass = isDeclined ? 'bg-base-200 opacity-60' : 'bg-base-100' + const selectedClass = isSelected ? 'ring-2 ring-primary' : '' + + return ` +
+ + +
+ +
+ +
+ +

+ ${visit.name || visit.place?.name || 'Unnamed Visit'} +

+ + +
+
+ + + + ${dateStr} +
+
+ + + + ${timeRange} +
+
+ + + + ${durationStr} +
+
+ + + ${isSuggested ? ` +
+ + +
+ ` : ''} + + + ${isConfirmed || isDeclined ? ` +
+ + ${visit.status} + +
+ ` : ''} +
+
+ ` + } + + /** + * Create bulk action buttons HTML + * @param {number} selectedCount - Number of selected visits + * @returns {string} HTML string + */ + static createBulkActions(selectedCount) { + if (selectedCount < 2) return '' + + return ` +
+
+ ${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected +
+
+ + + +
+
+ ` + } +} diff --git a/app/javascript/maps_maplibre/components/visit_popup.js b/app/javascript/maps_maplibre/components/visit_popup.js new file mode 100644 index 00000000..3db92fe2 --- /dev/null +++ b/app/javascript/maps_maplibre/components/visit_popup.js @@ -0,0 +1,138 @@ +import { formatTimestamp } from '../utils/geojson_transformers' +import { getCurrentTheme, getThemeColors } from '../utils/popup_theme' + +/** + * Factory for creating visit popups + */ +export class VisitPopupFactory { + /** + * Create popup for a visit + * @param {Object} properties - Visit properties + * @returns {string} HTML for popup + */ + static createVisitPopup(properties) { + const { id, name, status, started_at, ended_at, duration, place_name } = properties + + const startTime = formatTimestamp(started_at) + const endTime = formatTimestamp(ended_at) + const durationHours = Math.round(duration / 3600) + const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(duration / 60)}m` + + // Get theme colors + const theme = getCurrentTheme() + const colors = getThemeColors(theme) + + return ` +
+ + + +
+ + + ` + } +} diff --git a/app/javascript/maps_maplibre/layers/areas_layer.js b/app/javascript/maps_maplibre/layers/areas_layer.js new file mode 100644 index 00000000..c6f43b23 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/areas_layer.js @@ -0,0 +1,67 @@ +import { BaseLayer } from './base_layer' + +/** + * Areas layer for user-defined regions + */ +export class AreasLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'areas', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Area fills + { + id: `${this.id}-fill`, + type: 'fill', + source: this.sourceId, + paint: { + 'fill-color': '#ff0000', + 'fill-opacity': 0.4 + } + }, + + // Area outlines + { + id: `${this.id}-outline`, + type: 'line', + source: this.sourceId, + paint: { + 'line-color': '#ff0000', + 'line-width': 3 + } + }, + + // Area labels + { + id: `${this.id}-labels`, + type: 'symbol', + source: this.sourceId, + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-size': 14 + }, + paint: { + 'text-color': '#111827', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2 + } + } + ] + } + + getLayerIds() { + return [`${this.id}-fill`, `${this.id}-outline`, `${this.id}-labels`] + } +} diff --git a/app/javascript/maps_maplibre/layers/base_layer.js b/app/javascript/maps_maplibre/layers/base_layer.js new file mode 100644 index 00000000..6c79e253 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/base_layer.js @@ -0,0 +1,136 @@ +/** + * Base class for all map layers + * Provides common functionality for layer management + */ +export class BaseLayer { + constructor(map, options = {}) { + this.map = map + this.id = options.id || this.constructor.name.toLowerCase() + this.sourceId = `${this.id}-source` + this.visible = options.visible !== false + this.data = null + } + + /** + * Add layer to map with data + * @param {Object} data - GeoJSON or layer-specific data + */ + add(data) { + console.log(`[BaseLayer:${this.id}] add() called, visible:`, this.visible, 'features:', data?.features?.length || 0) + this.data = data + + // Add source + if (!this.map.getSource(this.sourceId)) { + console.log(`[BaseLayer:${this.id}] Adding source:`, this.sourceId) + this.map.addSource(this.sourceId, this.getSourceConfig()) + } else { + console.log(`[BaseLayer:${this.id}] Source already exists:`, this.sourceId) + } + + // Add layers + const layers = this.getLayerConfigs() + console.log(`[BaseLayer:${this.id}] Adding ${layers.length} layer(s)`) + layers.forEach(layerConfig => { + if (!this.map.getLayer(layerConfig.id)) { + console.log(`[BaseLayer:${this.id}] Adding layer:`, layerConfig.id, 'type:', layerConfig.type) + this.map.addLayer(layerConfig) + } else { + console.log(`[BaseLayer:${this.id}] Layer already exists:`, layerConfig.id) + } + }) + + this.setVisibility(this.visible) + console.log(`[BaseLayer:${this.id}] Layer added successfully`) + } + + /** + * Update layer data + * @param {Object} data - New data + */ + update(data) { + this.data = data + const source = this.map.getSource(this.sourceId) + if (source && source.setData) { + source.setData(data) + } + } + + /** + * Remove layer from map + */ + remove() { + this.getLayerIds().forEach(layerId => { + if (this.map.getLayer(layerId)) { + this.map.removeLayer(layerId) + } + }) + + if (this.map.getSource(this.sourceId)) { + this.map.removeSource(this.sourceId) + } + + this.data = null + } + + /** + * Show layer + */ + show() { + this.visible = true + this.setVisibility(true) + } + + /** + * Hide layer + */ + hide() { + this.visible = false + this.setVisibility(false) + } + + /** + * Toggle layer visibility + * @param {boolean} visible - Show/hide layer + */ + toggle(visible = !this.visible) { + this.visible = visible + this.setVisibility(visible) + } + + /** + * Set visibility for all layer IDs + * @param {boolean} visible + */ + setVisibility(visible) { + const visibility = visible ? 'visible' : 'none' + this.getLayerIds().forEach(layerId => { + if (this.map.getLayer(layerId)) { + this.map.setLayoutProperty(layerId, 'visibility', visibility) + } + }) + } + + /** + * Get source configuration (override in subclass) + * @returns {Object} MapLibre source config + */ + getSourceConfig() { + throw new Error('Must implement getSourceConfig()') + } + + /** + * Get layer configurations (override in subclass) + * @returns {Array} Array of MapLibre layer configs + */ + getLayerConfigs() { + throw new Error('Must implement getLayerConfigs()') + } + + /** + * Get all layer IDs for this layer + * @returns {Array} + */ + getLayerIds() { + return this.getLayerConfigs().map(config => config.id) + } +} diff --git a/app/javascript/maps_maplibre/layers/family_layer.js b/app/javascript/maps_maplibre/layers/family_layer.js new file mode 100644 index 00000000..42a1b19c --- /dev/null +++ b/app/javascript/maps_maplibre/layers/family_layer.js @@ -0,0 +1,151 @@ +import { BaseLayer } from './base_layer' + +/** + * Family layer showing family member locations + * Each member has unique color + */ +export class FamilyLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'family', ...options }) + this.memberColors = {} + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Member circles + { + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': 10, + 'circle-color': ['get', 'color'], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.9 + } + }, + + // Member labels + { + id: `${this.id}-labels`, + type: 'symbol', + source: this.sourceId, + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-size': 12, + 'text-offset': [0, 1.5], + 'text-anchor': 'top' + }, + paint: { + 'text-color': '#111827', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2 + } + }, + + // Pulse animation + { + id: `${this.id}-pulse`, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': [ + 'interpolate', + ['linear'], + ['zoom'], + 10, 15, + 15, 25 + ], + 'circle-color': ['get', 'color'], + 'circle-opacity': [ + 'interpolate', + ['linear'], + ['get', 'lastUpdate'], + Date.now() - 10000, 0, + Date.now(), 0.3 + ] + } + } + ] + } + + getLayerIds() { + return [this.id, `${this.id}-labels`, `${this.id}-pulse`] + } + + /** + * Update single family member location + * @param {Object} member - { id, name, latitude, longitude, color } + */ + updateMember(member) { + const features = this.data?.features || [] + + // Find existing or add new + const index = features.findIndex(f => f.properties.id === member.id) + + const feature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [member.longitude, member.latitude] + }, + properties: { + id: member.id, + name: member.name, + color: member.color || this.getMemberColor(member.id), + lastUpdate: Date.now() + } + } + + if (index >= 0) { + features[index] = feature + } else { + features.push(feature) + } + + this.update({ + type: 'FeatureCollection', + features + }) + } + + /** + * Get consistent color for member + */ + getMemberColor(memberId) { + if (!this.memberColors[memberId]) { + const colors = [ + '#3b82f6', '#10b981', '#f59e0b', + '#ef4444', '#8b5cf6', '#ec4899' + ] + const index = Object.keys(this.memberColors).length % colors.length + this.memberColors[memberId] = colors[index] + } + return this.memberColors[memberId] + } + + /** + * Remove family member + */ + removeMember(memberId) { + const features = this.data?.features || [] + const filtered = features.filter(f => f.properties.id !== memberId) + + this.update({ + type: 'FeatureCollection', + features: filtered + }) + } +} diff --git a/app/javascript/maps_maplibre/layers/fog_layer.js b/app/javascript/maps_maplibre/layers/fog_layer.js new file mode 100644 index 00000000..431226d6 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/fog_layer.js @@ -0,0 +1,140 @@ +/** + * Fog of war layer + * Shows explored vs unexplored areas using canvas overlay + * Does not extend BaseLayer as it uses canvas instead of MapLibre layers + */ +export class FogLayer { + constructor(map, options = {}) { + this.map = map + this.id = 'fog' + this.visible = options.visible !== undefined ? options.visible : false + this.canvas = null + this.ctx = null + this.clearRadius = options.clearRadius || 1000 // meters + this.points = [] + } + + add(data) { + this.points = data.features || [] + this.createCanvas() + if (this.visible) { + this.show() + } + this.render() + } + + update(data) { + this.points = data.features || [] + this.render() + } + + createCanvas() { + if (this.canvas) return + + // Create canvas overlay + this.canvas = document.createElement('canvas') + this.canvas.className = 'fog-canvas' + this.canvas.style.position = 'absolute' + this.canvas.style.top = '0' + this.canvas.style.left = '0' + this.canvas.style.pointerEvents = 'none' + this.canvas.style.zIndex = '10' + this.canvas.style.display = this.visible ? 'block' : 'none' + + this.ctx = this.canvas.getContext('2d') + + // Add to map container + const mapContainer = this.map.getContainer() + mapContainer.appendChild(this.canvas) + + // Update on map move/zoom/resize + this.map.on('move', () => this.render()) + this.map.on('zoom', () => this.render()) + this.map.on('resize', () => this.resizeCanvas()) + + this.resizeCanvas() + } + + resizeCanvas() { + if (!this.canvas) return + + const container = this.map.getContainer() + this.canvas.width = container.offsetWidth + this.canvas.height = container.offsetHeight + this.render() + } + + render() { + if (!this.canvas || !this.ctx || !this.visible) return + + const { width, height } = this.canvas + + // Clear canvas + this.ctx.clearRect(0, 0, width, height) + + // Draw fog overlay + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.6)' + this.ctx.fillRect(0, 0, width, height) + + // Clear circles around visited points + this.ctx.globalCompositeOperation = 'destination-out' + + this.points.forEach(feature => { + const coords = feature.geometry.coordinates + const point = this.map.project(coords) + + // Calculate pixel radius based on zoom level + const metersPerPixel = this.getMetersPerPixel(coords[1]) + const radiusPixels = this.clearRadius / metersPerPixel + + this.ctx.beginPath() + this.ctx.arc(point.x, point.y, radiusPixels, 0, Math.PI * 2) + this.ctx.fill() + }) + + this.ctx.globalCompositeOperation = 'source-over' + } + + getMetersPerPixel(latitude) { + const earthCircumference = 40075017 // meters at equator + const latitudeRadians = latitude * Math.PI / 180 + const zoom = this.map.getZoom() + return earthCircumference * Math.cos(latitudeRadians) / (256 * Math.pow(2, zoom)) + } + + show() { + this.visible = true + if (this.canvas) { + this.canvas.style.display = 'block' + this.render() + } + } + + hide() { + this.visible = false + if (this.canvas) { + this.canvas.style.display = 'none' + } + } + + toggle(visible = !this.visible) { + if (visible) { + this.show() + } else { + this.hide() + } + } + + remove() { + if (this.canvas) { + this.canvas.remove() + this.canvas = null + this.ctx = null + } + + // Remove event listeners + this.map.off('move', this.render) + this.map.off('zoom', this.render) + this.map.off('resize', this.resizeCanvas) + } +} diff --git a/app/javascript/maps_maplibre/layers/heatmap_layer.js b/app/javascript/maps_maplibre/layers/heatmap_layer.js new file mode 100644 index 00000000..3802e497 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/heatmap_layer.js @@ -0,0 +1,86 @@ +import { BaseLayer } from './base_layer' + +/** + * Heatmap layer showing point density + * Uses MapLibre's native heatmap for performance + * Fixed radius: 20 pixels + */ +export class HeatmapLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'heatmap', ...options }) + this.radius = 20 // Fixed radius + this.weight = options.weight || 1 + this.intensity = 1 // Fixed intensity + this.opacity = options.opacity || 0.6 + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + { + id: this.id, + type: 'heatmap', + source: this.sourceId, + paint: { + // Increase weight as diameter increases + 'heatmap-weight': [ + 'interpolate', + ['linear'], + ['get', 'weight'], + 0, 0, + 6, 1 + ], + + // Increase intensity as zoom increases + 'heatmap-intensity': [ + 'interpolate', + ['linear'], + ['zoom'], + 0, this.intensity, + 9, this.intensity * 3 + ], + + // Color ramp from blue to red + 'heatmap-color': [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, 'rgba(33,102,172,0)', + 0.2, 'rgb(103,169,207)', + 0.4, 'rgb(209,229,240)', + 0.6, 'rgb(253,219,199)', + 0.8, 'rgb(239,138,98)', + 1, 'rgb(178,24,43)' + ], + + // Fixed radius adjusted by zoom level + 'heatmap-radius': [ + 'interpolate', + ['linear'], + ['zoom'], + 0, this.radius, + 9, this.radius * 3 + ], + + // Transition from heatmap to circle layer by zoom level + 'heatmap-opacity': [ + 'interpolate', + ['linear'], + ['zoom'], + 7, this.opacity, + 9, 0 + ] + } + } + ] + } +} diff --git a/app/javascript/maps_maplibre/layers/photos_layer.js b/app/javascript/maps_maplibre/layers/photos_layer.js new file mode 100644 index 00000000..13df1381 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/photos_layer.js @@ -0,0 +1,220 @@ +import { BaseLayer } from './base_layer' +import maplibregl from 'maplibre-gl' +import { getCurrentTheme, getThemeColors } from '../utils/popup_theme' + +/** + * Photos layer with thumbnail markers + * Uses HTML DOM markers with circular image thumbnails + */ +export class PhotosLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'photos', ...options }) + this.markers = [] // Store marker references for cleanup + } + + async add(data) { + console.log('[PhotosLayer] add() called with data:', { + featuresCount: data.features?.length || 0, + sampleFeature: data.features?.[0], + visible: this.visible + }) + + // Store data + this.data = data + + // Create HTML markers for photos + this.createPhotoMarkers(data) + console.log('[PhotosLayer] Photo markers created') + } + + async update(data) { + console.log('[PhotosLayer] update() called with data:', { + featuresCount: data.features?.length || 0 + }) + + // Remove existing markers + this.clearMarkers() + + // Create new markers + this.createPhotoMarkers(data) + console.log('[PhotosLayer] Photo markers updated') + } + + /** + * Create HTML markers with photo thumbnails + * @param {Object} geojson - GeoJSON with photo features + */ + createPhotoMarkers(geojson) { + if (!geojson?.features) { + console.log('[PhotosLayer] No features to create markers for') + return + } + + console.log('[PhotosLayer] Creating markers for', geojson.features.length, 'photos') + console.log('[PhotosLayer] Sample feature:', geojson.features[0]) + + geojson.features.forEach((feature, index) => { + const { id, thumbnail_url, photo_url, taken_at } = feature.properties + const [lng, lat] = feature.geometry.coordinates + + if (index === 0) { + console.log('[PhotosLayer] First marker thumbnail_url:', thumbnail_url) + } + + // Create marker container (MapLibre will position this) + const container = document.createElement('div') + container.style.cssText = ` + display: ${this.visible ? 'block' : 'none'}; + ` + + // Create inner element for the image (this is what we'll transform) + const el = document.createElement('div') + el.className = 'photo-marker' + el.style.cssText = ` + width: 50px; + height: 50px; + border-radius: 50%; + cursor: pointer; + background-size: cover; + background-position: center; + background-image: url('${thumbnail_url}'); + border: 3px solid white; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + transition: transform 0.2s, box-shadow 0.2s; + ` + + // Add hover effect + el.addEventListener('mouseenter', () => { + el.style.transform = 'scale(1.2)' + el.style.boxShadow = '0 4px 8px rgba(0,0,0,0.4)' + el.style.zIndex = '1000' + }) + + el.addEventListener('mouseleave', () => { + el.style.transform = 'scale(1)' + el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)' + el.style.zIndex = '1' + }) + + // Add click handler to show popup + el.addEventListener('click', (e) => { + e.stopPropagation() + this.showPhotoPopup(feature) + }) + + // Add image element to container + container.appendChild(el) + + // Create MapLibre marker with container + const marker = new maplibregl.Marker({ element: container }) + .setLngLat([lng, lat]) + .addTo(this.map) + + this.markers.push(marker) + + if (index === 0) { + console.log('[PhotosLayer] First marker created at:', lng, lat) + } + }) + + console.log('[PhotosLayer] Created', this.markers.length, 'markers, visible:', this.visible) + } + + /** + * Show photo popup with image + * @param {Object} feature - GeoJSON feature with photo properties + */ + showPhotoPopup(feature) { + const { thumbnail_url, taken_at, filename, city, state, country, type, source } = feature.properties + const [lng, lat] = feature.geometry.coordinates + + const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown' + const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location' + const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo' + + // Get theme colors + const theme = getCurrentTheme() + const colors = getThemeColors(theme) + + // Create popup HTML with theme-aware styling + const popupHTML = ` +
+
+ ${filename || 'Photo'} +
+
+ ${filename ? `
${filename}
` : ''} +
📅 ${takenDate}
+
📍 ${location}
+
Coordinates: ${lat.toFixed(6)}, ${lng.toFixed(6)}
+ ${source ? `
Source: ${source}
` : ''} +
${mediaType}
+
+
+ ` + + // Create and show popup + new maplibregl.Popup({ + closeButton: true, + closeOnClick: true, + maxWidth: '400px' + }) + .setLngLat([lng, lat]) + .setHTML(popupHTML) + .addTo(this.map) + } + + /** + * Clear all markers from map + */ + clearMarkers() { + this.markers.forEach(marker => marker.remove()) + this.markers = [] + } + + /** + * Override remove to clean up markers + */ + remove() { + this.clearMarkers() + super.remove() + } + + /** + * Override show to display markers + */ + show() { + this.visible = true + this.markers.forEach(marker => { + marker.getElement().style.display = 'block' + }) + } + + /** + * Override hide to hide markers + */ + hide() { + this.visible = false + this.markers.forEach(marker => { + marker.getElement().style.display = 'none' + }) + } + + // Override these methods since we're not using source/layer approach + getSourceConfig() { + return null + } + + getLayerConfigs() { + return [] + } + + getLayerIds() { + return [] + } +} diff --git a/app/javascript/maps_maplibre/layers/places_layer.js b/app/javascript/maps_maplibre/layers/places_layer.js new file mode 100644 index 00000000..6e27a4e6 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/places_layer.js @@ -0,0 +1,66 @@ +import { BaseLayer } from './base_layer' + +/** + * Places layer showing user-created places with tags + * Different colors based on tags + */ +export class PlacesLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'places', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Place circles + { + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': 10, + 'circle-color': [ + 'coalesce', + ['get', 'color'], // Use tag color if available + '#6366f1' // Default indigo color + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.85 + } + }, + + // Place labels + { + id: `${this.id}-labels`, + type: 'symbol', + source: this.sourceId, + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-size': 11, + 'text-offset': [0, 1.3], + 'text-anchor': 'top' + }, + paint: { + 'text-color': '#111827', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2 + } + } + ] + } + + getLayerIds() { + return [this.id, `${this.id}-labels`] + } +} diff --git a/app/javascript/maps_maplibre/layers/points_layer.js b/app/javascript/maps_maplibre/layers/points_layer.js new file mode 100644 index 00000000..8a7f9d33 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/points_layer.js @@ -0,0 +1,37 @@ +import { BaseLayer } from './base_layer' + +/** + * Points layer for displaying individual location points + */ +export class PointsLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'points', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Individual points + { + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-color': '#3b82f6', + 'circle-radius': 6, + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + } + ] + } +} diff --git a/app/javascript/maps_maplibre/layers/recent_point_layer.js b/app/javascript/maps_maplibre/layers/recent_point_layer.js new file mode 100644 index 00000000..e1e90f1d --- /dev/null +++ b/app/javascript/maps_maplibre/layers/recent_point_layer.js @@ -0,0 +1,94 @@ +import { BaseLayer } from './base_layer' + +/** + * Recent point layer for displaying the most recent location in live mode + * This layer is always visible when live mode is enabled, regardless of points layer visibility + */ +export class RecentPointLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'recent-point', visible: true, ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Pulsing outer circle (animation effect) + { + id: `${this.id}-pulse`, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-color': '#ef4444', + 'circle-radius': [ + 'interpolate', + ['linear'], + ['zoom'], + 0, 8, + 20, 40 + ], + 'circle-opacity': 0.3 + } + }, + // Main point circle + { + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-color': '#ef4444', + 'circle-radius': [ + 'interpolate', + ['linear'], + ['zoom'], + 0, 6, + 20, 20 + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + } + ] + } + + /** + * Update layer with a single recent point + * @param {number} lon - Longitude + * @param {number} lat - Latitude + * @param {Object} properties - Additional point properties + */ + updateRecentPoint(lon, lat, properties = {}) { + const data = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [lon, lat] + }, + properties + } + ] + } + this.update(data) + } + + /** + * Clear the recent point + */ + clear() { + this.update({ + type: 'FeatureCollection', + features: [] + }) + } +} diff --git a/app/javascript/maps_maplibre/layers/routes_layer.js b/app/javascript/maps_maplibre/layers/routes_layer.js new file mode 100644 index 00000000..56009ed1 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/routes_layer.js @@ -0,0 +1,145 @@ +import { BaseLayer } from './base_layer' + +/** + * Routes layer showing travel paths + * Connects points chronologically with solid color + */ +export class RoutesLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'routes', ...options }) + this.maxGapHours = options.maxGapHours || 5 // Max hours between points to connect + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + { + id: this.id, + type: 'line', + source: this.sourceId, + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + paint: { + 'line-color': '#f97316', // Solid orange color + 'line-width': 3, + 'line-opacity': 0.8 + } + } + ] + } + + /** + * Calculate haversine distance between two points in kilometers + * @param {number} lat1 - First point latitude + * @param {number} lon1 - First point longitude + * @param {number} lat2 - Second point latitude + * @param {number} lon2 - Second point longitude + * @returns {number} Distance in kilometers + */ + static haversineDistance(lat1, lon1, lat2, lon2) { + const R = 6371 // Earth's radius in kilometers + const dLat = (lat2 - lat1) * Math.PI / 180 + const dLon = (lon2 - lon1) * Math.PI / 180 + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) * Math.sin(dLon / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return R * c + } + + /** + * Convert points to route LineStrings with splitting + * Matches V1's route splitting logic for consistency + * @param {Array} points - Points from API + * @param {Object} options - Splitting options + * @returns {Object} GeoJSON FeatureCollection + */ + static pointsToRoutes(points, options = {}) { + if (points.length < 2) { + return { type: 'FeatureCollection', features: [] } + } + + // Default thresholds (matching V1 defaults from polylines.js) + const distanceThresholdKm = (options.distanceThresholdMeters || 500) / 1000 + const timeThresholdMinutes = options.timeThresholdMinutes || 60 + + // Sort by timestamp + const sorted = points.slice().sort((a, b) => a.timestamp - b.timestamp) + + // Split into segments based on distance and time gaps (like V1) + const segments = [] + let currentSegment = [sorted[0]] + + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1] + const curr = sorted[i] + + // Calculate distance between consecutive points + const distance = this.haversineDistance( + prev.latitude, prev.longitude, + curr.latitude, curr.longitude + ) + + // Calculate time difference in minutes + const timeDiff = (curr.timestamp - prev.timestamp) / 60 + + // Split if either threshold is exceeded (matching V1 logic) + if (distance > distanceThresholdKm || timeDiff > timeThresholdMinutes) { + if (currentSegment.length > 1) { + segments.push(currentSegment) + } + currentSegment = [curr] + } else { + currentSegment.push(curr) + } + } + + if (currentSegment.length > 1) { + segments.push(currentSegment) + } + + // Convert segments to LineStrings + const features = segments.map(segment => { + const coordinates = segment.map(p => [p.longitude, p.latitude]) + + // Calculate total distance for the segment + let totalDistance = 0 + for (let i = 0; i < segment.length - 1; i++) { + totalDistance += this.haversineDistance( + segment[i].latitude, segment[i].longitude, + segment[i + 1].latitude, segment[i + 1].longitude + ) + } + + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates + }, + properties: { + pointCount: segment.length, + startTime: segment[0].timestamp, + endTime: segment[segment.length - 1].timestamp, + distance: totalDistance + } + } + }) + + return { + type: 'FeatureCollection', + features + } + } +} diff --git a/app/javascript/maps_maplibre/layers/scratch_layer.js b/app/javascript/maps_maplibre/layers/scratch_layer.js new file mode 100644 index 00000000..0aff4ac4 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/scratch_layer.js @@ -0,0 +1,178 @@ +import { BaseLayer } from './base_layer' + +/** + * Scratch map layer + * Highlights countries that have been visited based on points' country_name attribute + * Extracts country names from points (via database country relationship) + * Matches country names to polygons in lib/assets/countries.geojson by name field + * "Scratches off" visited countries by overlaying gold/amber polygons + */ +export class ScratchLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'scratch', ...options }) + this.visitedCountries = new Set() + this.countriesData = null + this.loadingCountries = null // Promise for loading countries + this.apiClient = options.apiClient // For authenticated requests + } + + async add(data) { + const points = data.features || [] + + // Load country boundaries + await this.loadCountryBoundaries() + + // Detect which countries have been visited + this.visitedCountries = this.detectCountriesFromPoints(points) + + // Create GeoJSON with visited countries + const geojson = this.createCountriesGeoJSON() + + super.add(geojson) + } + + async update(data) { + const points = data.features || [] + + // Countries already loaded from add() + this.visitedCountries = this.detectCountriesFromPoints(points) + + const geojson = this.createCountriesGeoJSON() + + super.update(geojson) + } + + /** + * Extract country names from points' country_name attribute + * Points already have country association from database (country_id relationship) + * @param {Array} points - Array of point features with properties.country_name + * @returns {Set} Set of country names + */ + detectCountriesFromPoints(points) { + const visitedCountries = new Set() + + // Extract unique country names from points + points.forEach(point => { + const countryName = point.properties?.country_name + + if (countryName && countryName !== 'Unknown') { + visitedCountries.add(countryName) + } + }) + + return visitedCountries + } + + /** + * Load country boundaries from internal API endpoint + * Endpoint: GET /api/v1/countries/borders + */ + async loadCountryBoundaries() { + // Return existing promise if already loading + if (this.loadingCountries) { + return this.loadingCountries + } + + // Return immediately if already loaded + if (this.countriesData) { + return + } + + this.loadingCountries = (async () => { + try { + // Use internal API endpoint with authentication + const headers = {} + if (this.apiClient) { + headers['Authorization'] = `Bearer ${this.apiClient.apiKey}` + } + + const response = await fetch('/api/v1/countries/borders.json', { + headers: headers + }) + + if (!response.ok) { + throw new Error(`Failed to load country borders: ${response.statusText}`) + } + + this.countriesData = await response.json() + } catch (error) { + console.error('[ScratchLayer] Failed to load country boundaries:', error) + // Fallback to empty data + this.countriesData = { type: 'FeatureCollection', features: [] } + } + })() + + return this.loadingCountries + } + + /** + * Create GeoJSON for visited countries + * Matches visited country names from points to boundary polygons by name + * @returns {Object} GeoJSON FeatureCollection + */ + createCountriesGeoJSON() { + if (!this.countriesData || this.visitedCountries.size === 0) { + return { + type: 'FeatureCollection', + features: [] + } + } + + // Filter country features by matching name field to visited country names + const visitedFeatures = this.countriesData.features.filter(country => { + const countryName = country.properties.name || country.properties.NAME + + if (!countryName) return false + + // Case-insensitive exact match + return Array.from(this.visitedCountries).some(visitedName => + countryName.toLowerCase() === visitedName.toLowerCase() + ) + }) + + return { + type: 'FeatureCollection', + features: visitedFeatures + } + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Country fill + { + id: this.id, + type: 'fill', + source: this.sourceId, + paint: { + 'fill-color': '#fbbf24', // Amber/gold color + 'fill-opacity': 0.3 + } + }, + // Country outline + { + id: `${this.id}-outline`, + type: 'line', + source: this.sourceId, + paint: { + 'line-color': '#f59e0b', + 'line-width': 1, + 'line-opacity': 0.6 + } + } + ] + } + + getLayerIds() { + return [this.id, `${this.id}-outline`] + } +} diff --git a/app/javascript/maps_maplibre/layers/selected_points_layer.js b/app/javascript/maps_maplibre/layers/selected_points_layer.js new file mode 100644 index 00000000..d133b333 --- /dev/null +++ b/app/javascript/maps_maplibre/layers/selected_points_layer.js @@ -0,0 +1,96 @@ +import { BaseLayer } from './base_layer' + +/** + * Layer for displaying selected points with distinct styling + */ +export class SelectedPointsLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'selected-points', ...options }) + this.pointIds = [] + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Outer circle (highlight) + { + id: `${this.id}-highlight`, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': 8, + 'circle-color': '#ef4444', + 'circle-opacity': 0.3 + } + }, + // Inner circle (selected point) + { + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': 5, + 'circle-color': '#ef4444', + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + } + ] + } + + /** + * Get layer IDs for this layer + */ + getLayerIds() { + return [`${this.id}-highlight`, this.id] + } + + /** + * Update selected points and store their IDs + */ + updateSelectedPoints(geojson) { + this.data = geojson + + // Extract point IDs + this.pointIds = geojson.features.map(f => f.properties.id) + + // Update map source + this.update(geojson) + + console.log('[SelectedPointsLayer] Updated with', this.pointIds.length, 'points') + } + + /** + * Get IDs of selected points + */ + getSelectedPointIds() { + return this.pointIds + } + + /** + * Clear selected points + */ + clearSelection() { + this.pointIds = [] + this.update({ + type: 'FeatureCollection', + features: [] + }) + } + + /** + * Get count of selected points + */ + getCount() { + return this.pointIds.length + } +} diff --git a/app/javascript/maps_maplibre/layers/selection_layer.js b/app/javascript/maps_maplibre/layers/selection_layer.js new file mode 100644 index 00000000..c03c41cd --- /dev/null +++ b/app/javascript/maps_maplibre/layers/selection_layer.js @@ -0,0 +1,200 @@ +import { BaseLayer } from './base_layer' + +/** + * Selection layer for drawing selection rectangles on the map + * Allows users to select areas by clicking and dragging + */ +export class SelectionLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'selection', ...options }) + this.isDrawing = false + this.startPoint = null + this.currentRect = null + this.onSelectionComplete = options.onSelectionComplete || (() => {}) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Fill layer + { + id: `${this.id}-fill`, + type: 'fill', + source: this.sourceId, + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.1 + } + }, + // Outline layer + { + id: `${this.id}-outline`, + type: 'line', + source: this.sourceId, + paint: { + 'line-color': '#3b82f6', + 'line-width': 2, + 'line-dasharray': [2, 2] + } + } + ] + } + + /** + * Get layer IDs for this layer + */ + getLayerIds() { + return [`${this.id}-fill`, `${this.id}-outline`] + } + + /** + * Enable selection mode + */ + enableSelectionMode() { + this.map.getCanvas().style.cursor = 'crosshair' + + // Add mouse event listeners + this.handleMouseDown = this.onMouseDown.bind(this) + this.handleMouseMove = this.onMouseMove.bind(this) + this.handleMouseUp = this.onMouseUp.bind(this) + + this.map.on('mousedown', this.handleMouseDown) + this.map.on('mousemove', this.handleMouseMove) + this.map.on('mouseup', this.handleMouseUp) + + console.log('[SelectionLayer] Selection mode enabled') + } + + /** + * Disable selection mode + */ + disableSelectionMode() { + this.map.getCanvas().style.cursor = '' + + // Remove mouse event listeners + if (this.handleMouseDown) { + this.map.off('mousedown', this.handleMouseDown) + this.map.off('mousemove', this.handleMouseMove) + this.map.off('mouseup', this.handleMouseUp) + } + + // Clear selection + this.clearSelection() + + console.log('[SelectionLayer] Selection mode disabled') + } + + /** + * Handle mouse down - start drawing + */ + onMouseDown(e) { + // Prevent default to stop map panning during selection + e.preventDefault() + + this.isDrawing = true + this.startPoint = e.lngLat + + console.log('[SelectionLayer] Started drawing at:', this.startPoint) + } + + /** + * Handle mouse move - update rectangle + */ + onMouseMove(e) { + if (!this.isDrawing || !this.startPoint) return + + const endPoint = e.lngLat + + // Create rectangle from start and end points + const rect = this.createRectangle(this.startPoint, endPoint) + + // Update layer with rectangle + this.update({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [rect] + } + }] + }) + + this.currentRect = { start: this.startPoint, end: endPoint } + } + + /** + * Handle mouse up - finish drawing + */ + onMouseUp(e) { + if (!this.isDrawing || !this.startPoint) return + + this.isDrawing = false + const endPoint = e.lngLat + + // Calculate bounds + const bounds = this.calculateBounds(this.startPoint, endPoint) + + console.log('[SelectionLayer] Selection completed:', bounds) + + // Notify callback + this.onSelectionComplete(bounds) + + this.startPoint = null + } + + /** + * Create rectangle coordinates from two points + */ + createRectangle(start, end) { + return [ + [start.lng, start.lat], + [end.lng, start.lat], + [end.lng, end.lat], + [start.lng, end.lat], + [start.lng, start.lat] + ] + } + + /** + * Calculate bounds from two points + */ + calculateBounds(start, end) { + return { + minLng: Math.min(start.lng, end.lng), + maxLng: Math.max(start.lng, end.lng), + minLat: Math.min(start.lat, end.lat), + maxLat: Math.max(start.lat, end.lat) + } + } + + /** + * Clear current selection + */ + clearSelection() { + this.update({ + type: 'FeatureCollection', + features: [] + }) + this.currentRect = null + this.startPoint = null + this.isDrawing = false + } + + /** + * Remove layer and cleanup + */ + remove() { + this.disableSelectionMode() + super.remove() + } +} diff --git a/app/javascript/maps_maplibre/layers/tracks_layer.js b/app/javascript/maps_maplibre/layers/tracks_layer.js new file mode 100644 index 00000000..76a8161d --- /dev/null +++ b/app/javascript/maps_maplibre/layers/tracks_layer.js @@ -0,0 +1,39 @@ +import { BaseLayer } from './base_layer' + +/** + * Tracks layer for saved routes + */ +export class TracksLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'tracks', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + { + id: this.id, + type: 'line', + source: this.sourceId, + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + paint: { + 'line-color': ['get', 'color'], + 'line-width': 4, + 'line-opacity': 0.7 + } + } + ] + } +} diff --git a/app/javascript/maps_maplibre/layers/visits_layer.js b/app/javascript/maps_maplibre/layers/visits_layer.js new file mode 100644 index 00000000..44b3cb8f --- /dev/null +++ b/app/javascript/maps_maplibre/layers/visits_layer.js @@ -0,0 +1,66 @@ +import { BaseLayer } from './base_layer' + +/** + * Visits layer showing suggested and confirmed visits + * Yellow = suggested, Green = confirmed + */ +export class VisitsLayer extends BaseLayer { + constructor(map, options = {}) { + super(map, { id: 'visits', ...options }) + } + + getSourceConfig() { + return { + type: 'geojson', + data: this.data || { + type: 'FeatureCollection', + features: [] + } + } + } + + getLayerConfigs() { + return [ + // Visit circles + { + id: this.id, + type: 'circle', + source: this.sourceId, + paint: { + 'circle-radius': 12, + 'circle-color': [ + 'case', + ['==', ['get', 'status'], 'confirmed'], '#22c55e', // Green for confirmed + '#eab308' // Yellow for suggested + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.9 + } + }, + + // Visit labels + { + id: `${this.id}-labels`, + type: 'symbol', + source: this.sourceId, + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-size': 11, + 'text-offset': [0, 1.5], + 'text-anchor': 'top' + }, + paint: { + 'text-color': '#111827', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2 + } + } + ] + } + + getLayerIds() { + return [this.id, `${this.id}-labels`] + } +} diff --git a/app/javascript/maps_maplibre/services/api_client.js b/app/javascript/maps_maplibre/services/api_client.js new file mode 100644 index 00000000..661f5f0e --- /dev/null +++ b/app/javascript/maps_maplibre/services/api_client.js @@ -0,0 +1,357 @@ +/** + * API client for Maps V2 + * Wraps all API endpoints with consistent error handling + */ +export class ApiClient { + constructor(apiKey) { + this.apiKey = apiKey + this.baseURL = '/api/v1' + } + + /** + * Fetch points for date range (paginated) + * @param {Object} options - { start_at, end_at, page, per_page } + * @returns {Promise} { points, currentPage, totalPages } + */ + async fetchPoints({ start_at, end_at, page = 1, per_page = 1000 }) { + const params = new URLSearchParams({ + start_at, + end_at, + page: page.toString(), + per_page: per_page.toString() + }) + + const response = await fetch(`${this.baseURL}/points?${params}`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch points: ${response.statusText}`) + } + + const points = await response.json() + + return { + points, + currentPage: parseInt(response.headers.get('X-Current-Page') || '1'), + totalPages: parseInt(response.headers.get('X-Total-Pages') || '1') + } + } + + /** + * Fetch all points for date range (handles pagination) + * @param {Object} options - { start_at, end_at, onProgress } + * @returns {Promise} All points + */ + async fetchAllPoints({ start_at, end_at, onProgress = null }) { + const allPoints = [] + let page = 1 + let totalPages = 1 + + do { + const { points, currentPage, totalPages: total } = + await this.fetchPoints({ start_at, end_at, page, per_page: 1000 }) + + allPoints.push(...points) + totalPages = total + page++ + + if (onProgress) { + // Avoid division by zero - if no pages, progress is 100% + const progress = totalPages > 0 ? currentPage / totalPages : 1.0 + onProgress({ + loaded: allPoints.length, + currentPage, + totalPages, + progress + }) + } + } while (page <= totalPages) + + return allPoints + } + + /** + * Fetch visits for date range + */ + async fetchVisits({ start_at, end_at }) { + const params = new URLSearchParams({ start_at, end_at }) + + const response = await fetch(`${this.baseURL}/visits?${params}`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch visits: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch places optionally filtered by tags + */ + async fetchPlaces({ tag_ids = [] } = {}) { + const params = new URLSearchParams() + + if (tag_ids && tag_ids.length > 0) { + tag_ids.forEach(id => params.append('tag_ids[]', id)) + } + + const url = `${this.baseURL}/places${params.toString() ? '?' + params.toString() : ''}` + + const response = await fetch(url, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch places: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch photos for date range + */ + async fetchPhotos({ start_at, end_at }) { + // Photos API uses start_date/end_date parameters + // Pass dates as-is (matching V1 behavior) + const params = new URLSearchParams({ + start_date: start_at, + end_date: end_at + }) + + const url = `${this.baseURL}/photos?${params}` + + const response = await fetch(url, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch photos: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch areas + */ + async fetchAreas() { + const response = await fetch(`${this.baseURL}/areas`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch areas: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch single area by ID + * @param {number} areaId - Area ID + */ + async fetchArea(areaId) { + const response = await fetch(`${this.baseURL}/areas/${areaId}`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch area: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch tracks + */ + async fetchTracks() { + const response = await fetch(`${this.baseURL}/tracks`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch tracks: ${response.statusText}`) + } + + return response.json() + } + + /** + * Create area + * @param {Object} area - Area data + */ + async createArea(area) { + const response = await fetch(`${this.baseURL}/areas`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ area }) + }) + + if (!response.ok) { + throw new Error(`Failed to create area: ${response.statusText}`) + } + + return response.json() + } + + /** + * Delete area by ID + * @param {number} areaId - Area ID + */ + async deleteArea(areaId) { + const response = await fetch(`${this.baseURL}/areas/${areaId}`, { + method: 'DELETE', + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to delete area: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch points within a geographic area + * @param {Object} options - { start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude } + * @returns {Promise} Points within the area + */ + async fetchPointsInArea({ start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }) { + const params = new URLSearchParams({ + start_at, + end_at, + min_longitude: min_longitude.toString(), + max_longitude: max_longitude.toString(), + min_latitude: min_latitude.toString(), + max_latitude: max_latitude.toString(), + per_page: '10000' // Get all points in area (up to 10k) + }) + + const response = await fetch(`${this.baseURL}/points?${params}`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch points in area: ${response.statusText}`) + } + + return response.json() + } + + /** + * Fetch visits within a geographic area + * @param {Object} options - { start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng } + * @returns {Promise} Visits within the area + */ + async fetchVisitsInArea({ start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }) { + const params = new URLSearchParams({ + start_at, + end_at, + selection: 'true', + sw_lat: sw_lat.toString(), + sw_lng: sw_lng.toString(), + ne_lat: ne_lat.toString(), + ne_lng: ne_lng.toString() + }) + + const response = await fetch(`${this.baseURL}/visits?${params}`, { + headers: this.getHeaders() + }) + + if (!response.ok) { + throw new Error(`Failed to fetch visits in area: ${response.statusText}`) + } + + return response.json() + } + + /** + * Bulk delete points + * @param {Array} pointIds - Array of point IDs to delete + * @returns {Promise} { message, count } + */ + async bulkDeletePoints(pointIds) { + const response = await fetch(`${this.baseURL}/points/bulk_destroy`, { + method: 'DELETE', + headers: this.getHeaders(), + body: JSON.stringify({ point_ids: pointIds }) + }) + + if (!response.ok) { + throw new Error(`Failed to delete points: ${response.statusText}`) + } + + return response.json() + } + + /** + * Update visit status (confirm/decline) + * @param {number} visitId - Visit ID + * @param {string} status - 'confirmed' or 'declined' + * @returns {Promise} Updated visit + */ + async updateVisitStatus(visitId, status) { + const response = await fetch(`${this.baseURL}/visits/${visitId}`, { + method: 'PATCH', + headers: this.getHeaders(), + body: JSON.stringify({ visit: { status } }) + }) + + if (!response.ok) { + throw new Error(`Failed to update visit status: ${response.statusText}`) + } + + return response.json() + } + + /** + * Merge multiple visits + * @param {Array} visitIds - Array of visit IDs to merge + * @returns {Promise} Merged visit + */ + async mergeVisits(visitIds) { + const response = await fetch(`${this.baseURL}/visits/merge`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ visit_ids: visitIds }) + }) + + if (!response.ok) { + throw new Error(`Failed to merge visits: ${response.statusText}`) + } + + return response.json() + } + + /** + * Bulk update visit status + * @param {Array} visitIds - Array of visit IDs to update + * @param {string} status - 'confirmed' or 'declined' + * @returns {Promise} Update result + */ + async bulkUpdateVisits(visitIds, status) { + const response = await fetch(`${this.baseURL}/visits/bulk_update`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ visit_ids: visitIds, status }) + }) + + if (!response.ok) { + throw new Error(`Failed to bulk update visits: ${response.statusText}`) + } + + return response.json() + } + + getHeaders() { + return { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + } +} diff --git a/app/javascript/maps_maplibre/services/location_search_service.js b/app/javascript/maps_maplibre/services/location_search_service.js new file mode 100644 index 00000000..52c09c6b --- /dev/null +++ b/app/javascript/maps_maplibre/services/location_search_service.js @@ -0,0 +1,117 @@ +/** + * Location Search Service + * Handles API calls for location search (suggestions and visits) + */ + +export class LocationSearchService { + constructor(apiKey) { + this.apiKey = apiKey + this.baseHeaders = { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + } + + /** + * Fetch location suggestions based on query + * @param {string} query - Search query + * @returns {Promise} Array of location suggestions + */ + async fetchSuggestions(query) { + if (!query || query.length < 2) { + return [] + } + + try { + const response = await fetch( + `/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, + { + method: 'GET', + headers: this.baseHeaders + } + ) + + if (!response.ok) { + throw new Error(`Suggestions API error: ${response.status}`) + } + + const data = await response.json() + + // Transform suggestions to expected format + // API returns coordinates as [lat, lon], we need { lat, lon } + const suggestions = (data.suggestions || []).map(suggestion => ({ + name: suggestion.name, + address: suggestion.address, + lat: suggestion.coordinates?.[0], + lon: suggestion.coordinates?.[1], + type: suggestion.type + })) + + return suggestions + } catch (error) { + console.error('LocationSearchService: Suggestion fetch error:', error) + throw error + } + } + + /** + * Search for visits at a specific location + * @param {Object} params - Search parameters + * @param {number} params.lat - Latitude + * @param {number} params.lon - Longitude + * @param {string} params.name - Location name + * @param {string} params.address - Location address + * @returns {Promise} Search results with locations and visits + */ + async searchVisits({ lat, lon, name, address = '' }) { + try { + const params = new URLSearchParams({ + lat: lat.toString(), + lon: lon.toString(), + name, + address + }) + + const response = await fetch(`/api/v1/locations?${params}`, { + method: 'GET', + headers: this.baseHeaders + }) + + if (!response.ok) { + throw new Error(`Location search API error: ${response.status}`) + } + + const data = await response.json() + return data + } catch (error) { + console.error('LocationSearchService: Visit search error:', error) + throw error + } + } + + /** + * Create a new visit + * @param {Object} visitData - Visit data + * @returns {Promise} Created visit + */ + async createVisit(visitData) { + try { + const response = await fetch('/api/v1/visits', { + method: 'POST', + headers: this.baseHeaders, + body: JSON.stringify({ visit: visitData }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.message || 'Failed to create visit') + } + + return data + } catch (error) { + console.error('LocationSearchService: Create visit error:', error) + throw error + } + } +} diff --git a/app/javascript/maps_maplibre/utils/cleanup_helper.js b/app/javascript/maps_maplibre/utils/cleanup_helper.js new file mode 100644 index 00000000..4ef723ef --- /dev/null +++ b/app/javascript/maps_maplibre/utils/cleanup_helper.js @@ -0,0 +1,49 @@ +/** + * Helper for tracking and cleaning up resources + * Prevents memory leaks by tracking event listeners, intervals, timeouts, and observers + */ +export class CleanupHelper { + constructor() { + this.listeners = [] + this.intervals = [] + this.timeouts = [] + this.observers = [] + } + + addEventListener(target, event, handler, options) { + target.addEventListener(event, handler, options) + this.listeners.push({ target, event, handler, options }) + } + + setInterval(callback, delay) { + const id = setInterval(callback, delay) + this.intervals.push(id) + return id + } + + setTimeout(callback, delay) { + const id = setTimeout(callback, delay) + this.timeouts.push(id) + return id + } + + addObserver(observer) { + this.observers.push(observer) + } + + cleanup() { + this.listeners.forEach(({ target, event, handler, options }) => { + target.removeEventListener(event, handler, options) + }) + this.listeners = [] + + this.intervals.forEach(id => clearInterval(id)) + this.intervals = [] + + this.timeouts.forEach(id => clearTimeout(id)) + this.timeouts = [] + + this.observers.forEach(observer => observer.disconnect()) + this.observers = [] + } +} diff --git a/app/javascript/maps_maplibre/utils/fps_monitor.js b/app/javascript/maps_maplibre/utils/fps_monitor.js new file mode 100644 index 00000000..9496a871 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/fps_monitor.js @@ -0,0 +1,49 @@ +/** + * FPS (Frames Per Second) monitor + * Tracks rendering performance + */ +export class FPSMonitor { + constructor(sampleSize = 60) { + this.sampleSize = sampleSize + this.frames = [] + this.lastTime = performance.now() + this.isRunning = false + this.rafId = null + } + + start() { + if (this.isRunning) return + this.isRunning = true + this.#tick() + } + + stop() { + this.isRunning = false + if (this.rafId) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + } + + getFPS() { + if (this.frames.length === 0) return 0 + const avg = this.frames.reduce((a, b) => a + b, 0) / this.frames.length + return Math.round(avg) + } + + #tick = () => { + if (!this.isRunning) return + + const now = performance.now() + const delta = now - this.lastTime + const fps = 1000 / delta + + this.frames.push(fps) + if (this.frames.length > this.sampleSize) { + this.frames.shift() + } + + this.lastTime = now + this.rafId = requestAnimationFrame(this.#tick) + } +} diff --git a/app/javascript/maps_maplibre/utils/geojson_transformers.js b/app/javascript/maps_maplibre/utils/geojson_transformers.js new file mode 100644 index 00000000..9cfe30e6 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/geojson_transformers.js @@ -0,0 +1,54 @@ +/** + * Transform points array to GeoJSON FeatureCollection + * @param {Array} points - Array of point objects from API + * @returns {Object} GeoJSON FeatureCollection + */ +export function pointsToGeoJSON(points) { + return { + type: 'FeatureCollection', + features: points.map(point => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [point.longitude, point.latitude] + }, + properties: { + id: point.id, + timestamp: point.timestamp, + altitude: point.altitude, + battery: point.battery, + accuracy: point.accuracy, + velocity: point.velocity, + country_name: point.country_name + } + })) + } +} + +/** + * Format timestamp for display + * @param {number|string} timestamp - Unix timestamp (seconds) or ISO 8601 string + * @returns {string} Formatted date/time + */ +export function formatTimestamp(timestamp) { + // Handle different timestamp formats + let date + if (typeof timestamp === 'string') { + // ISO 8601 string + date = new Date(timestamp) + } else if (timestamp < 10000000000) { + // Unix timestamp in seconds + date = new Date(timestamp * 1000) + } else { + // Unix timestamp in milliseconds + date = new Date(timestamp) + } + + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} diff --git a/app/javascript/maps_maplibre/utils/geometry.js b/app/javascript/maps_maplibre/utils/geometry.js new file mode 100644 index 00000000..b9bac686 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/geometry.js @@ -0,0 +1,69 @@ +/** + * Calculate distance between two points in meters + * @param {Array} point1 - [lng, lat] + * @param {Array} point2 - [lng, lat] + * @returns {number} Distance in meters + */ +export function calculateDistance(point1, point2) { + const [lng1, lat1] = point1 + const [lng2, lat2] = point2 + + const R = 6371000 // Earth radius in meters + const φ1 = lat1 * Math.PI / 180 + const φ2 = lat2 * Math.PI / 180 + const Δφ = (lat2 - lat1) * Math.PI / 180 + const Δλ = (lng2 - lng1) * Math.PI / 180 + + const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ / 2) * Math.sin(Δλ / 2) + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + return R * c +} + +/** + * Create circle polygon + * @param {Array} center - [lng, lat] + * @param {number} radiusInMeters + * @param {number} points - Number of points in polygon + * @returns {Array} Coordinates array + */ +export function createCircle(center, radiusInMeters, points = 64) { + const [lng, lat] = center + const coords = [] + + const distanceX = radiusInMeters / (111320 * Math.cos(lat * Math.PI / 180)) + const distanceY = radiusInMeters / 110540 + + for (let i = 0; i < points; i++) { + const theta = (i / points) * (2 * Math.PI) + const x = distanceX * Math.cos(theta) + const y = distanceY * Math.sin(theta) + coords.push([lng + x, lat + y]) + } + + coords.push(coords[0]) // Close the circle + + return coords +} + +/** + * Create rectangle from bounds + * @param {Object} bounds - { minLng, minLat, maxLng, maxLat } + * @returns {Array} Coordinates array + */ +export function createRectangle(bounds) { + const { minLng, minLat, maxLng, maxLat } = bounds + + return [ + [ + [minLng, minLat], + [maxLng, minLat], + [maxLng, maxLat], + [minLng, maxLat], + [minLng, minLat] + ] + ] +} diff --git a/app/javascript/maps_maplibre/utils/lazy_loader.js b/app/javascript/maps_maplibre/utils/lazy_loader.js new file mode 100644 index 00000000..ca268b98 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/lazy_loader.js @@ -0,0 +1,76 @@ +/** + * Lazy loader for heavy map layers + * Reduces initial bundle size by loading layers on demand + */ +export class LazyLoader { + constructor() { + this.cache = new Map() + this.loading = new Map() + } + + /** + * Load layer class dynamically + * @param {string} name - Layer name (e.g., 'fog', 'scratch') + * @returns {Promise} + */ + async loadLayer(name) { + // Return cached + if (this.cache.has(name)) { + return this.cache.get(name) + } + + // Wait for loading + if (this.loading.has(name)) { + return this.loading.get(name) + } + + // Start loading + const loadPromise = this.#load(name) + this.loading.set(name, loadPromise) + + try { + const LayerClass = await loadPromise + this.cache.set(name, LayerClass) + this.loading.delete(name) + return LayerClass + } catch (error) { + this.loading.delete(name) + throw error + } + } + + async #load(name) { + const paths = { + 'fog': () => import('../layers/fog_layer.js'), + 'scratch': () => import('../layers/scratch_layer.js') + } + + const loader = paths[name] + if (!loader) { + throw new Error(`Unknown layer: ${name}`) + } + + const module = await loader() + return module[this.#getClassName(name)] + } + + #getClassName(name) { + // fog -> FogLayer, scratch -> ScratchLayer + return name.charAt(0).toUpperCase() + name.slice(1) + 'Layer' + } + + /** + * Preload layers + * @param {string[]} names + */ + async preload(names) { + return Promise.all(names.map(name => this.loadLayer(name))) + } + + clear() { + this.cache.clear() + this.loading.clear() + } +} + +export const lazyLoader = new LazyLoader() diff --git a/app/javascript/maps_maplibre/utils/performance_monitor.js b/app/javascript/maps_maplibre/utils/performance_monitor.js new file mode 100644 index 00000000..7f1b15b1 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/performance_monitor.js @@ -0,0 +1,108 @@ +/** + * Performance monitoring utility + * Tracks timing metrics and memory usage + */ +export class PerformanceMonitor { + constructor() { + this.marks = new Map() + this.metrics = [] + } + + /** + * Start timing + * @param {string} name + */ + mark(name) { + this.marks.set(name, performance.now()) + } + + /** + * End timing and record + * @param {string} name + * @returns {number} Duration in ms + */ + measure(name) { + const startTime = this.marks.get(name) + if (!startTime) { + console.warn(`No mark found for: ${name}`) + return 0 + } + + const duration = performance.now() - startTime + this.marks.delete(name) + + this.metrics.push({ + name, + duration, + timestamp: Date.now() + }) + + return duration + } + + /** + * Get performance report + * @returns {Object} + */ + getReport() { + const grouped = this.metrics.reduce((acc, metric) => { + if (!acc[metric.name]) { + acc[metric.name] = [] + } + acc[metric.name].push(metric.duration) + return acc + }, {}) + + const report = {} + for (const [name, durations] of Object.entries(grouped)) { + const avg = durations.reduce((a, b) => a + b, 0) / durations.length + const min = Math.min(...durations) + const max = Math.max(...durations) + + report[name] = { + count: durations.length, + avg: Math.round(avg), + min: Math.round(min), + max: Math.round(max) + } + } + + return report + } + + /** + * Get memory usage + * @returns {Object|null} + */ + getMemoryUsage() { + if (!performance.memory) return null + + return { + used: Math.round(performance.memory.usedJSHeapSize / 1048576), + total: Math.round(performance.memory.totalJSHeapSize / 1048576), + limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576) + } + } + + /** + * Log report to console + */ + logReport() { + console.group('Performance Report') + console.table(this.getReport()) + + const memory = this.getMemoryUsage() + if (memory) { + console.log(`Memory: ${memory.used}MB / ${memory.total}MB (limit: ${memory.limit}MB)`) + } + + console.groupEnd() + } + + clear() { + this.marks.clear() + this.metrics = [] + } +} + +export const performanceMonitor = new PerformanceMonitor() diff --git a/app/javascript/maps_maplibre/utils/popup_theme.js b/app/javascript/maps_maplibre/utils/popup_theme.js new file mode 100644 index 00000000..95e8777c --- /dev/null +++ b/app/javascript/maps_maplibre/utils/popup_theme.js @@ -0,0 +1,120 @@ +/** + * Theme utilities for MapLibre popups + * Provides consistent theming across all popup types + */ + +/** + * Get current theme from document + * @returns {string} 'dark' or 'light' + */ +export function getCurrentTheme() { + if (document.documentElement.getAttribute('data-theme') === 'dark' || + document.documentElement.classList.contains('dark')) { + return 'dark' + } + return 'light' +} + +/** + * Get theme-aware color values + * @param {string} theme - 'dark' or 'light' + * @returns {Object} Color values for the theme + */ +export function getThemeColors(theme = getCurrentTheme()) { + if (theme === 'dark') { + return { + // Background colors + background: '#1f2937', + backgroundAlt: '#374151', + + // Text colors + textPrimary: '#f9fafb', + textSecondary: '#d1d5db', + textMuted: '#9ca3af', + + // Border colors + border: '#4b5563', + borderLight: '#374151', + + // Accent colors + accent: '#3b82f6', + accentHover: '#2563eb', + + // Badge colors + badgeSuggested: { bg: '#713f12', text: '#fef3c7' }, + badgeConfirmed: { bg: '#065f46', text: '#d1fae5' } + } + } else { + return { + // Background colors + background: '#ffffff', + backgroundAlt: '#f9fafb', + + // Text colors + textPrimary: '#111827', + textSecondary: '#374151', + textMuted: '#6b7280', + + // Border colors + border: '#e5e7eb', + borderLight: '#f3f4f6', + + // Accent colors + accent: '#3b82f6', + accentHover: '#2563eb', + + // Badge colors + badgeSuggested: { bg: '#fef3c7', text: '#92400e' }, + badgeConfirmed: { bg: '#d1fae5', text: '#065f46' } + } + } +} + +/** + * Get base popup styles as inline CSS + * @param {string} theme - 'dark' or 'light' + * @returns {string} CSS string for inline styles + */ +export function getPopupBaseStyles(theme = getCurrentTheme()) { + const colors = getThemeColors(theme) + + return ` + font-family: system-ui, -apple-system, sans-serif; + background-color: ${colors.background}; + color: ${colors.textPrimary}; + ` +} + +/** + * Get popup container class with theme + * @param {string} baseClass - Base CSS class name + * @param {string} theme - 'dark' or 'light' + * @returns {string} Class name with theme + */ +export function getPopupClass(baseClass, theme = getCurrentTheme()) { + return `${baseClass} ${baseClass}--${theme}` +} + +/** + * Listen for theme changes and update popup if needed + * @param {Function} callback - Callback to execute on theme change + * @returns {Function} Cleanup function to remove listener + */ +export function onThemeChange(callback) { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && + (mutation.attributeName === 'data-theme' || + mutation.attributeName === 'class')) { + callback(getCurrentTheme()) + } + }) + }) + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme', 'class'] + }) + + return () => observer.disconnect() +} diff --git a/app/javascript/maps_maplibre/utils/progressive_loader.js b/app/javascript/maps_maplibre/utils/progressive_loader.js new file mode 100644 index 00000000..f284398d --- /dev/null +++ b/app/javascript/maps_maplibre/utils/progressive_loader.js @@ -0,0 +1,101 @@ +/** + * Progressive loader for large datasets + * Loads data in chunks with progress feedback and abort capability + */ +export class ProgressiveLoader { + constructor(options = {}) { + this.onProgress = options.onProgress || null + this.onComplete = options.onComplete || null + this.abortController = null + } + + /** + * Load data progressively + * @param {Function} fetchFn - Function that fetches one page + * @param {Object} options - { batchSize, maxConcurrent, maxPoints } + * @returns {Promise} + */ + async load(fetchFn, options = {}) { + const { + batchSize = 1000, + maxConcurrent = 3, + maxPoints = 100000 // Limit for safety + } = options + + this.abortController = new AbortController() + const allData = [] + let page = 1 + let totalPages = 1 + const activeRequests = [] + + try { + do { + // Check abort + if (this.abortController.signal.aborted) { + throw new Error('Load cancelled') + } + + // Check max points limit + if (allData.length >= maxPoints) { + console.warn(`Reached max points limit: ${maxPoints}`) + break + } + + // Limit concurrent requests + while (activeRequests.length >= maxConcurrent) { + await Promise.race(activeRequests) + } + + const requestPromise = fetchFn({ + page, + per_page: batchSize, + signal: this.abortController.signal + }).then(result => { + allData.push(...result.data) + + if (result.totalPages) { + totalPages = result.totalPages + } + + this.onProgress?.({ + loaded: allData.length, + total: Math.min(totalPages * batchSize, maxPoints), + currentPage: page, + totalPages, + progress: page / totalPages + }) + + // Remove from active + const idx = activeRequests.indexOf(requestPromise) + if (idx > -1) activeRequests.splice(idx, 1) + + return result + }) + + activeRequests.push(requestPromise) + page++ + + } while (page <= totalPages && allData.length < maxPoints) + + // Wait for remaining + await Promise.all(activeRequests) + + this.onComplete?.(allData) + return allData + + } catch (error) { + if (error.name === 'AbortError' || error.message === 'Load cancelled') { + console.log('Progressive load cancelled') + return allData // Return partial data + } + throw error + } + } + + /** + * Cancel loading + */ + cancel() { + this.abortController?.abort() + } +} diff --git a/app/javascript/maps_maplibre/utils/search_manager.js b/app/javascript/maps_maplibre/utils/search_manager.js new file mode 100644 index 00000000..99e1a026 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/search_manager.js @@ -0,0 +1,729 @@ +/** + * Search Manager + * Manages location search functionality for Maps V2 + */ + +import { LocationSearchService } from '../services/location_search_service.js' + +export class SearchManager { + constructor(map, apiKey) { + this.map = map + this.service = new LocationSearchService(apiKey) + this.searchInput = null + this.resultsContainer = null + this.debounceTimer = null + this.debounceDelay = 300 // ms + this.currentMarker = null + this.currentVisitsData = null // Store visits data for click handling + } + + /** + * Initialize search manager with DOM elements + * @param {HTMLInputElement} searchInput - Search input element + * @param {HTMLElement} resultsContainer - Container for search results + */ + initialize(searchInput, resultsContainer) { + this.searchInput = searchInput + this.resultsContainer = resultsContainer + + if (!this.searchInput || !this.resultsContainer) { + console.warn('SearchManager: Missing required DOM elements') + return + } + + this.attachEventListeners() + } + + /** + * Attach event listeners to search input + */ + attachEventListeners() { + // Input event with debouncing + this.searchInput.addEventListener('input', (e) => { + this.handleSearchInput(e.target.value) + }) + + // Prevent results from hiding when clicking inside results container + this.resultsContainer.addEventListener('mousedown', (e) => { + e.preventDefault() // Prevent blur event on search input + }) + + // Clear results when clicking outside + document.addEventListener('click', (e) => { + if (!this.searchInput.contains(e.target) && !this.resultsContainer.contains(e.target)) { + // Delay to allow animations to complete + setTimeout(() => { + this.clearResults() + }, 100) + } + }) + + // Handle Enter key + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + const firstResult = this.resultsContainer.querySelector('.search-result-item') + if (firstResult) { + firstResult.click() + } + } + }) + } + + /** + * Handle search input with debouncing + * @param {string} query - Search query + */ + handleSearchInput(query) { + clearTimeout(this.debounceTimer) + + if (!query || query.length < 2) { + this.clearResults() + return + } + + this.debounceTimer = setTimeout(async () => { + try { + this.showLoading() + const suggestions = await this.service.fetchSuggestions(query) + this.displayResults(suggestions) + } catch (error) { + this.showError('Failed to fetch suggestions') + console.error('SearchManager: Search error:', error) + } + }, this.debounceDelay) + } + + /** + * Display search results + * @param {Array} suggestions - Array of location suggestions + */ + displayResults(suggestions) { + this.clearResults() + + if (!suggestions || suggestions.length === 0) { + this.showNoResults() + return + } + + suggestions.forEach(suggestion => { + const resultItem = this.createResultItem(suggestion) + this.resultsContainer.appendChild(resultItem) + }) + + this.resultsContainer.classList.remove('hidden') + } + + /** + * Create a result item element + * @param {Object} suggestion - Location suggestion + * @returns {HTMLElement} Result item element + */ + createResultItem(suggestion) { + const item = document.createElement('div') + item.className = 'search-result-item p-3 hover:bg-base-200 cursor-pointer rounded-lg transition-colors' + item.setAttribute('data-lat', suggestion.lat) + item.setAttribute('data-lon', suggestion.lon) + + const name = document.createElement('div') + name.className = 'font-medium text-sm' + name.textContent = suggestion.name || 'Unknown location' + + if (suggestion.address) { + const address = document.createElement('div') + address.className = 'text-xs text-base-content/60 mt-1' + address.textContent = suggestion.address + item.appendChild(name) + item.appendChild(address) + } else { + item.appendChild(name) + } + + item.addEventListener('click', () => { + this.handleResultClick(suggestion) + }) + + return item + } + + /** + * Handle click on search result + * @param {Object} location - Selected location + */ + async handleResultClick(location) { + // Fly to location on map + this.map.flyTo({ + center: [location.lon, location.lat], + zoom: 15, + duration: 1000 + }) + + // Add temporary marker + this.addSearchMarker(location.lon, location.lat) + + // Update search input + if (this.searchInput) { + this.searchInput.value = location.name || '' + } + + // Show loading state in results + this.showVisitsLoading(location.name) + + // Search for visits at this location + try { + const visitsData = await this.service.searchVisits({ + lat: location.lat, + lon: location.lon, + name: location.name, + address: location.address || '' + }) + + // Display visits results + this.displayVisitsResults(visitsData, location) + } catch (error) { + console.error('SearchManager: Failed to fetch visits:', error) + this.showError('Failed to load visits for this location') + } + + // Dispatch custom event for other components + this.dispatchSearchEvent(location) + } + + /** + * Add a temporary marker at search location + * @param {number} lon - Longitude + * @param {number} lat - Latitude + */ + addSearchMarker(lon, lat) { + // Remove existing marker + if (this.currentMarker) { + this.currentMarker.remove() + } + + // Create marker element + const el = document.createElement('div') + el.className = 'search-marker' + el.style.cssText = ` + width: 30px; + height: 30px; + background-color: #3b82f6; + border: 3px solid white; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + cursor: pointer; + ` + + // Add marker to map (MapLibre GL style) + if (this.map.getSource) { + // Use MapLibre marker + const maplibregl = window.maplibregl + if (maplibregl) { + this.currentMarker = new maplibregl.Marker({ element: el }) + .setLngLat([lon, lat]) + .addTo(this.map) + } + } + } + + /** + * Dispatch custom search event + * @param {Object} location - Selected location + */ + dispatchSearchEvent(location) { + const event = new CustomEvent('location-search:selected', { + detail: { location }, + bubbles: true + }) + document.dispatchEvent(event) + } + + /** + * Show loading indicator + */ + showLoading() { + this.clearResults() + this.resultsContainer.innerHTML = ` +
+ + Searching... +
+ ` + this.resultsContainer.classList.remove('hidden') + } + + /** + * Show no results message + */ + showNoResults() { + this.resultsContainer.innerHTML = ` +
+ No locations found +
+ ` + this.resultsContainer.classList.remove('hidden') + } + + /** + * Show error message + * @param {string} message - Error message + */ + showError(message) { + this.resultsContainer.innerHTML = ` +
+ ${message} +
+ ` + this.resultsContainer.classList.remove('hidden') + } + + /** + * Show loading state while fetching visits + * @param {string} locationName - Name of the location being searched + */ + showVisitsLoading(locationName) { + this.resultsContainer.innerHTML = ` +
+
+ + Searching for visits... +
+
${this.escapeHtml(locationName)}
+
+ ` + this.resultsContainer.classList.remove('hidden') + } + + /** + * Display visits results + * @param {Object} visitsData - Visits data from API + * @param {Object} location - Selected location + */ + displayVisitsResults(visitsData, location) { + // Store visits data for click handling + this.currentVisitsData = visitsData + + if (!visitsData.locations || visitsData.locations.length === 0) { + this.resultsContainer.innerHTML = ` +
+
📍
+
No visits found
+
No visits found for "${this.escapeHtml(location.name)}"
+
+ ` + this.resultsContainer.classList.remove('hidden') + return + } + + // Display visits grouped by location + let html = ` +
+
Found ${visitsData.total_locations} location(s)
+
for "${this.escapeHtml(location.name)}"
+
+ ` + + visitsData.locations.forEach((loc, index) => { + html += this.buildLocationVisitsHtml(loc, index) + }) + + this.resultsContainer.innerHTML = html + this.resultsContainer.classList.remove('hidden') + + // Attach event listeners to year toggles and visit items + this.attachYearToggleListeners() + } + + /** + * Build HTML for a location with its visits + * @param {Object} location - Location with visits + * @param {number} index - Location index + * @returns {string} HTML string + */ + buildLocationVisitsHtml(location, index) { + const visits = location.visits || [] + if (visits.length === 0) return '' + + // Handle case where visits are sorted newest first + const sortedVisits = [...visits].sort((a, b) => new Date(a.date) - new Date(b.date)) + const firstVisit = sortedVisits[0] + const lastVisit = sortedVisits[sortedVisits.length - 1] + const visitsByYear = this.groupVisitsByYear(visits) + + // Use place_name, address, or coordinates as fallback + const displayName = location.place_name || location.address || + `Location (${location.coordinates?.[0]?.toFixed(4)}, ${location.coordinates?.[1]?.toFixed(4)})` + + return ` +
+
+
${this.escapeHtml(displayName)}
+ ${location.address && location.place_name !== location.address ? + `
${this.escapeHtml(location.address)}
` : ''} +
+
${location.total_visits} visit(s)
+
+ first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)} +
+
+
+ + +
+ ${Object.entries(visitsByYear).map(([year, yearVisits]) => ` +
+
+ ${year} +
+ ${yearVisits.length} visits + +
+
+ +
+ `).join('')} +
+
+ ` + } + + /** + * Group visits by year + * @param {Array} visits - Array of visits + * @returns {Object} Visits grouped by year + */ + groupVisitsByYear(visits) { + const groups = {} + visits.forEach(visit => { + const year = new Date(visit.date).getFullYear().toString() + if (!groups[year]) { + groups[year] = [] + } + groups[year].push(visit) + }) + return groups + } + + /** + * Attach event listeners to year toggle elements + */ + attachYearToggleListeners() { + const toggles = this.resultsContainer.querySelectorAll('.year-toggle') + toggles.forEach(toggle => { + toggle.addEventListener('click', (e) => { + const locationIndex = e.currentTarget.dataset.locationIndex + const year = e.currentTarget.dataset.year + const visitsContainer = document.getElementById(`year-${locationIndex}-${year}`) + const arrow = e.currentTarget.querySelector('.year-arrow') + + if (visitsContainer) { + visitsContainer.classList.toggle('hidden') + arrow.style.transform = visitsContainer.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)' + } + }) + }) + + // Attach event listeners to individual visit items + const visitItems = this.resultsContainer.querySelectorAll('.visit-item') + visitItems.forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation() + const locationIndex = parseInt(item.dataset.locationIndex) + const visitIndex = parseInt(item.dataset.visitIndex) + this.handleVisitClick(locationIndex, visitIndex) + }) + }) + } + + /** + * Handle click on individual visit item + * @param {number} locationIndex - Index of location in results + * @param {number} visitIndex - Index of visit within location + */ + handleVisitClick(locationIndex, visitIndex) { + if (!this.currentVisitsData || !this.currentVisitsData.locations) return + + const location = this.currentVisitsData.locations[locationIndex] + if (!location || !location.visits) return + + const visit = location.visits[visitIndex] + if (!visit) return + + // Fly to visit coordinates (more precise than location coordinates) + const [lat, lon] = visit.coordinates || location.coordinates + this.map.flyTo({ + center: [lon, lat], + zoom: 18, + duration: 1000 + }) + + // Extract visit details + const visitDetails = visit.visit_details || {} + const startTime = visitDetails.start_time || visit.date + const endTime = visitDetails.end_time || visit.date + const placeName = location.place_name || location.address || 'Unnamed Location' + + // Open create visit modal + this.openCreateVisitModal({ + name: placeName, + latitude: lat, + longitude: lon, + started_at: startTime, + ended_at: endTime + }) + } + + /** + * Open modal to create a visit with prefilled data + * @param {Object} visitData - Visit data to prefill + */ + openCreateVisitModal(visitData) { + // Create modal HTML + const modalId = 'create-visit-modal' + + // Remove existing modal if present + const existingModal = document.getElementById(modalId) + if (existingModal) { + existingModal.remove() + } + + const modal = document.createElement('div') + modal.id = modalId + modal.innerHTML = ` + + + ` + + document.body.appendChild(modal) + + // Attach event listeners + const form = modal.querySelector('form') + const closeBtn = modal.querySelector('[data-action="close"]') + const modalToggle = modal.querySelector(`#${modalId}-toggle`) + const backdrop = modal.querySelector('.modal-backdrop') + + form.addEventListener('submit', (e) => { + e.preventDefault() + this.submitCreateVisit(form, modal) + }) + + closeBtn.addEventListener('click', () => { + modalToggle.checked = false + setTimeout(() => modal.remove(), 300) + }) + + backdrop.addEventListener('click', () => { + modalToggle.checked = false + setTimeout(() => modal.remove(), 300) + }) + } + + /** + * Submit create visit form + * @param {HTMLFormElement} form - Form element + * @param {HTMLElement} modal - Modal element + */ + async submitCreateVisit(form, modal) { + const submitBtn = form.querySelector('button[type="submit"]') + const submitText = submitBtn.querySelector('.submit-text') + const spinner = submitBtn.querySelector('.loading') + + // Disable submit button and show loading + submitBtn.disabled = true + submitText.classList.add('hidden') + spinner.classList.remove('hidden') + + try { + const formData = new FormData(form) + const visitData = { + name: formData.get('name'), + latitude: parseFloat(formData.get('latitude')), + longitude: parseFloat(formData.get('longitude')), + started_at: formData.get('started_at'), + ended_at: formData.get('ended_at'), + status: 'confirmed' + } + + const response = await this.service.createVisit(visitData) + + if (response.error) { + throw new Error(response.error) + } + + // Success - close modal and show success message + const modalToggle = modal.querySelector('.modal-toggle') + modalToggle.checked = false + setTimeout(() => modal.remove(), 300) + + // Show success notification + this.showSuccessNotification('Visit created successfully!') + + // Dispatch custom event for other components to react + document.dispatchEvent(new CustomEvent('visit:created', { + detail: { visit: response, coordinates: [visitData.longitude, visitData.latitude] } + })) + + } catch (error) { + console.error('Failed to create visit:', error) + alert(`Failed to create visit: ${error.message}`) + + // Re-enable submit button + submitBtn.disabled = false + submitText.classList.remove('hidden') + spinner.classList.add('hidden') + } + } + + /** + * Show success notification + * @param {string} message - Success message + */ + showSuccessNotification(message) { + const notification = document.createElement('div') + notification.className = 'toast toast-top toast-end z-[9999]' + notification.innerHTML = ` +
+ ✓ ${this.escapeHtml(message)} +
+ ` + document.body.appendChild(notification) + + setTimeout(() => { + notification.remove() + }, 3000) + } + + /** + * Format datetime for input field (YYYY-MM-DDTHH:MM) + * @param {string} dateString - Date string + * @returns {string} Formatted datetime + */ + formatDateTimeForInput(dateString) { + const date = new Date(dateString) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` + } + + /** + * Format date in short format + * @param {string} dateString - Date string + * @returns {string} Formatted date + */ + formatDateShort(dateString) { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + } + + /** + * Format date and time + * @param {string} dateString - Date string + * @returns {string} Formatted date and time + */ + formatDateTime(dateString) { + const date = new Date(dateString) + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + /** + * Escape HTML to prevent XSS + * @param {string} str - String to escape + * @returns {string} Escaped string + */ + escapeHtml(str) { + if (!str) return '' + const div = document.createElement('div') + div.textContent = str + return div.innerHTML + } + + /** + * Clear search results + */ + clearResults() { + if (this.resultsContainer) { + this.resultsContainer.innerHTML = '' + this.resultsContainer.classList.add('hidden') + } + } + + /** + * Clear search marker + */ + clearMarker() { + if (this.currentMarker) { + this.currentMarker.remove() + this.currentMarker = null + } + } + + /** + * Cleanup + */ + destroy() { + clearTimeout(this.debounceTimer) + this.clearMarker() + this.clearResults() + } +} diff --git a/app/javascript/maps_maplibre/utils/settings_manager.js b/app/javascript/maps_maplibre/utils/settings_manager.js new file mode 100644 index 00000000..8e5cbf42 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/settings_manager.js @@ -0,0 +1,296 @@ +/** + * Settings manager for persisting user preferences + * Supports both localStorage (fallback) and backend API (primary) + */ + +const STORAGE_KEY = 'dawarich-maps-maplibre-settings' + +const DEFAULT_SETTINGS = { + mapStyle: 'light', + enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map + // Advanced settings + routeOpacity: 1.0, + fogOfWarRadius: 1000, + fogOfWarThreshold: 1, + metersBetweenRoutes: 500, + minutesBetweenRoutes: 60, + pointsRenderingMode: 'raw', + speedColoredRoutes: false +} + +// Mapping between v2 layer names and v1 layer names in enabled_map_layers array +const LAYER_NAME_MAP = { + 'Points': 'pointsVisible', + 'Routes': 'routesVisible', + 'Heatmap': 'heatmapEnabled', + 'Visits': 'visitsEnabled', + 'Photos': 'photosEnabled', + 'Areas': 'areasEnabled', + 'Tracks': 'tracksEnabled', + 'Fog of War': 'fogEnabled', + 'Scratch map': 'scratchEnabled' +} + +// Mapping between frontend settings and backend API keys +const BACKEND_SETTINGS_MAP = { + mapStyle: 'maps_maplibre_style', + enabledMapLayers: 'enabled_map_layers' +} + +export class SettingsManager { + static apiKey = null + static cachedSettings = null + + /** + * Initialize settings manager with API key + * @param {string} apiKey - User's API key for backend requests + */ + static initialize(apiKey) { + this.apiKey = apiKey + this.cachedSettings = null // Clear cache on initialization + } + + /** + * Get all settings (localStorage first, then merge with defaults) + * Converts enabled_map_layers array to individual boolean flags + * Uses cached settings if available to avoid race conditions + * @returns {Object} Settings object + */ + static getSettings() { + // Return cached settings if available + if (this.cachedSettings) { + return { ...this.cachedSettings } + } + + try { + const stored = localStorage.getItem(STORAGE_KEY) + const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS + + // Convert enabled_map_layers array to individual boolean flags + const expandedSettings = this._expandLayerSettings(settings) + + // Cache the settings + this.cachedSettings = expandedSettings + + return { ...expandedSettings } + } catch (error) { + console.error('Failed to load settings:', error) + return DEFAULT_SETTINGS + } + } + + /** + * Convert enabled_map_layers array to individual boolean flags + * @param {Object} settings - Settings with enabledMapLayers array + * @returns {Object} Settings with individual layer booleans + */ + static _expandLayerSettings(settings) { + const enabledLayers = settings.enabledMapLayers || [] + + // Set boolean flags based on array contents + Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => { + settings[settingKey] = enabledLayers.includes(layerName) + }) + + return settings + } + + /** + * Convert individual boolean flags to enabled_map_layers array + * @param {Object} settings - Settings with individual layer booleans + * @returns {Array} Array of enabled layer names + */ + static _collapseLayerSettings(settings) { + const enabledLayers = [] + + Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => { + if (settings[settingKey] === true) { + enabledLayers.push(layerName) + } + }) + + return enabledLayers + } + + /** + * Load settings from backend API + * @returns {Promise} Settings object from backend + */ + static async loadFromBackend() { + if (!this.apiKey) { + console.warn('[Settings] API key not set, cannot load from backend') + return null + } + + try { + const response = await fetch('/api/v1/settings', { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`Failed to load settings: ${response.status}`) + } + + const data = await response.json() + const backendSettings = data.settings + + // Convert backend settings to frontend format + const frontendSettings = {} + Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => { + if (backendKey in backendSettings) { + frontendSettings[frontendKey] = backendSettings[backendKey] + } + }) + + // Merge with defaults, but prioritize backend's enabled_map_layers completely + const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings } + + // If backend has enabled_map_layers, use it as-is (don't merge with defaults) + if (backendSettings.enabled_map_layers) { + mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers + } + + // Convert enabled_map_layers array to individual boolean flags + const expandedSettings = this._expandLayerSettings(mergedSettings) + + // Save to localStorage and cache + this.saveToLocalStorage(expandedSettings) + + return expandedSettings + } catch (error) { + console.error('[Settings] Failed to load from backend:', error) + return null + } + } + + /** + * Save all settings to localStorage and update cache + * @param {Object} settings - Settings object + */ + static saveToLocalStorage(settings) { + try { + // Update cache first + this.cachedSettings = { ...settings } + // Then save to localStorage + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + } catch (error) { + console.error('Failed to save settings to localStorage:', error) + } + } + + /** + * Save settings to backend API + * @param {Object} settings - Settings to save + * @returns {Promise} Success status + */ + static async saveToBackend(settings) { + if (!this.apiKey) { + console.warn('[Settings] API key not set, cannot save to backend') + return false + } + + try { + // Convert individual layer booleans to enabled_map_layers array + const enabledMapLayers = this._collapseLayerSettings(settings) + + // Convert frontend settings to backend format + const backendSettings = {} + Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => { + if (frontendKey === 'enabledMapLayers') { + // Use the collapsed array + backendSettings[backendKey] = enabledMapLayers + } else if (frontendKey in settings) { + backendSettings[backendKey] = settings[frontendKey] + } + }) + + const response = await fetch('/api/v1/settings', { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ settings: backendSettings }) + }) + + if (!response.ok) { + throw new Error(`Failed to save settings: ${response.status}`) + } + + console.log('[Settings] Saved to backend successfully:', backendSettings) + return true + } catch (error) { + console.error('[Settings] Failed to save to backend:', error) + return false + } + } + + /** + * Get a specific setting + * @param {string} key - Setting key + * @returns {*} Setting value + */ + static getSetting(key) { + return this.getSettings()[key] + } + + /** + * Update a specific setting (saves to both localStorage and backend) + * @param {string} key - Setting key + * @param {*} value - New value + */ + static async updateSetting(key, value) { + const settings = this.getSettings() + settings[key] = value + + // If this is a layer visibility setting, also update the enabledMapLayers array + // This ensures the array is in sync before backend save + const isLayerSetting = Object.values(LAYER_NAME_MAP).includes(key) + if (isLayerSetting) { + settings.enabledMapLayers = this._collapseLayerSettings(settings) + } + + // Save to localStorage immediately + this.saveToLocalStorage(settings) + + // Save to backend (non-blocking) + this.saveToBackend(settings).catch(error => { + console.warn('[Settings] Backend save failed, but localStorage updated:', error) + }) + } + + /** + * Reset to defaults + */ + static resetToDefaults() { + try { + localStorage.removeItem(STORAGE_KEY) + this.cachedSettings = null // Clear cache + + // Also reset on backend + if (this.apiKey) { + this.saveToBackend(DEFAULT_SETTINGS).catch(error => { + console.warn('[Settings] Failed to reset backend settings:', error) + }) + } + } catch (error) { + console.error('Failed to reset settings:', error) + } + } + + /** + * Sync settings: load from backend and merge with localStorage + * Call this on app initialization + * @returns {Promise} Merged settings + */ + static async sync() { + const backendSettings = await this.loadFromBackend() + if (backendSettings) { + return backendSettings + } + return this.getSettings() + } +} diff --git a/app/javascript/maps_maplibre/utils/speed_colors.js b/app/javascript/maps_maplibre/utils/speed_colors.js new file mode 100644 index 00000000..112ac1d0 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/speed_colors.js @@ -0,0 +1,140 @@ +/** + * Speed color utilities for route visualization + * Provides speed calculation and color interpolation for route segments + */ + +// Default color stops for speed visualization +export const colorStopsFallback = [ + { speed: 0, color: '#00ff00' }, // Stationary/very slow (green) + { speed: 15, color: '#00ffff' }, // Walking/jogging (cyan) + { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta) + { speed: 50, color: '#ffff00' }, // Urban driving (yellow) + { speed: 100, color: '#ff3300' } // Highway driving (red) +] + +/** + * Encode color stops array to string format for storage + * @param {Array} arr - Array of {speed, color} objects + * @returns {string} Encoded string (e.g., "0:#00ff00|15:#00ffff") + */ +export function colorFormatEncode(arr) { + return arr.map(item => `${item.speed}:${item.color}`).join('|') +} + +/** + * Decode color stops string to array format + * @param {string} str - Encoded color stops string + * @returns {Array} Array of {speed, color} objects + */ +export function colorFormatDecode(str) { + return str.split('|').map(segment => { + const [speed, color] = segment.split(':') + return { speed: Number(speed), color } + }) +} + +/** + * Convert hex color to RGB object + * @param {string} hex - Hex color (e.g., "#ff0000") + * @returns {Object} RGB object {r, g, b} + */ +function hexToRGB(hex) { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return { r, g, b } +} + +/** + * Calculate speed between two points + * @param {Object} point1 - First point with lat, lon, timestamp + * @param {Object} point2 - Second point with lat, lon, timestamp + * @returns {number} Speed in km/h + */ +export function calculateSpeed(point1, point2) { + if (!point1 || !point2 || !point1.timestamp || !point2.timestamp) { + return 0 + } + + const distanceKm = haversineDistance( + point1.latitude, point1.longitude, + point2.latitude, point2.longitude + ) + const timeDiffSeconds = point2.timestamp - point1.timestamp + + // Handle edge cases + if (timeDiffSeconds <= 0 || distanceKm <= 0) { + return 0 + } + + const speedKmh = (distanceKm / timeDiffSeconds) * 3600 + + // Cap speed at reasonable maximum (150 km/h) + const MAX_SPEED = 150 + return Math.min(speedKmh, MAX_SPEED) +} + +/** + * Calculate haversine distance between two points + * @param {number} lat1 - First point latitude + * @param {number} lon1 - First point longitude + * @param {number} lat2 - Second point latitude + * @param {number} lon2 - Second point longitude + * @returns {number} Distance in kilometers + */ +function haversineDistance(lat1, lon1, lat2, lon2) { + const R = 6371 // Earth's radius in kilometers + const dLat = (lat2 - lat1) * Math.PI / 180 + const dLon = (lon2 - lon1) * Math.PI / 180 + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) * Math.sin(dLon / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return R * c +} + +/** + * Get color for a given speed with interpolation + * @param {number} speedKmh - Speed in km/h + * @param {boolean} useSpeedColors - Whether to use speed-based coloring + * @param {string} speedColorScale - Encoded color scale string + * @returns {string} RGB color string (e.g., "rgb(255, 0, 0)") + */ +export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) { + if (!useSpeedColors) { + return '#f97316' // Default orange color + } + + let colorStops + + try { + colorStops = colorFormatDecode(speedColorScale).map(stop => ({ + ...stop, + rgb: hexToRGB(stop.color) + })) + } catch (error) { + // If user has given invalid values, use fallback + colorStops = colorStopsFallback.map(stop => ({ + ...stop, + rgb: hexToRGB(stop.color) + })) + } + + // Find the appropriate color segment and interpolate + for (let i = 1; i < colorStops.length; i++) { + if (speedKmh <= colorStops[i].speed) { + const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed) + const color1 = colorStops[i-1].rgb + const color2 = colorStops[i].rgb + + const r = Math.round(color1.r + (color2.r - color1.r) * ratio) + const g = Math.round(color1.g + (color2.g - color1.g) * ratio) + const b = Math.round(color1.b + (color2.b - color1.b) * ratio) + + return `rgb(${r}, ${g}, ${b})` + } + } + + // If speed exceeds all stops, return the last color + return colorStops[colorStops.length - 1].color +} diff --git a/app/javascript/maps_maplibre/utils/style_manager.js b/app/javascript/maps_maplibre/utils/style_manager.js new file mode 100644 index 00000000..f71749b4 --- /dev/null +++ b/app/javascript/maps_maplibre/utils/style_manager.js @@ -0,0 +1,113 @@ +/** + * Style Manager for MapLibre GL styles + * Loads and configures local map styles with dynamic tile source + */ + +const TILE_SOURCE_URL = 'https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt' + +// Cache for loaded styles +const styleCache = {} + +/** + * Available map styles + */ +export const MAP_STYLES = { + dark: 'dark', + light: 'light', + white: 'white', + black: 'black', + grayscale: 'grayscale' +} + +/** + * Load a style JSON file via fetch + * @param {string} styleName - Name of the style + * @returns {Promise} Style object + */ +async function loadStyleFile(styleName) { + // Check cache first + if (styleCache[styleName]) { + return styleCache[styleName] + } + + // Fetch the style file from the public assets + const response = await fetch(`/maps_maplibre/styles/${styleName}.json`) + if (!response.ok) { + throw new Error(`Failed to load style: ${styleName} (${response.status})`) + } + + const style = await response.json() + styleCache[styleName] = style + return style +} + +/** + * Get a map style with configured tile source + * @param {string} styleName - Name of the style (dark, light, white, black, grayscale) + * @returns {Promise} MapLibre style object + */ +export async function getMapStyle(styleName = 'light') { + try { + // Load the style file + const style = await loadStyleFile(styleName) + + // Clone the style to avoid mutating the cached object + const clonedStyle = JSON.parse(JSON.stringify(style)) + + // Update the tile source URL + if (clonedStyle.sources && clonedStyle.sources.protomaps) { + clonedStyle.sources.protomaps = { + type: 'vector', + tiles: [TILE_SOURCE_URL], + minzoom: 0, + maxzoom: 14, + attribution: clonedStyle.sources.protomaps.attribution || + 'Protomaps © OpenStreetMap' + } + } + + return clonedStyle + } catch (error) { + console.error(`Error loading style '${styleName}':`, error) + // Fall back to light style if the requested style fails + if (styleName !== 'light') { + console.warn(`Falling back to 'light' style`) + return getMapStyle('light') + } + throw error + } +} + +/** + * Get list of available style names + * @returns {string[]} Array of style names + */ +export function getAvailableStyles() { + return Object.keys(MAP_STYLES) +} + +/** + * Get style display name + * @param {string} styleName - Style identifier + * @returns {string} Human-readable style name + */ +export function getStyleDisplayName(styleName) { + const displayNames = { + dark: 'Dark', + light: 'Light', + white: 'White', + black: 'Black', + grayscale: 'Grayscale' + } + return displayNames[styleName] || styleName.charAt(0).toUpperCase() + styleName.slice(1) +} + +/** + * Preload all styles into cache for faster switching + * @returns {Promise} + */ +export async function preloadAllStyles() { + const styleNames = getAvailableStyles() + await Promise.all(styleNames.map(name => loadStyleFile(name))) + console.log('All map styles preloaded') +} diff --git a/app/javascript/maps_maplibre/utils/websocket_manager.js b/app/javascript/maps_maplibre/utils/websocket_manager.js new file mode 100644 index 00000000..c16e48fe --- /dev/null +++ b/app/javascript/maps_maplibre/utils/websocket_manager.js @@ -0,0 +1,82 @@ +/** + * WebSocket connection manager + * Handles reconnection logic and connection state + */ +export class WebSocketManager { + constructor(options = {}) { + this.maxReconnectAttempts = options.maxReconnectAttempts || 5 + this.reconnectDelay = options.reconnectDelay || 1000 + this.reconnectAttempts = 0 + this.isConnected = false + this.subscription = null + this.onConnect = options.onConnect || null + this.onDisconnect = options.onDisconnect || null + this.onError = options.onError || null + } + + /** + * Connect to channel + * @param {Object} subscription - ActionCable subscription + */ + connect(subscription) { + this.subscription = subscription + + // Monitor connection state + this.subscription.connected = () => { + this.isConnected = true + this.reconnectAttempts = 0 + this.onConnect?.() + } + + this.subscription.disconnected = () => { + this.isConnected = false + this.onDisconnect?.() + this.attemptReconnect() + } + } + + /** + * Attempt to reconnect + */ + attemptReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.onError?.(new Error('Max reconnect attempts reached')) + return + } + + this.reconnectAttempts++ + + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + + console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`) + + setTimeout(() => { + if (!this.isConnected) { + this.subscription?.perform('reconnect') + } + }, delay) + } + + /** + * Disconnect + */ + disconnect() { + if (this.subscription) { + this.subscription.unsubscribe() + this.subscription = null + } + this.isConnected = false + } + + /** + * Send message + */ + send(action, data = {}) { + if (!this.isConnected) { + console.warn('Cannot send message: not connected') + return + } + + this.subscription?.perform(action, data) + } +} diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb index 99b3c14f..59f97c91 100644 --- a/app/models/concerns/taggable.rb +++ b/app/models/concerns/taggable.rb @@ -8,6 +8,16 @@ module Taggable has_many :tags, through: :taggings scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct } + scope :with_all_tags, ->(tag_ids) { + tag_ids = Array(tag_ids) + return none if tag_ids.empty? + + # For each tag, join and filter, then use HAVING to ensure all tags are present + joins(:taggings) + .where(taggings: { tag_id: tag_ids }) + .group("#{table_name}.id") + .having("COUNT(DISTINCT taggings.tag_id) = ?", tag_ids.length) + } scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) } scope :tagged_with, ->(tag_name, user) { joins(:tags).where(tags: { name: tag_name, user: user }).distinct diff --git a/app/serializers/api/point_serializer.rb b/app/serializers/api/point_serializer.rb index fd8dec19..a8f309f3 100644 --- a/app/serializers/api/point_serializer.rb +++ b/app/serializers/api/point_serializer.rb @@ -17,6 +17,7 @@ class Api::PointSerializer attributes['latitude'] = lat&.to_s attributes['longitude'] = lon&.to_s + attributes['country_name'] = point.country_name end end diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb index fb7740a6..3e01f73c 100644 --- a/app/services/users/safe_settings.rb +++ b/app/services/users/safe_settings.rb @@ -20,7 +20,8 @@ class Users::SafeSettings 'photoprism_api_key' => nil, 'maps' => { 'distance_unit' => 'km' }, 'visits_suggestions_enabled' => 'true', - 'enabled_map_layers' => ['Routes', 'Heatmap'] + 'enabled_map_layers' => ['Routes', 'Heatmap'], + 'maps_maplibre_style' => 'light' }.freeze def initialize(settings = {}) @@ -28,7 +29,7 @@ class Users::SafeSettings end # rubocop:disable Metrics/MethodLength - def default_settings + def config { fog_of_war_meters: fog_of_war_meters, meters_between_routes: meters_between_routes, @@ -49,7 +50,8 @@ class Users::SafeSettings visits_suggestions_enabled: visits_suggestions_enabled?, speed_color_scale: speed_color_scale, fog_of_war_threshold: fog_of_war_threshold, - enabled_map_layers: enabled_map_layers + enabled_map_layers: enabled_map_layers, + maps_maplibre_style: maps_maplibre_style } end # rubocop:enable Metrics/MethodLength @@ -133,4 +135,8 @@ class Users::SafeSettings def enabled_map_layers settings['enabled_map_layers'] end + + def maps_maplibre_style + settings['maps_maplibre_style'] + end end diff --git a/app/views/map/_settings_modals.html.erb b/app/views/map/leaflet/_settings_modals.html.erb similarity index 100% rename from app/views/map/_settings_modals.html.erb rename to app/views/map/leaflet/_settings_modals.html.erb diff --git a/app/views/map/leaflet/index.html.erb b/app/views/map/leaflet/index.html.erb new file mode 100644 index 00000000..1086601b --- /dev/null +++ b/app/views/map/leaflet/index.html.erb @@ -0,0 +1,35 @@ +<% content_for :title, 'Map' %> + +<%= render 'shared/map/date_navigation', start_at: @start_at, end_at: @end_at %> + + +
+
+
+
+
+
+
+ +<%= render 'map/leaflet/settings_modals' %> + + +<%= render 'shared/place_creation_modal' %> diff --git a/app/views/map/maplibre/_area_creation_modal.html.erb b/app/views/map/maplibre/_area_creation_modal.html.erb new file mode 100644 index 00000000..9ef7d159 --- /dev/null +++ b/app/views/map/maplibre/_area_creation_modal.html.erb @@ -0,0 +1,67 @@ +
+ +
diff --git a/app/views/map/maplibre/_settings_panel.html.erb b/app/views/map/maplibre/_settings_panel.html.erb new file mode 100644 index 00000000..7ccb28df --- /dev/null +++ b/app/views/map/maplibre/_settings_panel.html.erb @@ -0,0 +1,655 @@ +
+ +
+ + + + + + + + + + <% if !DawarichSettings.self_hosted? %> + + <% end %> +
+ + +
+ +
+

Layers

+ +
+ + +
+ +
+
+ +
+ + + +
+

+ Search for a location to find places you visited +

+
+
+ + +
+
+ +
+ +

Show individual location points

+
+ +
+ + +
+ +

Show connected route lines

+
+ + + + +
+ + +
+ +

Show density heatmap

+
+ +
+ + +
+ +

Show detected area visits

+
+ + + + +
+ + +
+ +

Show your saved places

+
+ + + + +
+ + +
+ +

Show geotagged photos

+
+ +
+ + +
+ +

Show defined areas

+
+ +
+ + + <%#
+ +

Show saved tracks

+
%> + + <%#
%> + + +
+ +

Show explored areas

+
+ +
+ + +
+ +

Show scratched countries

+
+ +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ 10% + 50% + 100% +
+
+ +
+ + +
+ + +
+ 5m + 1000m + 2000m +
+

Clear radius around visited points

+
+ +
+ + +
+ 1 + 5 + 10 +
+

Minimum points to clear fog

+
+ +
+ + +
+ + +
+ 100m + 2500m + 5000m +
+

Distance threshold for route splitting

+
+ +
+ + +
+ 1min + 90min + 180min +
+

Time threshold for route splitting

+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +

Color routes by speed

+
+ +
+ + +
+ +

Show new points in real-time

+
+ +
+ + + + + + +
+
+ + +
+
+ +
+ + + + + + + + + + + +
+ + + + + + +
+
+ + <% if !DawarichSettings.self_hosted? %> + +
+
+ +
+

Community

+
+ Discord + X + Github + Mastodon +
+
+ +
+ + + + +
+ + + +
+
+ <% end %> +
+
+
+ diff --git a/app/views/map/maplibre/_visit_creation_modal.html.erb b/app/views/map/maplibre/_visit_creation_modal.html.erb new file mode 100644 index 00000000..f49c584a --- /dev/null +++ b/app/views/map/maplibre/_visit_creation_modal.html.erb @@ -0,0 +1,60 @@ +
+ +
diff --git a/app/views/map/maplibre/index.html.erb b/app/views/map/maplibre/index.html.erb new file mode 100644 index 00000000..9d6bc8ac --- /dev/null +++ b/app/views/map/maplibre/index.html.erb @@ -0,0 +1,49 @@ +<% content_for :title, 'Map' %> + +<%= render 'shared/map/date_navigation_v2', start_at: @start_at, end_at: @end_at %> + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + + + <%= render 'map/maplibre/settings_panel' %> + + + <%= render 'map/maplibre/visit_creation_modal' %> + + + <%= render 'map/maplibre/area_creation_modal' %> + + + <%= render 'shared/place_creation_modal' %> +
diff --git a/app/views/settings/maps/index.html.erb b/app/views/settings/maps/index.html.erb index fa8ee555..00c1f63f 100644 --- a/app/views/settings/maps/index.html.erb +++ b/app/views/settings/maps/index.html.erb @@ -80,6 +80,23 @@ +
+ + Choose which map version to use by default. V1 uses Leaflet, V2 uses MapLibre with enhanced features. +

Map Preview

diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index e29ad920..fcf869a7 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -5,7 +5,7 @@
- <%= link_to 'Dawarichα'.html_safe, root_path, class: 'btn btn-ghost normal-case text-xl'%> + <%= link_to 'Dawarichα'.html_safe, (user_signed_in? ? preferred_map_path : root_path), class: 'btn btn-ghost normal-case text-xl'%>