From 6772f2f7b792c35d52049b9b68b2988389100b44 Mon Sep 17 00:00:00 2001 From: Robin Tuszik Date: Tue, 25 Nov 2025 20:30:34 +0100 Subject: [PATCH 01/68] fix: move foreman to global gems to fix startup crash (#1971) --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 36cf0d9c..de3aafef 100644 --- a/Gemfile +++ b/Gemfile @@ -55,6 +55,7 @@ gem 'stimulus-rails' gem 'tailwindcss-rails', '= 3.3.2' gem 'turbo-rails', '>= 2.0.17' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] +gem 'foreman' group :development, :test, :staging do gem 'brakeman', require: false @@ -81,7 +82,6 @@ end group :development do gem 'database_consistency', '>= 2.0.5', require: false - gem 'foreman' gem 'rubocop-rails', '>= 2.33.4', require: false gem 'strong_migrations', '>= 2.4.0' end From ac9b668c308340ae9c97612e5d67c3fbe390a661 Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Thu, 27 Nov 2025 21:29:59 +0100 Subject: [PATCH 02/68] =?UTF-8?q?Update=20exporting=20code=20to=20stream?= =?UTF-8?q?=20points=20data=20to=20file=20in=20batches=20to=20red=E2=80=A6?= =?UTF-8?q?=20(#1980)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog --- CHANGELOG.md | 6 + app/services/users/export_data.rb | 2 +- app/services/users/export_data/points.rb | 234 ++++++++++++------ .../services/users/export_data/points_spec.rb | 30 +++ 4 files changed, 197 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c022da7..018a36ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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/). +[Unreleased] + +## Fixed + +- Exporting user data now works a lot faster and consumes less memory. + # [0.36.0] - 2025-11-24 ## OIDC and KML support release diff --git a/app/services/users/export_data.rb b/app/services/users/export_data.rb index 29caa8dd..80e6c486 100644 --- a/app/services/users/export_data.rb +++ b/app/services/users/export_data.rb @@ -273,7 +273,7 @@ class Users::ExportData file.write(Users::ExportData::Notifications.new(user).call.to_json) file.write(',"points":') - file.write(Users::ExportData::Points.new(user).call.to_json) + Users::ExportData::Points.new(user, file).call file.write(',"visits":') file.write(Users::ExportData::Visits.new(user).call.to_json) diff --git a/app/services/users/export_data/points.rb b/app/services/users/export_data/points.rb index ef98e30c..cf224afa 100644 --- a/app/services/users/export_data/points.rb +++ b/app/services/users/export_data/points.rb @@ -1,12 +1,75 @@ # frozen_string_literal: true class Users::ExportData::Points - def initialize(user) + BATCH_SIZE = 10_000 + PROGRESS_LOG_INTERVAL = 50_000 + + def initialize(user, output_file = nil) @user = user + @output_file = output_file end + # For backward compatibility: returns array when no output_file provided + # For streaming mode: writes directly to file when output_file provided def call - points_sql = <<-SQL + if @output_file + stream_to_file + nil # Don't return array in streaming mode + else + # Legacy mode: load all into memory (deprecated for large datasets) + load_all_points + end + end + + private + + attr_reader :user, :output_file + + def stream_to_file + total_count = user.points.count + processed = 0 + first_record = true + + Rails.logger.info "Streaming #{total_count} points to file..." + puts "Starting export of #{total_count} points..." + + output_file.write('[') + + user.points.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, batch_index| + batch_sql = build_batch_query(batch.map(&:id)) + result = ActiveRecord::Base.connection.exec_query(batch_sql, 'Points Export Batch') + + result.each do |row| + point_hash = build_point_hash(row) + next unless point_hash # Skip points without coordinates + + output_file.write(',') unless first_record + output_file.write(point_hash.to_json) + first_record = false + processed += 1 + + log_progress(processed, total_count) if (processed % PROGRESS_LOG_INTERVAL).zero? + end + + # Show progress after each batch + percentage = (processed.to_f / total_count * 100).round(1) + puts "Exported #{processed}/#{total_count} points (#{percentage}%)" + end + + output_file.write(']') + Rails.logger.info "Completed streaming #{processed} points to file" + puts "Export completed: #{processed} points written" + end + + def load_all_points + result = ActiveRecord::Base.connection.exec_query(build_full_query, 'Points Export', [user.id]) + Rails.logger.info "Processing #{result.count} points for export..." + + result.filter_map { |row| build_point_hash(row) } + end + + def build_full_query + <<-SQL SELECT p.id, p.battery_status, p.battery, p.timestamp, p.altitude, p.velocity, p.accuracy, p.ping, p.tracker_id, p.topic, p.trigger, p.bssid, p.ssid, p.connection, @@ -14,18 +77,14 @@ class Users::ExportData::Points p.city, p.country, p.geodata, p.reverse_geocoded_at, p.course, p.course_accuracy, p.external_track_id, p.created_at, p.updated_at, p.lonlat, p.longitude, p.latitude, - -- Extract coordinates from lonlat if individual fields are missing COALESCE(p.longitude, ST_X(p.lonlat::geometry)) as computed_longitude, COALESCE(p.latitude, ST_Y(p.lonlat::geometry)) as computed_latitude, - -- Import reference i.name as import_name, i.source as import_source, i.created_at as import_created_at, - -- Country info c.name as country_name, c.iso_a2 as country_iso_a2, c.iso_a3 as country_iso_a3, - -- Visit reference v.name as visit_name, v.started_at as visit_started_at, v.ended_at as visit_ended_at @@ -36,85 +95,112 @@ class Users::ExportData::Points WHERE p.user_id = $1 ORDER BY p.id SQL + end - result = ActiveRecord::Base.connection.exec_query(points_sql, 'Points Export', [user.id]) + def build_batch_query(point_ids) + <<-SQL + SELECT + p.id, p.battery_status, p.battery, p.timestamp, p.altitude, p.velocity, p.accuracy, + p.ping, p.tracker_id, p.topic, p.trigger, p.bssid, p.ssid, p.connection, + p.vertical_accuracy, p.mode, p.inrids, p.in_regions, p.raw_data, + p.city, p.country, p.geodata, p.reverse_geocoded_at, p.course, + p.course_accuracy, p.external_track_id, p.created_at, p.updated_at, + p.lonlat, p.longitude, p.latitude, + COALESCE(p.longitude, ST_X(p.lonlat::geometry)) as computed_longitude, + COALESCE(p.latitude, ST_Y(p.lonlat::geometry)) as computed_latitude, + i.name as import_name, + i.source as import_source, + i.created_at as import_created_at, + c.name as country_name, + c.iso_a2 as country_iso_a2, + c.iso_a3 as country_iso_a3, + v.name as visit_name, + v.started_at as visit_started_at, + v.ended_at as visit_ended_at + FROM points p + LEFT JOIN imports i ON p.import_id = i.id + LEFT JOIN countries c ON p.country_id = c.id + LEFT JOIN visits v ON p.visit_id = v.id + WHERE p.id IN (#{point_ids.join(',')}) + ORDER BY p.id + SQL + end - Rails.logger.info "Processing #{result.count} points for export..." + def build_point_hash(row) + has_lonlat = row['lonlat'].present? + has_coordinates = row['computed_longitude'].present? && row['computed_latitude'].present? - result.filter_map do |row| - has_lonlat = row['lonlat'].present? - has_coordinates = row['computed_longitude'].present? && row['computed_latitude'].present? + unless has_lonlat || has_coordinates + Rails.logger.debug "Skipping point without coordinates: id=#{row['id'] || 'unknown'}" + return nil + end - unless has_lonlat || has_coordinates - Rails.logger.debug "Skipping point without coordinates: id=#{row['id'] || 'unknown'}" - next - end + point_hash = { + 'battery_status' => row['battery_status'], + 'battery' => row['battery'], + 'timestamp' => row['timestamp'], + 'altitude' => row['altitude'], + 'velocity' => row['velocity'], + 'accuracy' => row['accuracy'], + 'ping' => row['ping'], + 'tracker_id' => row['tracker_id'], + 'topic' => row['topic'], + 'trigger' => row['trigger'], + 'bssid' => row['bssid'], + 'ssid' => row['ssid'], + 'connection' => row['connection'], + 'vertical_accuracy' => row['vertical_accuracy'], + 'mode' => row['mode'], + 'inrids' => row['inrids'] || [], + 'in_regions' => row['in_regions'] || [], + 'raw_data' => row['raw_data'], + 'city' => row['city'], + 'country' => row['country'], + 'geodata' => row['geodata'], + 'reverse_geocoded_at' => row['reverse_geocoded_at'], + 'course' => row['course'], + 'course_accuracy' => row['course_accuracy'], + 'external_track_id' => row['external_track_id'], + 'created_at' => row['created_at'], + 'updated_at' => row['updated_at'] + } - point_hash = { - 'battery_status' => row['battery_status'], - 'battery' => row['battery'], - 'timestamp' => row['timestamp'], - 'altitude' => row['altitude'], - 'velocity' => row['velocity'], - 'accuracy' => row['accuracy'], - 'ping' => row['ping'], - 'tracker_id' => row['tracker_id'], - 'topic' => row['topic'], - 'trigger' => row['trigger'], - 'bssid' => row['bssid'], - 'ssid' => row['ssid'], - 'connection' => row['connection'], - 'vertical_accuracy' => row['vertical_accuracy'], - 'mode' => row['mode'], - 'inrids' => row['inrids'] || [], - 'in_regions' => row['in_regions'] || [], - 'raw_data' => row['raw_data'], - 'city' => row['city'], - 'country' => row['country'], - 'geodata' => row['geodata'], - 'reverse_geocoded_at' => row['reverse_geocoded_at'], - 'course' => row['course'], - 'course_accuracy' => row['course_accuracy'], - 'external_track_id' => row['external_track_id'], - 'created_at' => row['created_at'], - 'updated_at' => row['updated_at'] + populate_coordinate_fields(point_hash, row) + add_relationship_references(point_hash, row) + + point_hash + end + + def add_relationship_references(point_hash, row) + if row['import_name'] + point_hash['import_reference'] = { + 'name' => row['import_name'], + 'source' => row['import_source'], + 'created_at' => row['import_created_at'] } + end - # Ensure all coordinate fields are populated - populate_coordinate_fields(point_hash, row) + if row['country_name'] + point_hash['country_info'] = { + 'name' => row['country_name'], + 'iso_a2' => row['country_iso_a2'], + 'iso_a3' => row['country_iso_a3'] + } + end - # Add relationship references only if they exist - if row['import_name'] - point_hash['import_reference'] = { - 'name' => row['import_name'], - 'source' => row['import_source'], - 'created_at' => row['import_created_at'] - } - end - - if row['country_name'] - point_hash['country_info'] = { - 'name' => row['country_name'], - 'iso_a2' => row['country_iso_a2'], - 'iso_a3' => row['country_iso_a3'] - } - end - - if row['visit_name'] - point_hash['visit_reference'] = { - 'name' => row['visit_name'], - 'started_at' => row['visit_started_at'], - 'ended_at' => row['visit_ended_at'] - } - end - - point_hash + if row['visit_name'] + point_hash['visit_reference'] = { + 'name' => row['visit_name'], + 'started_at' => row['visit_started_at'], + 'ended_at' => row['visit_ended_at'] + } end end - private - - attr_reader :user + def log_progress(processed, total) + percentage = (processed.to_f / total * 100).round(1) + Rails.logger.info "Points export progress: #{processed}/#{total} (#{percentage}%)" + end def populate_coordinate_fields(point_hash, row) longitude = row['computed_longitude'] diff --git a/spec/services/users/export_data/points_spec.rb b/spec/services/users/export_data/points_spec.rb index b2fa0a52..3f9ead9a 100644 --- a/spec/services/users/export_data/points_spec.rb +++ b/spec/services/users/export_data/points_spec.rb @@ -264,5 +264,35 @@ RSpec.describe Users::ExportData::Points, type: :service do expect(point_data).to be_nil end end + + context 'streaming mode' do + let!(:points) { create_list(:point, 25, user: user) } + let(:output) { StringIO.new } + let(:streaming_service) { described_class.new(user, output) } + + it 'writes JSON array directly to file without loading all into memory' do + streaming_service.call + output.rewind + json_output = output.read + + expect(json_output).to start_with('[') + expect(json_output).to end_with(']') + + parsed = JSON.parse(json_output) + expect(parsed).to be_an(Array) + expect(parsed.size).to eq(25) + end + + it 'returns nil in streaming mode instead of array' do + expect(streaming_service.call).to be_nil + end + + it 'logs progress for large datasets' do + expect(Rails.logger).to receive(:info).with(/Streaming \d+ points to file.../) + expect(Rails.logger).to receive(:info).with(/Completed streaming \d+ points to file/) + + streaming_service.call + end + end end end From cebbc289124145766ff4aadcaa8c165aeacfa8f8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 29 Nov 2025 19:58:57 +0100 Subject: [PATCH 03/68] Update changelog --- .app_version | 2 +- CHANGELOG.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 93d4c1ef..19199bcc 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.36.0 +0.36.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 018a36ea..b3e01da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ 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/). -[Unreleased] +[0.36.1] - 2025-11-29 ## Fixed - Exporting user data now works a lot faster and consumes less memory. +- Fix the restart loop. #1937 #1975 # [0.36.0] - 2025-11-24 From 4421a5bf3c6648579d8ea62bb0abbf61dcdb81b9 Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Sat, 6 Dec 2025 20:34:49 +0100 Subject: [PATCH 04/68] 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 --- CHANGELOG.md | 18 +- 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 | 167 + .../controllers/area_drawer_controller.js | 152 + .../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 + .../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 | 361 + .../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 + 145 files changed, 73492 insertions(+), 155 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/CHANGELOG.md b/CHANGELOG.md index b3e01da2..293d5fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,23 @@ 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] - Unreleased + + +## Fixed + +- Heatmap and Fog of War now are moving correctly during map interactions. #1798 +- Polyline crossing international date line now are rendered correctly. #1162 +- Place popup tags parsing (MapLibre GL JS compatibility) +- Stats calculation should be faster now. + +## Changed + +- Points on the Map page are now loaded in chunks to improve performance and reduce memory consumption. + + +# [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..506eb733 --- /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..8566e8d3 --- /dev/null +++ b/app/javascript/controllers/area_creation_v2_controller.js @@ -0,0 +1,167 @@ +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) => { + console.log('[Area Creation V2] area:drawn event received:', e.detail) + this.open(e.detail.center, e.detail.radius) + }) + } + + /** + * Open the modal with area data + */ + open(center, radius) { + console.log('[Area Creation V2] open() called with center:', center, 'radius:', 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) { + // You can replace this with a toast notification if available + console.log(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..d1005866 --- /dev/null +++ b/app/javascript/controllers/area_drawer_controller.js @@ -0,0 +1,152 @@ +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) { + console.log('[Area Drawer] startDrawing called with map:', map) + if (!map) { + console.error('[Area Drawer] Map instance not provided') + return + } + + console.log('[Area Drawer] Starting drawing mode') + 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 + console.log('[Area Drawer] First click - setting center:', e.lngLat) + this.center = [e.lngLat.lng, e.lngLat.lat] + } else { + // Second click - finish drawing + console.log('[Area Drawer] Second click - finishing drawing') + + console.log('[Area Drawer] Dispatching area:drawn event') + 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/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..5cd756f3 --- /dev/null +++ b/app/javascript/maps_maplibre/services/api_client.js @@ -0,0 +1,361 @@ +/** + * 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}` + console.log('[ApiClient] Fetching photos from:', url) + console.log('[ApiClient] With headers:', this.getHeaders()) + + const response = await fetch(url, { + headers: this.getHeaders() + }) + + console.log('[ApiClient] Photos response status:', response.status) + + 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'%>