From 1d07eb652d76168d9f054fe1a7dddc91e063b693 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 19 Nov 2025 19:33:28 +0100 Subject: [PATCH] Extract tag serializer to its own file --- LAYER_CONTROL_UPGRADE.md | 171 -- PLACES_INTEGRATION_CHECKLIST.md | 141 - TESTING_CHECKLIST.md | 194 -- app/controllers/api/v1/tags_controller.rb | 18 +- .../controllers/maps_controller.js.bak | 2362 ----------------- app/serializers/tag_serializer.rb | 33 + .../20251116134506_add_user_id_to_places.rb | 1 + spec/models/user_spec.rb | 2 + spec/requests/api/v1/tags_spec.rb | 52 + spec/serializers/tag_serializer_spec.rb | 32 + verify_places_integration.rb | 105 - 11 files changed, 121 insertions(+), 2990 deletions(-) delete mode 100644 LAYER_CONTROL_UPGRADE.md delete mode 100644 PLACES_INTEGRATION_CHECKLIST.md delete mode 100644 TESTING_CHECKLIST.md delete mode 100644 app/javascript/controllers/maps_controller.js.bak create mode 100644 app/serializers/tag_serializer.rb create mode 100644 spec/requests/api/v1/tags_spec.rb create mode 100644 spec/serializers/tag_serializer_spec.rb delete mode 100755 verify_places_integration.rb diff --git a/LAYER_CONTROL_UPGRADE.md b/LAYER_CONTROL_UPGRADE.md deleted file mode 100644 index cc19b67c..00000000 --- a/LAYER_CONTROL_UPGRADE.md +++ /dev/null @@ -1,171 +0,0 @@ -# Layer Control Upgrade - Leaflet.Control.Layers.Tree - -## Summary - -Successfully installed and integrated the `Leaflet.Control.Layers.Tree` plugin to replace the standard Leaflet layer control with a hierarchical tree-based control that better organizes map layers and styles. - -## Changes Made - -### 1. Installation - -- **Plugin**: Installed `leaflet.control.layers.tree` via importmap -- **CSS**: Added plugin CSS file at `app/assets/stylesheets/leaflet.control.layers.tree.css` - -### 2. Maps Controller Updates - -#### File: `app/javascript/controllers/maps_controller.js` - -**Import Changes:** -- Added import for `leaflet.control.layers.tree` -- Removed import for `createPlacesControl` (now integrated into tree control) - -**Initialization Changes:** -- Removed standalone Places control initialization -- Added `this.userTags` property to store user tags for places filtering -- Updated layer control initialization to use `createTreeLayerControl()` - -**New Methods:** - -1. **`createTreeLayerControl(additionalLayers = {})`** - - Creates a hierarchical tree structure for map layers - - Organizes layers into two main groups: - - **Map Styles**: All available base map layers - - **Layers**: All overlay layers with nested groups - - Supports dynamic additional layers (e.g., Family Members) - - Structure: - ``` - + Map Styles - - OpenStreetMap - - OpenStreetMap.HOT - - ... - + Layers - - Points - - Routes - - Tracks - - Heatmap - - Fog of War - - Scratch map - - Areas - - Photos - + Visits - - Suggested - - Confirmed - + Places - - All - - Untagged - - (each tag with icon) - ``` - -**Updated Methods:** -- **`updateLayerControl()`**: Simplified to just recreate the tree control with additional layers -- Updated all layer control recreations throughout the file to use `createTreeLayerControl()` - -### 3. Places Manager Updates - -#### File: `app/javascript/maps/places.js` - -**New Methods:** - -1. **`createFilteredLayer(tagIds)`** - - Creates a layer group for filtered places - - Returns a layer that loads places when added to the map - - Supports tag-based and untagged filtering - -2. **`loadPlacesIntoLayer(layer, tagIds)`** - - Loads places into a specific layer with tag filtering - - Handles API calls with tag_ids or untagged parameters - - Creates markers using existing `createPlaceMarker()` method - -## Features - -### Hierarchical Organization -- Map styles and layers are now clearly separated -- Related layers are grouped together (Visits, Places) -- Easy to expand/collapse sections - -### Places Layer Integration -- No longer needs a separate control -- All places filters are now in the tree control -- Each tag gets its own layer in the tree -- Places group has "All", "Untagged", and individual tag layers regardless of tags -- "Untagged" shows only places without tags - -### Dynamic Layer Support -- Family Members layer can be added dynamically -- Additional layers can be easily integrated -- Maintains compatibility with existing layer management - -### Improved User Experience -- Cleaner UI with collapsible sections -- Better organization of many layers -- Consistent interface for all layer types -- Select All checkbox for grouped layers (Visits, Places) - -## API Changes - -### Places API -The Places API now supports an `untagged` parameter: -- `GET /api/v1/places?untagged=true` - Returns only untagged places -- `GET /api/v1/places?tag_ids=1,2,3` - Returns places with specified tags - -## Testing Recommendations - -1. **Basic Functionality** - - Verify all map styles load correctly - - Test all overlay layers (Points, Routes, Tracks, etc.) - - Confirm layer visibility persists correctly - -2. **Places Integration** - - Test "All" layer shows all places - - Verify "Untagged" layer shows only untagged places - - Test individual tag layers show correct places - - Confirm places load when layer is enabled - -3. **Visits Integration** - - Test Suggested and Confirmed visits layers - - Verify visits load correctly when enabled - -4. **Family Members** - - Test Family Members layer appears when family is available - - Verify layer updates when family locations change - -5. **Layer State Persistence** - - Verify enabled layers are saved to user settings - - Confirm layer state is restored on page load - -## Migration Notes - -### Removed Components -- Standalone Places control button (šŸ“) -- `createPlacesControl` function no longer used in maps_controller - -### Behavioral Changes -- Places layer is no longer managed by a separate control -- All places filtering is now done through the layer control -- Places markers are created on-demand when layer is enabled - -## Future Enhancements - -1. **Layer Icons**: Add custom icons for each layer type -2. **Layer Counts**: Show number of items in each layer -3. **Custom Styling**: Theme the tree control to match app theme -4. **Layer Search**: Add search functionality for finding layers -5. **Layer Presets**: Allow saving custom layer combinations - -## Files Modified - -1. `app/javascript/controllers/maps_controller.js` - Main map controller -2. `app/javascript/maps/places.js` - Places manager with new filtering methods -3. `config/importmap.rb` - Added tree control import (via bin/importmap) -4. `app/assets/stylesheets/leaflet.control.layers.tree.css` - Plugin CSS - -## Rollback Plan - -If needed, to rollback: -1. Remove `import "leaflet.control.layers.tree"` from maps_controller.js -2. Restore `import { createPlacesControl }` from places_control -3. Revert `createTreeLayerControl()` to `L.control.layers()` -4. Restore Places control initialization -5. Remove `leaflet.control.layers.tree` from importmap -6. Remove CSS file diff --git a/PLACES_INTEGRATION_CHECKLIST.md b/PLACES_INTEGRATION_CHECKLIST.md deleted file mode 100644 index d5985e71..00000000 --- a/PLACES_INTEGRATION_CHECKLIST.md +++ /dev/null @@ -1,141 +0,0 @@ -# Places Integration Checklist - -## Files Modified: -- āœ… `app/javascript/controllers/stat_page_controller.js` - Added PlacesManager integration -- āœ… `app/javascript/maps/places.js` - Fixed API authentication headers -- āœ… `app/views/stats/_month.html.erb` - Added Places button and tag filters -- āœ… `app/views/shared/_place_creation_modal.html.erb` - Already exists - -## What Should Appear: - -### On Monthly Stats Page (`/stats/YYYY/MM`): - -1. **Map Controls** (top right of map): - - [ ] "Heatmap" button - - [ ] "Points" button - - [ ] **"Places" button** ← NEW! - -2. **Below the Map**: - - [ ] **"Filter Places by Tags"** section ← NEW! - - [ ] Checkboxes for each tag you've created - - [ ] Each checkbox shows: icon + name + color dot - -## Troubleshooting Steps: - -### Step 1: Restart Server -```bash -# Stop server (Ctrl+C) -bundle exec rails server - -# Or with Docker: -docker-compose restart web -``` - -### Step 2: Hard Refresh Browser -- Mac: `Cmd + Shift + R` -- Windows/Linux: `Ctrl + Shift + R` - -### Step 3: Check Browser Console -1. Open Developer Tools (F12) -2. Go to Console tab -3. Look for errors (red text) -4. You should see: "StatPage controller connected" - -### Step 4: Verify URL -Make sure you're on a monthly stats page: -- āœ… `/stats/2024/11` ← Correct -- āŒ `/stats` ← Wrong (main stats index) -- āŒ `/stats/2024` ← Wrong (yearly stats) - -### Step 5: Check JavaScript Loading -In browser console, type: -```javascript -console.log(document.querySelector('[data-controller="stat-page"]')) -``` -Should show the element, not null. - -### Step 6: Verify Controller Registration -In browser console: -```javascript -console.log(application.controllers) -``` -Should include "stat-page" in the list. - -## Expected Behavior: - -### When You Click "Places" Button: -1. Places layer toggles on/off -2. Button highlights when active -3. Map shows custom markers with tag icons - -### When You Check Tag Filters: -1. Map updates immediately -2. Shows only places with selected tags -3. Unchecking all shows all places - -## If Nothing Shows: - -### Check if you have any places created: -```bash -bundle exec rails console - -# In console: -user = User.find_by(email: 'your@email.com') -user.places.count # Should be > 0 -user.tags.count # Should be > 0 -``` - -### Create test data: -```bash -bundle exec rails console - -user = User.first -tag = user.tags.create!(name: "Test", icon: "šŸ“", color: "#FF5733") - -# Create via API or console: -place = user.places.create!( - name: "Test Place", - latitude: 40.7128, - longitude: -74.0060, - source: :manual -) -place.tags << tag -``` - -## Verification Script: - -Run this in Rails console to verify everything: - -```ruby -user = User.first -puts "Tags: #{user.tags.count}" -puts "Places: #{user.places.count}" -puts "Places with tags: #{user.places.joins(:tags).distinct.count}" - -if user.tags.any? - puts "\nYour tags:" - user.tags.each do |tag| - puts " #{tag.icon} #{tag.name} (#{tag.places.count} places)" - end -end - -if user.places.any? - puts "\nYour places:" - user.places.limit(5).each do |place| - puts " #{place.name} at (#{place.latitude}, #{place.longitude})" - puts " Tags: #{place.tags.map(&:name).join(', ')}" - end -end -``` - -## Still Having Issues? - -Check these files exist and have the right content: -- `app/javascript/maps/places.js` - Should export PlacesManager class -- `app/javascript/controllers/stat_page_controller.js` - Should import PlacesManager -- `app/views/stats/_month.html.erb` - Should have Places button at line ~73 - -Look for JavaScript errors in browser console that might indicate: -- Import/export issues -- Syntax errors -- Missing dependencies diff --git a/TESTING_CHECKLIST.md b/TESTING_CHECKLIST.md deleted file mode 100644 index cae56cba..00000000 --- a/TESTING_CHECKLIST.md +++ /dev/null @@ -1,194 +0,0 @@ -# Layer Control Upgrade - Testing Checklist - -## Pre-Testing Setup - -1. **Start the development server** - ```bash - bin/dev - ``` - -2. **Clear browser cache** to ensure new JavaScript and CSS are loaded - -3. **Log in** to the application with demo credentials or your account - -4. **Navigate to the Map page** (`/map`) - -## Visual Verification - -- [ ] Layer control appears in the top-right corner -- [ ] Layer control shows a hierarchical tree structure (not flat list) -- [ ] Control has two main sections: "Map Styles" and "Layers" -- [ ] Sections can be expanded/collapsed -- [ ] No standalone Places control button (šŸ“) is visible - -## Map Styles Testing - -- [ ] Expand "Map Styles" section -- [ ] All map styles are listed (OpenStreetMap, OpenStreetMap.HOT, etc.) -- [ ] Selecting a different style changes the base map -- [ ] Only one map style can be selected at a time -- [ ] Selected style is indicated with a radio button - -## Layers Testing - -### Basic Layers -- [ ] Expand "Layers" section -- [ ] All basic layers are present: - - [ ] Points - - [ ] Routes - - [ ] Tracks - - [ ] Heatmap - - [ ] Fog of War - - [ ] Scratch map - - [ ] Areas - - [ ] Photos - -- [ ] Toggle each layer on/off -- [ ] Verify each layer displays correctly when enabled -- [ ] Multiple layers can be enabled simultaneously - -### Visits Group -- [ ] Expand "Visits" section -- [ ] Two sub-layers are present: - - [ ] Suggested - - [ ] Confirmed -- [ ] Enable "Suggested" - suggested visits appear on map -- [ ] Enable "Confirmed" - confirmed visits appear on map -- [ ] Disable both - no visits visible on map -- [ ] Select All checkbox works for Visits group - -### Places Group -- [ ] Expand "Places" section -- [ ] At least these options are present: - - [ ] Places (top-level checkbox) - - [ ] Untagged - - [ ] (Individual tags if any exist) - -**Testing "Places (top-level checkbox)":** -- [ ] Enable "Places (top-level checkbox)" -- [ ] All places appear on map regardless of tags -- [ ] Place markers are clickable -- [ ] Place popups show correct information - -**Testing "Untagged":** -- [ ] Enable "Untagged" (disable "Places (top-level checkbox)" first) -- [ ] Only places without tags appear -- [ ] Verify by checking places that have tags don't appear - -**Testing Individual Tags:** -(If you have tags created) -- [ ] Each tag appears as a separate layer -- [ ] Tag icon is displayed before tag name -- [ ] Enable a tag layer -- [ ] Only places with that tag appear -- [ ] Multiple tag layers can be enabled simultaneously -- [ ] Select All checkbox works for Places group - -### Family Members (if applicable) -- [ ] If in a family, "Family Members" layer appears -- [ ] Enable Family Members layer -- [ ] Family member locations appear on map -- [ ] Family member markers are distinguishable from own markers - -## Functional Testing - -### Layer Persistence -- [ ] Enable several layers (e.g., Points, Routes, Suggested Visits, Places (top-level checkbox)) -- [ ] Refresh the page -- [ ] Verify enabled layers remain enabled after refresh -- [ ] Verify disabled layers remain disabled after refresh - -### Places API Integration -- [ ] Open browser console (F12) -- [ ] Enable "Network" tab -- [ ] Enable "Untagged" places layer -- [ ] Verify API call: `GET /api/v1/places?api_key=...&untagged=true` -- [ ] Enable a tag layer -- [ ] Verify API call: `GET /api/v1/places?api_key=...&tag_ids=` -- [ ] Verify no JavaScript errors in console - -### Layer Interaction -- [ ] Enable Routes layer -- [ ] Click on a route segment -- [ ] Verify route details popup appears -- [ ] Enable Places "Places (top-level checkbox)" layer -- [ ] Click on a place marker -- [ ] Verify place details popup appears -- [ ] Verify layers don't interfere with each other - -### Performance -- [ ] Enable all layers simultaneously -- [ ] Map remains responsive -- [ ] No significant lag when toggling layers -- [ ] No memory leaks (check browser dev tools) - -## Edge Cases - -### No Tags Scenario -- [ ] If no tags exist, Places section should show: - - [ ] Places (top-level checkbox) - - [ ] Untagged -- [ ] No error in console - -### No Places Scenario -- [ ] Disable all place layers -- [ ] Enable "Untagged" -- [ ] Verify appropriate message or empty state -- [ ] No errors in console - -### No Family Scenario -- [ ] If not in a family, "Family Members" layer shouldn't appear -- [ ] No errors in console - -## Regression Testing - -### Existing Functionality -- [ ] Routes/Tracks selector still works (if visible with `tracks_debug=true`) -- [ ] Settings panel still works -- [ ] Calendar panel still works -- [ ] Visit selection tool still works -- [ ] Add visit button still works - -### Other Controllers -- [ ] Family members controller still works (if applicable) -- [ ] Photo markers still load correctly -- [ ] Area drawing still works -- [ ] Fog of war updates correctly - -## Mobile Testing (if applicable) - -- [ ] Layer control is accessible on mobile -- [ ] Tree structure expands/collapses on tap -- [ ] Layers can be toggled on mobile -- [ ] No layout issues on small screens - -## Error Scenarios - -- [ ] Disconnect internet, try to load a layer that requires API call -- [ ] Verify appropriate error handling -- [ ] Verify user gets feedback about the failure -- [ ] Verify app doesn't crash - -## Console Checks - -Throughout all testing, monitor the browser console for: -- [ ] No JavaScript errors -- [ ] No unexpected warnings -- [ ] No failed API requests (except during error scenario testing) -- [ ] Appropriate log messages for debugging - -## Sign-off - -- [ ] All critical tests pass -- [ ] Any failures are documented -- [ ] Ready for production deployment - ---- - -## Notes - -Record any issues, unexpected behavior, or suggestions for improvement: - -``` -[Your notes here] -``` diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb index eee75e08..f5089bee 100644 --- a/app/controllers/api/v1/tags_controller.rb +++ b/app/controllers/api/v1/tags_controller.rb @@ -6,23 +6,7 @@ module Api def privacy_zones zones = current_api_user.tags.privacy_zones.includes(:places) - render json: zones.map { |tag| - { - tag_id: tag.id, - tag_name: tag.name, - tag_icon: tag.icon, - tag_color: tag.color, - radius_meters: tag.privacy_radius_meters, - places: tag.places.map { |place| - { - id: place.id, - name: place.name, - latitude: place.latitude, - longitude: place.longitude - } - } - } - } + render json: zones.map { |tag| TagSerializer.new(tag).call } end end end diff --git a/app/javascript/controllers/maps_controller.js.bak b/app/javascript/controllers/maps_controller.js.bak deleted file mode 100644 index 0d2a1c74..00000000 --- a/app/javascript/controllers/maps_controller.js.bak +++ /dev/null @@ -1,2362 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import L from "leaflet"; -import "leaflet.heat"; -import "leaflet.control.layers.tree"; -import consumer from "../channels/consumer"; - -import { createMarkersArray } from "../maps/markers"; -import { LiveMapHandler } from "../maps/live_map_handler"; - -import { - createPolylinesLayer, - updatePolylinesOpacity, - updatePolylinesColors, - colorFormatEncode, - colorFormatDecode, - colorStopsFallback, - reestablishPolylineEventHandlers, - managePaneVisibility -} from "../maps/polylines"; - -import { - createTracksLayer, - updateTracksOpacity, - toggleTracksVisibility, - filterTracks, - trackColorPalette, - handleIncrementalTrackUpdate, - addOrUpdateTrack, - removeTrackById, - isTrackInTimeRange -} from "../maps/tracks"; - -import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas"; - -import { showFlashMessage } from "../maps/helpers"; -import { fetchAndDisplayPhotos } from "../maps/photos"; -import { countryCodesMap } from "../maps/country_codes"; -import { VisitsManager } from "../maps/visits"; -import { ScratchLayer } from "../maps/scratch_layer"; -import { LocationSearch } from "../maps/location_search"; -import { PlacesManager } from "../maps/places"; - -import "leaflet-draw"; -import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; -import { TileMonitor } from "../maps/tile_monitor"; -import BaseController from "./base_controller"; -import { createAllMapLayers } from "../maps/layers"; -import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils"; -import { addTopRightButtons } from "../maps/map_controls"; - -export default class extends BaseController { - static targets = ["container"]; - - settingsButtonAdded = false; - layerControl = null; - visitedCitiesCache = new Map(); - trackedMonthsCache = null; - tracksLayer = null; - tracksVisible = false; - tracksSubscription = null; - - connect() { - super.connect(); - console.log("Map controller connected"); - - this.apiKey = this.element.dataset.api_key; - this.selfHosted = this.element.dataset.self_hosted; - this.userTheme = this.element.dataset.user_theme || 'dark'; - - try { - this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : []; - } catch (error) { - console.error('Error parsing coordinates data:', error); - this.markers = []; - } - try { - this.tracksData = this.element.dataset.tracks ? JSON.parse(this.element.dataset.tracks) : null; - } catch (error) { - console.error('Error parsing tracks data:', error); - this.tracksData = null; - } - this.timezone = this.element.dataset.timezone; - try { - this.userSettings = this.element.dataset.user_settings ? JSON.parse(this.element.dataset.user_settings) : {}; - } catch (error) { - console.error('Error parsing user_settings data:', error); - this.userSettings = {}; - } - try { - this.features = this.element.dataset.features ? JSON.parse(this.element.dataset.features) : {}; - } catch (error) { - console.error('Error parsing features data:', error); - this.features = {}; - } - this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50; - this.fogLineThreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90; - // Store route opacity as decimal (0-1) internally - this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6; - this.distanceUnit = this.userSettings.maps?.distance_unit || "km"; - this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw"; - this.liveMapEnabled = this.userSettings.live_map_enabled || false; - this.countryCodesMap = countryCodesMap(); - this.speedColoredPolylines = this.userSettings.speed_colored_routes || false; - this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback); - - // Flag to prevent saving layers during initialization/restoration - this.isRestoringLayers = false; - - // Ensure we have valid markers array - if (!Array.isArray(this.markers)) { - console.warn('Markers is not an array, setting to empty array'); - this.markers = []; - } - - // Set default center (Berlin) if no markers available - this.center = this.markers.length > 0 ? this.markers[this.markers.length - 1] : [52.514568, 13.350111]; - - this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14); - - // Add scale control - this.scaleControl = L.control.scale({ - position: 'bottomright', - imperial: this.distanceUnit === 'mi', - metric: this.distanceUnit === 'km', - maxWidth: 120 - }).addTo(this.map); - - // Add stats control - const StatsControl = L.Control.extend({ - options: { - position: 'bottomright' - }, - onAdd: (map) => { - const div = L.DomUtil.create('div', 'leaflet-control-stats'); - let distance = parseInt(this.element.dataset.distance) || 0; - const pointsNumber = this.element.dataset.points_number || '0'; - - // Convert distance to miles if user prefers miles (assuming backend sends km) - if (this.distanceUnit === 'mi') { - distance = distance * 0.621371; // km to miles conversion - } - - const unit = this.distanceUnit === 'km' ? 'km' : 'mi'; - div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`; - applyThemeToControl(div, this.userTheme, { - padding: '0 5px', - marginRight: '5px', - display: 'inline-block' - }); - return div; - } - }); - - this.statsControl = new StatsControl().addTo(this.map); - - // Set the maximum bounds to prevent infinite scroll - var southWest = L.latLng(-120, -210); - var northEast = L.latLng(120, 210); - var bounds = L.latLngBounds(southWest, northEast); - - this.map.setMaxBounds(bounds); - - this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey); - this.markersLayer = L.layerGroup(this.markersArray); - this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]); - - this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit); - this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); - - // Initialize empty tracks layer for layer control (will be populated later) - this.tracksLayer = L.layerGroup(); - - // Create a proper Leaflet layer for fog - this.fogOverlay = new (createFogOverlay())(); - - // Create custom panes with proper z-index ordering - // Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700 - - // Areas pane - below visits so they don't block interaction - this.map.createPane('areasPane'); - this.map.getPane('areasPane').style.zIndex = 605; // Above markerPane but below visits - this.map.getPane('areasPane').style.pointerEvents = 'none'; // Don't block clicks, let them pass through - - // Legacy visits pane for backward compatibility - this.map.createPane('visitsPane'); - this.map.getPane('visitsPane').style.zIndex = 615; - this.map.getPane('visitsPane').style.pointerEvents = 'auto'; - - // Suggested visits pane - interactive layer - this.map.createPane('suggestedVisitsPane'); - this.map.getPane('suggestedVisitsPane').style.zIndex = 610; - this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'auto'; - - // Confirmed visits pane - on top of suggested, interactive - this.map.createPane('confirmedVisitsPane'); - this.map.getPane('confirmedVisitsPane').style.zIndex = 620; - this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'auto'; - - // Initialize areasLayer as a feature group and add it to the map immediately - this.areasLayer = new L.FeatureGroup(); - this.photoMarkers = L.layerGroup(); - - this.initializeScratchLayer(); - - if (!this.settingsButtonAdded) { - this.addSettingsButton(); - } - - // Add info toggle button - this.addInfoToggleButton(); - - // Initialize the visits manager - this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme, this); - - // Expose visits manager globally for location search integration - window.visitsManager = this.visitsManager; - - // Initialize the places manager - this.placesManager = new PlacesManager(this.map, this.apiKey); - this.placesManager.initialize(); - - // Parse user tags for places layer control - try { - this.userTags = this.element.dataset.user_tags ? JSON.parse(this.element.dataset.user_tags) : []; - } catch (error) { - console.error('Error parsing user tags:', error); - this.userTags = []; - } - - // Expose maps controller globally for family integration - window.mapsController = this; - - // Initialize tile monitor - this.tileMonitor = new TileMonitor(this.map, this.apiKey); - - this.addEventListeners(); - this.setupSubscription(); - this.setupTracksSubscription(); - - // Handle routes/tracks mode selection - if (this.shouldShowTracksSelector()) { - this.addRoutesTracksSelector(); - } - this.switchRouteMode('routes', true); - - // Initialize layers based on settings - this.initializeLayersFromSettings(); - - // Listen for Family Members layer becoming ready - this.setupFamilyLayerListener(); - - // Initialize tracks layer - this.initializeTracksLayer(); - - // Setup draw control - this.initializeDrawControl(); - - // Preload areas - fetchAndDrawAreas(this.areasLayer, this.apiKey); - - // Add all top-right buttons in the correct order - this.initializeTopRightButtons(); - - // Initialize tree-based layer control - this.layerControl = this.createTreeLayerControl(); - this.map.addControl(this.layerControl); - - - // Initialize Live Map Handler - this.initializeLiveMapHandler(); - - // Initialize Location Search - this.initializeLocationSearch(); - } - - disconnect() { - super.disconnect(); - this.removeEventListeners(); - - if (this.tracksSubscription) { - this.tracksSubscription.unsubscribe(); - } - if (this.tileMonitor) { - this.tileMonitor.destroy(); - } - if (this.visitsManager) { - this.visitsManager.destroy(); - } - if (this.layerControl) { - this.map.removeControl(this.layerControl); - } - if (this.map) { - this.map.remove(); - } - console.log("Map controller disconnected"); - } - - setupSubscription() { - consumer.subscriptions.create("PointsChannel", { - received: (data) => { - // TODO: - // Only append the point if its timestamp is within current - // timespan - if (this.map && this.map._loaded) { - this.appendPoint(data); - } - } - }); - } - - setupTracksSubscription() { - this.tracksSubscription = consumer.subscriptions.create("TracksChannel", { - received: (data) => { - console.log("Received track update:", data); - if (this.map && this.map._loaded && this.tracksLayer) { - this.handleTrackUpdate(data); - } - } - }); - } - - handleTrackUpdate(data) { - // Get current time range for filtering - const urlParams = new URLSearchParams(window.location.search); - const currentStartAt = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const currentEndAt = urlParams.get('end_at') || new Date().toISOString(); - - // Handle the track update - handleIncrementalTrackUpdate( - this.tracksLayer, - data, - this.map, - this.userSettings, - this.distanceUnit, - currentStartAt, - currentEndAt - ); - - // If tracks are visible, make sure the layer is properly displayed - if (this.tracksVisible && this.tracksLayer) { - if (!this.map.hasLayer(this.tracksLayer)) { - this.map.addLayer(this.tracksLayer); - } - } - } - - /** - * Initialize the Live Map Handler - */ - initializeLiveMapHandler() { - const layers = { - markersLayer: this.markersLayer, - polylinesLayer: this.polylinesLayer, - heatmapLayer: this.heatmapLayer, - fogOverlay: this.fogOverlay - }; - - const options = { - maxPoints: 1000, - routeOpacity: this.routeOpacity, - timezone: this.timezone, - distanceUnit: this.distanceUnit, - userSettings: this.userSettings, - clearFogRadius: this.clearFogRadius, - fogLineThreshold: this.fogLineThreshold, - // Pass existing data to LiveMapHandler - existingMarkers: this.markers || [], - existingMarkersArray: this.markersArray || [], - existingHeatmapMarkers: this.heatmapMarkers || [] - }; - - this.liveMapHandler = new LiveMapHandler(this.map, layers, options); - - // Enable live map handler if live mode is already enabled - if (this.liveMapEnabled) { - this.liveMapHandler.enable(); - } - } - - /** - * Delegate to LiveMapHandler for memory-efficient point appending - */ - appendPoint(data) { - if (this.liveMapHandler && this.liveMapEnabled) { - this.liveMapHandler.appendPoint(data); - // Update scratch layer manager with new markers - if (this.scratchLayerManager) { - this.scratchLayerManager.updateMarkers(this.markers); - } - } else { - console.warn('LiveMapHandler not initialized or live mode not enabled'); - } - } - - async initializeScratchLayer() { - this.scratchLayerManager = new ScratchLayer(this.map, this.markers, this.countryCodesMap, this.apiKey); - this.scratchLayer = await this.scratchLayerManager.setup(); - } - - toggleScratchLayer() { - if (this.scratchLayerManager) { - this.scratchLayerManager.toggle(); - } - } - - baseMaps() { - let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; - let maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted); - - // Add custom map if it exists in settings - if (this.userSettings.maps && this.userSettings.maps.url) { - const customLayer = L.tileLayer(this.userSettings.maps.url, { - maxZoom: 19, - attribution: "© OpenStreetMap contributors" - }); - - // If this is the preferred layer, add it to the map immediately - if (selectedLayerName === this.userSettings.maps.name) { - // Remove any existing base layers first - Object.values(maps).forEach(layer => { - if (this.map.hasLayer(layer)) { - this.map.removeLayer(layer); - } - }); - customLayer.addTo(this.map); - } - - maps[this.userSettings.maps.name] = customLayer; - } else { - // If no maps were created (fallback case), add OSM - if (Object.keys(maps).length === 0) { - console.warn('No map layers available, adding OSM fallback'); - const osmLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: "© OpenStreetMap" - }); - osmLayer.addTo(this.map); - maps["OpenStreetMap"] = osmLayer; - } - // Note: createAllMapLayers already added the user's preferred layer to the map - } - - return maps; - } - - createTreeLayerControl() { - // Build base maps tree structure - const baseMapsTree = { - label: 'Map Styles', - children: [] - }; - - const maps = this.baseMaps(); - Object.entries(maps).forEach(([name, layer]) => { - baseMapsTree.children.push({ - label: name, - layer: layer - }); - }); - - // Build places subtree with tags - const placesChildren = [ - { - label: 'All Places', - layer: this.placesManager?.placesLayer || L.layerGroup() - }, - { - label: 'Untagged', - layer: this.placesManager?.createFilteredLayer([]) || L.layerGroup() - } - ]; - - // Add individual tag layers - if (this.userTags && this.userTags.length > 0) { - this.userTags.forEach(tag => { - const icon = tag.icon || 'šŸ“'; - const label = `${icon} ${tag.name}`; - placesChildren.push({ - label: label, - layer: this.placesManager?.createFilteredLayer([tag.id]) || L.layerGroup() - }); - }); - } - - // Build visits subtree - const visitsChildren = [ - { - label: 'Suggested', - layer: this.visitsManager?.getVisitCirclesLayer() || L.layerGroup() - }, - { - label: 'Confirmed', - layer: this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() - } - ]; - - // Build the overlays tree structure - const overlaysTree = { - label: 'Layers', - selectAllCheckbox: false, - children: [ - { - label: 'Points', - layer: this.markersLayer - }, - { - label: 'Routes', - layer: this.polylinesLayer - }, - { - label: 'Tracks', - layer: this.tracksLayer - }, - { - label: 'Heatmap', - layer: this.heatmapLayer - }, - { - label: 'Fog of War', - layer: this.fogOverlay - }, - { - label: 'Scratch map', - layer: this.scratchLayerManager?.getLayer() || L.layerGroup() - }, - { - label: 'Areas', - layer: this.areasLayer - }, - { - label: 'Photos', - layer: this.photoMarkers - }, - { - label: 'Visits', - selectAllCheckbox: true, - children: visitsChildren - }, - { - label: 'Places', - selectAllCheckbox: true, - children: placesChildren - } - ] - }; - - // Create the tree control - return L.control.layers.tree( - baseMapsTree, - overlaysTree, - { - namedToggle: false, - collapsed: true, - position: 'topright' - } - ); - } - - removeEventListeners() { - document.removeEventListener('click', this.handleDeleteClick); - } - - addEventListeners() { - // Create the handler only once and store it as an instance property - if (!this.handleDeleteClick) { - this.handleDeleteClick = (event) => { - if (event.target && event.target.classList.contains('delete-point')) { - event.preventDefault(); - const pointId = event.target.getAttribute('data-id'); - - if (confirm('Are you sure you want to delete this point?')) { - this.deletePoint(pointId, this.apiKey); - } - } - }; - - // Add the listener only if it hasn't been added before - document.addEventListener('click', this.handleDeleteClick); - } - - // Add an event listener for base layer change in Leaflet - this.map.on('baselayerchange', (event) => { - const selectedLayerName = event.name; - this.updatePreferredBaseLayer(selectedLayerName); - }); - - // Add event listeners for overlay layer changes to keep routes/tracks selector in sync - this.map.on('overlayadd', (event) => { - // Save enabled layers whenever a layer is added (unless we're restoring from settings) - if (!this.isRestoringLayers) { - this.saveEnabledLayers(); - } - - if (event.name === 'Routes') { - this.handleRouteLayerToggle('routes'); - // Re-establish event handlers when routes are manually added - if (event.layer === this.polylinesLayer) { - reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); - } - } else if (event.name === 'Tracks') { - this.handleRouteLayerToggle('tracks'); - } else if (event.name === 'Areas') { - // Show draw control when Areas layer is enabled - if (this.drawControl && !this.map.hasControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { - this.map.addControl(this.drawControl); - } - } else if (event.name === 'Photos') { - // Load photos when Photos layer is enabled - console.log('Photos layer enabled via layer control'); - const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const endDate = urlParams.get('end_at') || new Date().toISOString(); - - console.log('Fetching photos for date range:', { startDate, endDate }); - fetchAndDisplayPhotos({ - map: this.map, - photoMarkers: this.photoMarkers, - apiKey: this.apiKey, - startDate: startDate, - endDate: endDate, - userSettings: this.userSettings - }); - } else if (event.name === 'Suggested Visits' || event.name === 'Confirmed Visits') { - // Load visits when layer is enabled - console.log(`${event.name} layer enabled via layer control`); - if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { - // Fetch and populate the visits - this will create circles and update drawer if open - this.visitsManager.fetchAndDisplayVisits(); - } - } else if (event.name === 'Scratch map') { - // Add scratch map layer - console.log('Scratch map layer enabled via layer control'); - if (this.scratchLayerManager) { - this.scratchLayerManager.addToMap(); - } - } else if (event.name === 'Fog of War') { - // Enable fog of war when layer is added - this.fogOverlay = event.layer; - if (this.markers && this.markers.length > 0) { - this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); - } - } - - // Manage pane visibility when layers are manually toggled - this.updatePaneVisibilityAfterLayerChange(); - }); - - this.map.on('overlayremove', (event) => { - // Save enabled layers whenever a layer is removed (unless we're restoring from settings) - if (!this.isRestoringLayers) { - this.saveEnabledLayers(); - } - - if (event.name === 'Routes' || event.name === 'Tracks') { - // Don't auto-switch when layers are manually turned off - // Just update the radio button state to reflect current visibility - this.updateRadioButtonState(); - - // Manage pane visibility when layers are manually toggled - this.updatePaneVisibilityAfterLayerChange(); - } else if (event.name === 'Areas') { - // Hide draw control when Areas layer is disabled - if (this.drawControl && this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { - this.map.removeControl(this.drawControl); - } - } else if (event.name === 'Suggested Visits') { - // Clear suggested visits when layer is disabled - console.log('Suggested Visits layer disabled via layer control'); - if (this.visitsManager) { - // Clear the visit circles when layer is disabled - this.visitsManager.visitCircles.clearLayers(); - } - } else if (event.name === 'Scratch map') { - // Handle scratch map layer removal - console.log('Scratch map layer disabled via layer control'); - if (this.scratchLayerManager) { - this.scratchLayerManager.remove(); - } - } else if (event.name === 'Fog of War') { - // Fog canvas will be automatically removed by the layer's onRemove method - this.fogOverlay = null; - } - }); - } - - updatePreferredBaseLayer(selectedLayerName) { - fetch('/api/v1/settings', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - body: JSON.stringify({ - settings: { - preferred_map_layer: selectedLayerName - }, - }), - }) - .then((response) => response.json()) - .then((data) => { - if (data.status === 'success') { - showFlashMessage('notice', `Preferred map layer updated to: ${selectedLayerName}`); - } else { - showFlashMessage('error', data.message); - } - }); - } - - saveEnabledLayers() { - const enabledLayers = []; - const layerNames = [ - 'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War', - 'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits', - 'Family Members' - ]; - - const controlsLayer = { - 'Points': this.markersLayer, - 'Routes': this.polylinesLayer, - 'Tracks': this.tracksLayer, - 'Heatmap': this.heatmapLayer, - 'Fog of War': this.fogOverlay, - 'Scratch map': this.scratchLayerManager?.getLayer(), - 'Areas': this.areasLayer, - 'Photos': this.photoMarkers, - 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), - 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(), - 'Family Members': window.familyMembersController?.familyMarkersLayer - }; - - layerNames.forEach(name => { - const layer = controlsLayer[name]; - if (layer && this.map.hasLayer(layer)) { - enabledLayers.push(name); - } - }); - - fetch('/api/v1/settings', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - body: JSON.stringify({ - settings: { - enabled_map_layers: enabledLayers - }, - }), - }) - .then((response) => response.json()) - .then((data) => { - if (data.status === 'success') { - console.log('Enabled layers saved:', enabledLayers); - showFlashMessage('notice', 'Map layer preferences saved'); - } else { - console.error('Failed to save enabled layers:', data.message); - showFlashMessage('error', `Failed to save layer preferences: ${data.message}`); - } - }) - .catch(error => { - console.error('Error saving enabled layers:', error); - showFlashMessage('error', 'Error saving layer preferences'); - }); - } - - deletePoint(id, apiKey) { - fetch(`/api/v1/points/${id}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` - } - }) - .then(response => { - if (!response.ok) { - throw new Error('Network response was not ok'); - } - return response.json(); - }) - .then(data => { - // Remove the marker and update all layers - this.removeMarker(id); - let wasPolyLayerVisible = false; - // Explicitly remove old polylines layer from map - if (this.polylinesLayer) { - if (this.map.hasLayer(this.polylinesLayer)) { - wasPolyLayerVisible = true; - } - this.map.removeLayer(this.polylinesLayer); - - } - - // Create new polylines layer - this.polylinesLayer = createPolylinesLayer( - this.markers, - this.map, - this.timezone, - this.routeOpacity, - this.userSettings, - this.distanceUnit - ); - if (wasPolyLayerVisible) { - // Add new polylines layer to map and to layer control - this.polylinesLayer.addTo(this.map); - } else { - this.map.removeLayer(this.polylinesLayer); - } - // Update the layer control - if (this.layerControl) { - this.map.removeControl(this.layerControl); - const controlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.layerGroup(), - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup() - }; - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - } - - // Update heatmap - this.heatmapLayer.setLatLngs(this.markers.map(marker => [marker[0], marker[1], 0.2])); - - // Update fog if enabled - if (this.map.hasLayer(this.fogOverlay)) { - this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); - } - - // Show success message - showFlashMessage('notice', 'Point deleted successfully'); - }) - .catch(error => { - console.error('There was a problem with the delete request:', error); - showFlashMessage('error', 'Failed to delete point'); - }); - } - - removeMarker(id) { - const numericId = parseInt(id); - - const markerIndex = this.markersArray.findIndex(marker => - marker.getPopup().getContent().includes(`data-id="${id}"`) - ); - - if (markerIndex !== -1) { - this.markersArray[markerIndex].remove(); - this.markersArray.splice(markerIndex, 1); - this.markersLayer.clearLayers(); - this.markersLayer.addLayer(L.layerGroup(this.markersArray)); - - this.markers = this.markers.filter(marker => { - const markerId = parseInt(marker[6]); - return markerId !== numericId; - }); - - // Update scratch layer manager with updated markers - if (this.scratchLayerManager) { - this.scratchLayerManager.updateMarkers(this.markers); - } - } - } - - updateFog(markers, clearFogRadius, fogLineThreshold) { - // Call the fog overlay's updateFog method if it exists - if (this.fogOverlay && typeof this.fogOverlay.updateFog === 'function') { - this.fogOverlay.updateFog(markers, clearFogRadius, fogLineThreshold); - } else { - // Fallback for when fog overlay isn't available - const fog = document.getElementById('fog'); - if (!fog) { - initializeFogCanvas(this.map); - } - requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold)); - } - } - - initializeDrawControl() { - // Initialize the FeatureGroup to store editable layers - this.drawnItems = new L.FeatureGroup(); - this.map.addLayer(this.drawnItems); - - // Initialize the draw control and pass it the FeatureGroup of editable layers - this.drawControl = new L.Control.Draw({ - draw: { - polyline: false, - polygon: false, - rectangle: false, - marker: false, - circlemarker: false, - circle: { - shapeOptions: { - color: 'red', - fillColor: '#f03', - fillOpacity: 0.5, - }, - }, - } - }); - - // Handle circle creation - this.map.on('draw:created', (event) => { - const layer = event.layer; - - if (event.layerType === 'circle') { - try { - // Add the layer to the map first - layer.addTo(this.map); - handleAreaCreated(this.areasLayer, layer, this.apiKey); - } catch (error) { - console.error("Error in handleAreaCreated:", error); - console.error(error.stack); // Add stack trace - } - } - }); - } - - addSettingsButton() { - if (this.settingsButtonAdded) return; - - // Define the custom control - const SettingsControl = L.Control.extend({ - onAdd: (map) => { - const button = L.DomUtil.create('button', 'map-settings-button tooltip tooltip-right'); - button.innerHTML = ''; // Gear icon - button.setAttribute('data-tip', 'Settings'); - - // Style the button with theme-aware styling - applyThemeToButton(button, this.userTheme); - button.style.width = '30px'; - button.style.height = '30px'; - button.style.display = 'flex'; - button.style.alignItems = 'center'; - button.style.justifyContent = 'center'; - button.style.padding = '0'; - button.style.borderRadius = '4px'; - - // Disable map interactions when clicking the button - L.DomEvent.disableClickPropagation(button); - - // Toggle settings menu on button click - L.DomEvent.on(button, 'click', () => { - this.toggleSettingsMenu(); - }); - - return button; - } - }); - - // Add the control to the map - this.map.addControl(new SettingsControl({ position: 'topleft' })); - this.settingsButtonAdded = true; - } - - addInfoToggleButton() { - // Store reference to the controller instance for use in the control - const controller = this; - - const InfoToggleControl = L.Control.extend({ - options: { - position: 'bottomleft' - }, - onAdd: function(map) { - const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); - const button = L.DomUtil.create('button', 'map-info-toggle-button tooltip tooltip-right', container); - button.setAttribute('data-tip', 'Toggle footer visibility'); - - // Lucide info icon - button.innerHTML = ` - - - - - - `; - - // Style the button with theme-aware styling - applyThemeToButton(button, controller.userTheme); - button.style.width = '34px'; - button.style.height = '34px'; - button.style.display = 'flex'; - button.style.alignItems = 'center'; - button.style.justifyContent = 'center'; - button.style.cursor = 'pointer'; - button.style.border = 'none'; - button.style.borderRadius = '4px'; - - // Disable map interactions when clicking the button - L.DomEvent.disableClickPropagation(container); - - // Toggle footer visibility on button click - L.DomEvent.on(button, 'click', () => { - controller.toggleFooterVisibility(); - }); - - return container; - } - }); - - // Add the control to the map - this.map.addControl(new InfoToggleControl()); - } - - toggleFooterVisibility() { - // Toggle the page footer - const footer = document.getElementById('map-footer'); - if (!footer) return; - - const isCurrentlyHidden = footer.classList.contains('hidden'); - - // Toggle Tailwind's hidden class - footer.classList.toggle('hidden'); - - // Adjust bottom controls position based on footer visibility - if (isCurrentlyHidden) { - // Footer is being shown - move controls up - setTimeout(() => { - const footerHeight = footer.offsetHeight; - // Add extra 20px margin above footer - this.adjustBottomControls(footerHeight + 20); - }, 10); // Small delay to ensure footer is rendered - } else { - // Footer is being hidden - reset controls position - this.adjustBottomControls(10); // Back to default padding - } - - // Add click event to close footer when clicking on it (only add once) - if (!footer.dataset.clickHandlerAdded) { - footer.addEventListener('click', (e) => { - // Only close if clicking the footer itself, not its contents - if (e.target === footer) { - footer.classList.add('hidden'); - this.adjustBottomControls(10); // Reset controls position - } - }); - footer.dataset.clickHandlerAdded = 'true'; - } - } - - adjustBottomControls(paddingBottom) { - // Adjust all bottom Leaflet controls - const bottomLeftControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-left'); - const bottomRightControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-right'); - - if (bottomLeftControls) { - bottomLeftControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important'); - } - if (bottomRightControls) { - bottomRightControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important'); - } - } - - toggleSettingsMenu() { - // If the settings panel already exists, just show/hide it - if (this.settingsPanel) { - if (this.settingsPanel._map) { - this.map.removeControl(this.settingsPanel); - } else { - this.map.addControl(this.settingsPanel); - } - return; - } - - // Create the settings panel for the first time - this.settingsPanel = L.control({ position: 'topleft' }); - - this.settingsPanel.onAdd = () => { - const div = L.DomUtil.create('div', 'leaflet-settings-panel'); - - // Form HTML - div.innerHTML = ` -
-
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- -
- -
- -
- -
- - -
- -
- -
- - -
- `; - - // Style the panel with theme-aware styling - applyThemeToPanel(div, this.userTheme); - div.style.padding = '10px'; - div.style.width = '220px'; - div.style.maxHeight = 'calc(60vh - 20px)'; - div.style.overflowY = 'auto'; - - // Prevent map interactions when interacting with the form - L.DomEvent.disableClickPropagation(div); - L.DomEvent.disableScrollPropagation(div); - - // Attach event listener to the "Edit Gradient" button: - const editBtn = div.querySelector("#edit-gradient-btn"); - if (editBtn) { - editBtn.addEventListener("click", this.showGradientEditor.bind(this)); - } - - // Add event listener to the form submission - div.querySelector('#settings-form').addEventListener( - 'submit', this.updateSettings.bind(this) - ); - - return div; - }; - - this.map.addControl(this.settingsPanel); - } - - pointsRenderingModeChecked(value) { - if (value === this.pointsRenderingMode) { - return 'checked'; - } else { - return ''; - } - } - - liveMapEnabledChecked(value) { - if (value === this.liveMapEnabled) { - return 'checked'; - } else { - return ''; - } - } - - speedColoredRoutesChecked() { - return this.userSettings.speed_colored_routes ? 'checked' : ''; - } - - updateSettings(event) { - event.preventDefault(); - console.log('Form submitted'); - - // Convert percentage to decimal for route_opacity - const opacityValue = event.target.route_opacity.value.replace('%', ''); - const decimalOpacity = parseFloat(opacityValue) / 100; - - fetch('/api/v1/settings', { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - body: JSON.stringify({ - settings: { - route_opacity: decimalOpacity.toString(), - fog_of_war_meters: event.target.fog_of_war_meters.value, - fog_of_war_threshold: event.target.fog_of_war_threshold.value, - meters_between_routes: event.target.meters_between_routes.value, - minutes_between_routes: event.target.minutes_between_routes.value, - time_threshold_minutes: event.target.time_threshold_minutes.value, - merge_threshold_minutes: event.target.merge_threshold_minutes.value, - points_rendering_mode: event.target.points_rendering_mode.value, - live_map_enabled: event.target.live_map_enabled.checked, - speed_colored_routes: event.target.speed_colored_routes.checked, - speed_color_scale: event.target.speed_color_scale.value - }, - }), - }) - .then((response) => response.json()) - .then((data) => { - console.log('Settings update response:', data); - if (data.status === 'success') { - showFlashMessage('notice', data.message); - this.updateMapWithNewSettings(data.settings); - - if (data.settings.live_map_enabled) { - this.setupSubscription(); - if (this.liveMapHandler) { - this.liveMapHandler.enable(); - } - } else { - if (this.liveMapHandler) { - this.liveMapHandler.disable(); - } - } - } else { - showFlashMessage('error', data.message); - } - }) - .catch(error => { - console.error('Settings update error:', error); - showFlashMessage('error', 'Failed to update settings'); - }); - } - - updateMapWithNewSettings(newSettings) { - // Show loading indicator - const loadingDiv = document.createElement('div'); - loadingDiv.className = 'map-loading-overlay'; - loadingDiv.innerHTML = '
Updating map...
'; - document.body.appendChild(loadingDiv); - - try { - // Update settings first - if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) { - if (this.polylinesLayer) { - updatePolylinesColors( - this.polylinesLayer, - newSettings.speed_colored_routes, - newSettings.speed_color_scale - ); - } - } - - if (newSettings.speed_color_scale !== this.userSettings.speed_color_scale) { - if (this.polylinesLayer) { - updatePolylinesColors( - this.polylinesLayer, - newSettings.speed_colored_routes, - newSettings.speed_color_scale - ); - } - } - - if (newSettings.route_opacity !== this.userSettings.route_opacity) { - const newOpacity = parseFloat(newSettings.route_opacity) || 0.6; - if (this.polylinesLayer) { - updatePolylinesOpacity(this.polylinesLayer, newOpacity); - } - } - - // Update the local settings - this.userSettings = { ...this.userSettings, ...newSettings }; - // Store the value as decimal internally, but display as percentage in UI - this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; - this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; - this.liveMapEnabled = newSettings.live_map_enabled || false; - - // Update the DOM data attribute to keep it in sync - const mapElement = document.getElementById('map'); - if (mapElement) { - mapElement.setAttribute('data-user_settings', JSON.stringify(this.userSettings)); - // Update theme if it changed - if (newSettings.theme && newSettings.theme !== this.userTheme) { - this.userTheme = newSettings.theme; - mapElement.setAttribute('data-user_theme', this.userTheme); - - // Dispatch theme change event for other controllers - document.dispatchEvent(new CustomEvent('theme:changed', { - detail: { theme: this.userTheme } - })); - } - } - - // Store current layer states - const layerStates = { - Points: this.map.hasLayer(this.markersLayer), - Routes: this.map.hasLayer(this.polylinesLayer), - Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false, - Heatmap: this.map.hasLayer(this.heatmapLayer), - "Fog of War": this.map.hasLayer(this.fogOverlay), - "Scratch map": this.scratchLayerManager?.isVisible() || false, - Areas: this.map.hasLayer(this.areasLayer), - Photos: this.map.hasLayer(this.photoMarkers) - }; - - // Remove only the layer control - if (this.layerControl) { - this.map.removeControl(this.layerControl); - } - - // Create new controls layer object - const controlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Tracks: this.tracksLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup() - }; - - // Re-add the layer control in the same position - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - - // Restore layer visibility states - Object.entries(layerStates).forEach(([name, wasVisible]) => { - const layer = controlsLayer[name]; - if (wasVisible && layer) { - layer.addTo(this.map); - // Re-establish event handlers for polylines layer when it's re-added - if (name === 'Routes' && layer === this.polylinesLayer) { - reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); - } - } else if (layer && this.map.hasLayer(layer)) { - this.map.removeLayer(layer); - } - }); - - // Manage pane visibility based on which layers are visible - const routesVisible = this.map.hasLayer(this.polylinesLayer); - const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer); - - if (routesVisible && !tracksVisible) { - managePaneVisibility(this.map, 'routes'); - } else if (tracksVisible && !routesVisible) { - managePaneVisibility(this.map, 'tracks'); - } else { - managePaneVisibility(this.map, 'both'); - } - - } catch (error) { - console.error('Error updating map settings:', error); - console.error(error.stack); - } finally { - // Remove loading indicator - setTimeout(() => { - document.body.removeChild(loadingDiv); - }, 500); - } - } - - initializeTopRightButtons() { - // Add all top-right buttons in the correct order: - // 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer - // Note: Layer control is added separately and appears at the top - - this.topRightControls = addTopRightButtons( - this.map, - { - onSelectArea: () => this.visitsManager.toggleSelectionMode(), - // onAddVisit is intentionally null - the add_visit_controller will attach its handler - onAddVisit: null, - onToggleCalendar: () => this.toggleRightPanel(), - onToggleDrawer: () => this.visitsManager.toggleDrawer() - }, - this.userTheme - ); - - // Add CSS for selection button active state (needed by visits manager) - if (!document.getElementById('selection-tool-active-style')) { - const style = document.createElement('style'); - style.id = 'selection-tool-active-style'; - style.textContent = ` - #selection-tool-button.active { - border: 2px dashed #3388ff !important; - box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important; - } - `; - document.head.appendChild(style); - } - } - - shouldShowTracksSelector() { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get('tracks_debug') === 'true'; - } - - addRoutesTracksSelector() { - // Store reference to the controller instance for use in the control - const controller = this; - - const RouteTracksControl = L.Control.extend({ - onAdd: function(map) { - const container = L.DomUtil.create('div', 'routes-tracks-selector leaflet-bar'); - applyThemeToControl(container, controller.userTheme, { - padding: '8px', - borderRadius: '4px', - fontSize: '12px', - lineHeight: '1.2' - }); - - // Get saved preference or default to 'routes' - const savedPreference = localStorage.getItem('mapRouteMode') || 'routes'; - - container.innerHTML = ` -
Display
-
- - -
- `; - - // Disable map interactions when clicking the control - L.DomEvent.disableClickPropagation(container); - - // Add change event listeners - const radioButtons = container.querySelectorAll('input[name="route-mode"]'); - radioButtons.forEach(radio => { - L.DomEvent.on(radio, 'change', () => { - if (radio.checked) { - controller.switchRouteMode(radio.value); - } - }); - }); - - return container; - } - }); - - // Add the control to the map - this.map.addControl(new RouteTracksControl({ position: 'topleft' })); - - // Apply initial state based on saved preference - const savedPreference = localStorage.getItem('mapRouteMode') || 'routes'; - this.switchRouteMode(savedPreference, true); - - // Set initial pane visibility - this.updatePaneVisibilityAfterLayerChange(); - } - - switchRouteMode(mode, isInitial = false) { - // Save preference to localStorage - localStorage.setItem('mapRouteMode', mode); - - if (mode === 'routes') { - // Hide tracks layer if it exists and is visible - if (this.tracksLayer && this.map.hasLayer(this.tracksLayer)) { - this.map.removeLayer(this.tracksLayer); - } - - // Show routes layer if it exists and is not visible - if (this.polylinesLayer && !this.map.hasLayer(this.polylinesLayer)) { - this.map.addLayer(this.polylinesLayer); - // Re-establish event handlers after adding the layer back - reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); - } else if (this.polylinesLayer) { - reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); - } - - // Manage pane visibility to fix z-index blocking - managePaneVisibility(this.map, 'routes'); - - // Update layer control checkboxes - this.updateLayerControlCheckboxes('Routes', true); - this.updateLayerControlCheckboxes('Tracks', false); - } else if (mode === 'tracks') { - // Hide routes layer if it exists and is visible - if (this.polylinesLayer && this.map.hasLayer(this.polylinesLayer)) { - this.map.removeLayer(this.polylinesLayer); - } - - // Show tracks layer if it exists and is not visible - if (this.tracksLayer && !this.map.hasLayer(this.tracksLayer)) { - this.map.addLayer(this.tracksLayer); - } - - // Manage pane visibility to fix z-index blocking - managePaneVisibility(this.map, 'tracks'); - - // Update layer control checkboxes - this.updateLayerControlCheckboxes('Routes', false); - this.updateLayerControlCheckboxes('Tracks', true); - } - } - - updateLayerControlCheckboxes(layerName, isVisible) { - // Find the layer control input for the specified layer - const layerControlContainer = document.querySelector('.leaflet-control-layers'); - if (!layerControlContainer) return; - - const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]'); - inputs.forEach(input => { - const label = input.nextElementSibling; - if (label && label.textContent.trim() === layerName) { - input.checked = isVisible; - } - }); - } - - handleRouteLayerToggle(mode) { - // Update the radio button selection - const radioButtons = document.querySelectorAll('input[name="route-mode"]'); - radioButtons.forEach(radio => { - if (radio.value === mode) { - radio.checked = true; - } - }); - - // Switch to the selected mode and enforce mutual exclusivity - this.switchRouteMode(mode); - } - - updateRadioButtonState() { - // Update radio buttons to reflect current layer visibility - const routesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer); - const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer); - - const radioButtons = document.querySelectorAll('input[name="route-mode"]'); - radioButtons.forEach(radio => { - if (radio.value === 'routes' && routesVisible && !tracksVisible) { - radio.checked = true; - } else if (radio.value === 'tracks' && tracksVisible && !routesVisible) { - radio.checked = true; - } - }); - } - - updatePaneVisibilityAfterLayerChange() { - // Update pane visibility based on current layer visibility - const routesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer); - const tracksVisible = this.tracksLayer && this.map.hasLayer(this.tracksLayer); - - if (routesVisible && !tracksVisible) { - managePaneVisibility(this.map, 'routes'); - } else if (tracksVisible && !routesVisible) { - managePaneVisibility(this.map, 'tracks'); - } else { - managePaneVisibility(this.map, 'both'); - } - } - - initializeLayersFromSettings() { - // Initialize layer visibility based on user settings or defaults - // This method sets up the initial state of overlay layers - - // Get enabled layers from user settings - const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap']; - console.log('Initializing layers from settings:', enabledLayers); - - const controlsLayer = { - 'Points': this.markersLayer, - 'Routes': this.polylinesLayer, - 'Tracks': this.tracksLayer, - 'Heatmap': this.heatmapLayer, - 'Fog of War': this.fogOverlay, - 'Scratch map': this.scratchLayerManager?.getLayer(), - 'Areas': this.areasLayer, - 'Photos': this.photoMarkers, - 'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(), - 'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(), - 'Family Members': window.familyMembersController?.familyMarkersLayer - }; - - // Apply saved layer preferences - Object.entries(controlsLayer).forEach(([name, layer]) => { - if (!layer) { - if (enabledLayers.includes(name)) { - console.log(`Layer ${name} is in enabled layers but layer object is null/undefined`); - } - return; - } - - const shouldBeEnabled = enabledLayers.includes(name); - const isCurrentlyEnabled = this.map.hasLayer(layer); - - if (name === 'Family Members') { - console.log('Family Members layer check:', { - shouldBeEnabled, - isCurrentlyEnabled, - layerExists: !!layer, - controllerExists: !!window.familyMembersController - }); - } - - if (shouldBeEnabled && !isCurrentlyEnabled) { - // Add layer to map - layer.addTo(this.map); - console.log(`Enabled layer: ${name}`); - - // Trigger special initialization for certain layers - if (name === 'Photos') { - const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const endDate = urlParams.get('end_at') || new Date().toISOString(); - fetchAndDisplayPhotos({ - map: this.map, - photoMarkers: this.photoMarkers, - apiKey: this.apiKey, - startDate: startDate, - endDate: endDate, - userSettings: this.userSettings - }); - } else if (name === 'Fog of War') { - this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); - } else if (name === 'Suggested Visits' || name === 'Confirmed Visits') { - if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') { - this.visitsManager.fetchAndDisplayVisits(); - } - } else if (name === 'Scratch map') { - if (this.scratchLayerManager) { - this.scratchLayerManager.addToMap(); - } - } else if (name === 'Routes') { - // Re-establish event handlers for routes layer - reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit); - } else if (name === 'Areas') { - // Show draw control when Areas layer is enabled - if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { - this.map.addControl(this.drawControl); - } - } else if (name === 'Family Members') { - // Refresh family locations when layer is restored - if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') { - window.familyMembersController.refreshFamilyLocations(); - } - } - } else if (!shouldBeEnabled && isCurrentlyEnabled) { - // Remove layer from map - this.map.removeLayer(layer); - console.log(`Disabled layer: ${name}`); - } - }); - } - - setupFamilyLayerListener() { - // Listen for when the Family Members layer becomes available - document.addEventListener('family:layer:ready', (event) => { - console.log('Family layer ready event received'); - const enabledLayers = this.userSettings.enabled_map_layers || []; - - // Check if Family Members should be enabled based on saved settings - if (enabledLayers.includes('Family Members')) { - const layer = event.detail.layer; - if (layer && !this.map.hasLayer(layer)) { - // Set flag to prevent saving during restoration - this.isRestoringLayers = true; - - layer.addTo(this.map); - console.log('Enabled layer: Family Members (from ready event)'); - - // Refresh family locations - if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') { - window.familyMembersController.refreshFamilyLocations(); - } - - // Reset flag after a short delay to allow all events to complete - setTimeout(() => { - this.isRestoringLayers = false; - }, 100); - } - } - }, { once: true }); // Only listen once - } - - toggleRightPanel() { - if (this.rightPanel) { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - if (panel.style.display === 'none') { - panel.style.display = 'block'; - localStorage.setItem('mapPanelOpen', 'true'); - } else { - panel.style.display = 'none'; - localStorage.setItem('mapPanelOpen', 'false'); - } - return; - } - } - - this.rightPanel = L.control({ position: 'topright' }); - - this.rightPanel.onAdd = () => { - const div = L.DomUtil.create('div', 'leaflet-right-panel'); - const allMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - // Get current date from URL query parameters - const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at'); - const currentYear = startDate - ? new Date(startDate).getFullYear().toString() - : new Date().getFullYear().toString(); - const currentMonth = startDate - ? allMonths[new Date(startDate).getMonth()] - : allMonths[new Date().getMonth()]; - - // Initially create select with loading state and current year if available - div.innerHTML = ` -
-
-
- - - Whole year - -
- -
- ${allMonths.map(month => ` - - - - `).join('')} -
-
- -
- `; - - this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths); - - applyThemeToPanel(div, this.userTheme); - div.style.padding = '10px'; - div.style.marginRight = '10px'; - div.style.marginTop = '10px'; - div.style.width = '300px'; - div.style.maxHeight = '80vh'; - div.style.overflowY = 'auto'; - - L.DomEvent.disableClickPropagation(div); - - // Add container for visited cities - div.innerHTML += ` -
-

Visited cities

-
-

Loading visited places...

-
-
- `; - - // Prevent map zoom when scrolling the cities list - const citiesList = div.querySelector('#visited-cities-list'); - L.DomEvent.disableScrollPropagation(citiesList); - - // Fetch visited cities when panel is first created - this.fetchAndDisplayVisitedCities(); - - // Since user clicked to open panel, make it visible and update localStorage - div.style.display = 'block'; - localStorage.setItem('mapPanelOpen', 'true'); - - return div; - }; - - this.map.addControl(this.rightPanel); - } - - async fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths) { - try { - let yearsData; - - // Check cache first - if (this.trackedMonthsCache) { - yearsData = this.trackedMonthsCache; - } else { - const response = await fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - yearsData = await response.json(); - // Store in cache - this.trackedMonthsCache = yearsData; - } - - const yearSelect = document.getElementById('year-select'); - - if (!Array.isArray(yearsData) || yearsData.length === 0) { - yearSelect.innerHTML = ''; - return; - } - - // Check if the current year exists in the API response - const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear); - - const options = yearsData - .filter(yearData => yearData && yearData.year) - .map(yearData => { - const months = Array.isArray(yearData.months) ? yearData.months : []; - const isCurrentYear = yearData.year.toString() === currentYear; - return ` - - `; - }) - .join(''); - - yearSelect.innerHTML = ` - - ${options} - `; - - const updateMonthLinks = (selectedYear, availableMonths) => { - // Get current date from URL parameters - const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date(); - const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date(); - - allMonths.forEach((month, index) => { - const monthLink = div.querySelector(`a[data-month-name="${month}"]`); - if (!monthLink) return; - - // Update the content to show the month name instead of loading dots - monthLink.innerHTML = month; - - // Check if this month falls within the selected date range - const isSelected = startDate && endDate && - selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year - isMonthInRange(index, startDate, endDate, parseInt(selectedYear)); - - if (availableMonths.includes(month)) { - monthLink.classList.remove('disabled'); - monthLink.style.pointerEvents = 'auto'; - monthLink.style.opacity = '1'; - - // Update the active state based on selection - if (isSelected) { - monthLink.classList.add('btn-active', 'btn-primary'); - } else { - monthLink.classList.remove('btn-active', 'btn-primary'); - } - - const monthNum = (index + 1).toString().padStart(2, '0'); - const startDate = `${selectedYear}-${monthNum}-01T00:00`; - const lastDay = new Date(selectedYear, index + 1, 0).getDate(); - const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`; - - const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; - monthLink.setAttribute('href', href); - } else { - monthLink.classList.add('disabled'); - monthLink.classList.remove('btn-active', 'btn-primary'); - monthLink.style.pointerEvents = 'none'; - monthLink.style.opacity = '0.6'; - monthLink.setAttribute('href', '#'); - } - }); - }; - - // Helper function to check if a month falls within a date range - const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => { - // Create date objects for the first and last day of the month in the selected year - const monthStart = new Date(selectedYear, monthIndex, 1); - const monthEnd = new Date(selectedYear, monthIndex + 1, 0); - - // Check if any part of the month overlaps with the selected date range - return monthStart <= endDate && monthEnd >= startDate; - }; - - yearSelect.addEventListener('change', (event) => { - const selectedOption = event.target.selectedOptions[0]; - const selectedYear = selectedOption.value; - const availableMonths = JSON.parse(selectedOption.dataset.months || '[]'); - - // Update whole year link with selected year - const wholeYearLink = document.getElementById('whole-year-link'); - const startDate = `${selectedYear}-01-01T00:00`; - const endDate = `${selectedYear}-12-31T23:59`; - const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; - wholeYearLink.setAttribute('href', href); - - updateMonthLinks(selectedYear, availableMonths); - }); - - // If we have a current year, set it and update month links - if (currentYear && currentYearData) { - yearSelect.value = currentYear; - updateMonthLinks(currentYear, currentYearData.months); - } - } catch (error) { - const yearSelect = document.getElementById('year-select'); - yearSelect.innerHTML = ''; - console.error('Error fetching tracked months:', error); - } - } - - getWholeYearLink() { - // First try to get year from URL parameters - const urlParams = new URLSearchParams(window.location.search); - let year; - - if (urlParams.has('start_at')) { - year = new Date(urlParams.get('start_at')).getFullYear(); - } else { - // If no URL params, try to get year from start_at input - const startAtInput = document.querySelector('input#start_at'); - if (startAtInput && startAtInput.value) { - year = new Date(startAtInput.value).getFullYear(); - } else { - // If no input value, use current year - year = new Date().getFullYear(); - } - } - - const startDate = `${year}-01-01T00:00`; - const endDate = `${year}-12-31T23:59`; - return `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; - } - - async fetchAndDisplayVisitedCities() { - const urlParams = new URLSearchParams(window.location.search); - const startAt = urlParams.get('start_at') || new Date().toISOString(); - const endAt = urlParams.get('end_at') || new Date().toISOString(); - - // Create a cache key from the date range - const cacheKey = `${startAt}-${endAt}`; - - // Check if we have cached data for this date range - if (this.visitedCitiesCache.has(cacheKey)) { - this.displayVisitedCities(this.visitedCitiesCache.get(cacheKey)); - return; - } - - try { - const response = await fetch(`/api/v1/countries/visited_cities?api_key=${this.apiKey}&start_at=${startAt}&end_at=${endAt}`, { - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - } - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - - const data = await response.json(); - - // Cache the results - this.visitedCitiesCache.set(cacheKey, data.data); - - this.displayVisitedCities(data.data); - } catch (error) { - console.error('Error fetching visited cities:', error); - const container = document.getElementById('visited-cities-list'); - if (container) { - container.innerHTML = '

Error loading visited places

'; - } - } - } - - displayVisitedCities(citiesData) { - const container = document.getElementById('visited-cities-list'); - if (!container) return; - - if (!citiesData || citiesData.length === 0) { - container.innerHTML = '

No places visited during this period

'; - return; - } - - const html = citiesData.map(country => ` -
-

${country.country}

-
    - ${country.cities.map(city => ` -
  • - ${city.city} - - (${new Date(city.timestamp * 1000).toLocaleDateString()}) - -
  • - `).join('')} -
-
- `).join(''); - - container.innerHTML = html; - } - - showGradientEditor() { - const modal = document.createElement("div"); - modal.id = "gradient-editor-modal"; - Object.assign(modal.style, { - position: "fixed", - top: "0", - left: "0", - right: "0", - bottom: "0", - backgroundColor: "rgba(0, 0, 0, 0.5)", - display: "flex", - justifyContent: "center", - alignItems: "center", - zIndex: "100", - }); - - const content = document.createElement("div"); - Object.assign(content.style, { - backgroundColor: "#fff", - padding: "20px", - borderRadius: "5px", - minWidth: "300px", - maxHeight: "80vh", - display: "flex", - flexDirection: "column", - }); - - const title = document.createElement("h2"); - title.textContent = "Edit Speed Color Scale"; - content.appendChild(title); - - const gradientContainer = document.createElement("div"); - gradientContainer.id = "gradient-editor-container"; - Object.assign(gradientContainer.style, { - marginTop: "15px", - overflowY: "auto", - flex: "1", - border: "1px solid #ccc", - padding: "5px", - }); - - const createRow = (stop = { speed: 0, color: "#000000" }) => { - const row = document.createElement("div"); - row.style.display = "flex"; - row.style.alignItems = "center"; - row.style.gap = "10px"; - row.style.marginBottom = "8px"; - - const speedInput = document.createElement("input"); - speedInput.type = "number"; - speedInput.value = stop.speed; - speedInput.style.width = "70px"; - - const colorInput = document.createElement("input"); - colorInput.type = "color"; - colorInput.value = stop.color; - colorInput.style.width = "70px"; - - const removeBtn = document.createElement("button"); - removeBtn.textContent = "x"; - removeBtn.style.color = "#cc3311"; - removeBtn.style.flexShrink = "0"; - removeBtn.addEventListener("click", () => { - if (gradientContainer.childElementCount > 1) { - gradientContainer.removeChild(row); - } else { - showFlashMessage('error', 'At least one gradient stop is required.'); - } - }); - - row.appendChild(speedInput); - row.appendChild(colorInput); - row.appendChild(removeBtn); - return row; - }; - - let stops; - try { - stops = colorFormatDecode(this.speedColorScale); - } catch (error) { - stops = colorStopsFallback; - } - stops.forEach(stop => { - const row = createRow(stop); - gradientContainer.appendChild(row); - }); - - content.appendChild(gradientContainer); - - const addRowBtn = document.createElement("button"); - addRowBtn.textContent = "Add Row"; - addRowBtn.style.marginTop = "10px"; - addRowBtn.addEventListener("click", () => { - const newRow = createRow({ speed: 0, color: "#000000" }); - gradientContainer.appendChild(newRow); - }); - content.appendChild(addRowBtn); - - const btnContainer = document.createElement("div"); - btnContainer.style.display = "flex"; - btnContainer.style.justifyContent = "flex-end"; - btnContainer.style.gap = "10px"; - btnContainer.style.marginTop = "15px"; - - const cancelBtn = document.createElement("button"); - cancelBtn.textContent = "Cancel"; - cancelBtn.addEventListener("click", () => { - document.body.removeChild(modal); - }); - - const saveBtn = document.createElement("button"); - saveBtn.textContent = "Save"; - saveBtn.addEventListener("click", () => { - const newStops = []; - gradientContainer.querySelectorAll("div").forEach(row => { - const inputs = row.querySelectorAll("input"); - const speed = Number(inputs[0].value); - const color = inputs[1].value; - newStops.push({ speed, color }); - }); - - const newGradient = colorFormatEncode(newStops); - - this.speedColorScale = newGradient; - const speedColorScaleInput = document.getElementById("speed_color_scale"); - if (speedColorScaleInput) { - speedColorScaleInput.value = newGradient; - } - - document.body.removeChild(modal); - }); - - btnContainer.appendChild(cancelBtn); - btnContainer.appendChild(saveBtn); - content.appendChild(btnContainer); - modal.appendChild(content); - document.body.appendChild(modal); - } - - // Track-related methods - async initializeTracksLayer() { - // Use pre-loaded tracks data if available - if (this.tracksData && this.tracksData.length > 0) { - this.createTracksFromData(this.tracksData); - } else { - // Create empty layer for layer control - this.tracksLayer = L.layerGroup(); - } - } - - createTracksFromData(tracksData) { - // Clear existing tracks - this.tracksLayer.clearLayers(); - - if (!tracksData || tracksData.length === 0) { - return; - } - - // Create tracks layer with data and add to existing tracks layer - const newTracksLayer = createTracksLayer( - tracksData, - this.map, - this.userSettings, - this.distanceUnit - ); - - // Add all tracks to the existing tracks layer - newTracksLayer.eachLayer((layer) => { - this.tracksLayer.addLayer(layer); - }); - } - - toggleTracksVisibility(event) { - this.tracksVisible = event.target.checked; - - if (this.tracksLayer) { - toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); - } - } - - initializeLocationSearch() { - if (this.map && this.apiKey && this.features.reverse_geocoding) { - this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme); - } - } - - // Helper method for family controller to update layer control - updateLayerControl(additionalLayers = {}) { - if (!this.layerControl) return; - - // Store which base and overlay layers are currently visible - const overlayStates = {}; - let activeBaseLayer = null; - let activeBaseLayerName = null; - - if (this.layerControl._layers) { - Object.values(this.layerControl._layers).forEach(layerObj => { - if (layerObj.overlay && layerObj.layer) { - // Store overlay layer states - overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer); - } else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) { - // Store the currently active base layer - activeBaseLayer = layerObj.layer; - activeBaseLayerName = layerObj.name; - } - }); - } - - // Remove existing layer control - this.map.removeControl(this.layerControl); - - // Create base controls layer object - const baseControlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Tracks: this.tracksLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup(), - "Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(), - "Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() - }; - - // Merge with additional layers (like family members) - const controlsLayer = { ...baseControlsLayer, ...additionalLayers }; - - // Get base maps and re-add the layer control - const baseMaps = this.baseMaps(); - this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map); - - // Restore the active base layer if we had one - if (activeBaseLayer && activeBaseLayerName) { - console.log(`Restoring base layer: ${activeBaseLayerName}`); - // Make sure the base layer is added to the map - if (!this.map.hasLayer(activeBaseLayer)) { - activeBaseLayer.addTo(this.map); - } - } else { - // If no active base layer was found, ensure we have a default one - console.log('No active base layer found, adding default'); - const defaultBaseLayer = Object.values(baseMaps)[0]; - if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) { - defaultBaseLayer.addTo(this.map); - } - } - - // Restore overlay layer visibility states - Object.entries(overlayStates).forEach(([name, wasVisible]) => { - const layer = controlsLayer[name]; - if (layer && wasVisible && !this.map.hasLayer(layer)) { - layer.addTo(this.map); - } - }); - } - - togglePlaceCreationMode() { - if (!this.placesManager) { - console.warn("Places manager not initialized"); - return; - } - - const button = document.getElementById('create-place-btn'); - - if (this.placesManager.creationMode) { - // Disable creation mode - this.placesManager.disableCreationMode(); - if (button) { - button.classList.remove('btn-error'); - button.classList.add('btn-success'); - button.title = 'Click to create a place on the map'; - } - } else { - // Enable creation mode - this.placesManager.enableCreationMode(); - if (button) { - button.classList.remove('btn-success'); - button.classList.add('btn-error'); - button.title = 'Click map to place marker (click again to cancel)'; - } - } - } - - -} diff --git a/app/serializers/tag_serializer.rb b/app/serializers/tag_serializer.rb new file mode 100644 index 00000000..8e187103 --- /dev/null +++ b/app/serializers/tag_serializer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class TagSerializer + def initialize(tag) + @tag = tag + end + + def call + { + tag_id: tag.id, + tag_name: tag.name, + tag_icon: tag.icon, + tag_color: tag.color, + radius_meters: tag.privacy_radius_meters, + places: places + } + end + + private + + attr_reader :tag + + def places + tag.places.map do |place| + { + id: place.id, + name: place.name, + latitude: place.latitude.to_f, + longitude: place.longitude.to_f + } + end + end +end diff --git a/db/migrate/20251116134506_add_user_id_to_places.rb b/db/migrate/20251116134506_add_user_id_to_places.rb index 7d9d9fc7..f54220be 100644 --- a/db/migrate/20251116134506_add_user_id_to_places.rb +++ b/db/migrate/20251116134506_add_user_id_to_places.rb @@ -1,5 +1,6 @@ class AddUserIdToPlaces < ActiveRecord::Migration[8.0] disable_ddl_transaction! + def up # Add nullable for backward compatibility, will enforce later via data migration add_reference :places, :user, null: true, index: {algorithm: :concurrently} unless foreign_key_exists?(:places, :users) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 25770617..2a97ccb7 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,6 +14,8 @@ RSpec.describe User, type: :model do it { is_expected.to have_many(:places).through(:visits) } it { is_expected.to have_many(:trips).dependent(:destroy) } it { is_expected.to have_many(:tracks).dependent(:destroy) } + it { is_expected.to have_many(:tags).dependent(:destroy) } + it { is_expected.to have_many(:visited_places).through(:visits) } end describe 'enums' do diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb new file mode 100644 index 00000000..4007de4a --- /dev/null +++ b/spec/requests/api/v1/tags_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Tags', type: :request do + let(:user) { create(:user) } + let(:tag) { create(:tag, user: user, name: 'Home', icon: 'šŸ ', color: '#4CAF50', privacy_radius_meters: 500) } + let!(:place) { create(:place, name: 'My Place', latitude: 10.0, longitude: 20.0) } + + before do + tag.places << place + end + + describe 'GET /api/v1/tags/privacy_zones' do + context 'when authenticated' do + before do + user.create_api_key unless user.api_key.present? + get privacy_zones_api_v1_tags_path, params: { api_key: user.api_key } + end + + it 'returns success' do + expect(response).to be_successful + end + + it 'returns the correct JSON structure' do + json_response = JSON.parse(response.body) + expect(json_response).to be_an(Array) + expect(json_response.first).to include( + 'tag_id' => tag.id, + 'tag_name' => 'Home', + 'tag_icon' => 'šŸ ', + 'tag_color' => '#4CAF50', + 'radius_meters' => 500 + ) + expect(json_response.first['places']).to be_an(Array) + expect(json_response.first['places'].first).to include( + 'id' => place.id, + 'name' => 'My Place', + 'latitude' => 10.0, + 'longitude' => 20.0 + ) + end + end + + context 'when not authenticated' do + it 'returns unauthorized' do + get privacy_zones_api_v1_tags_path + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/serializers/tag_serializer_spec.rb b/spec/serializers/tag_serializer_spec.rb new file mode 100644 index 00000000..6d62aa92 --- /dev/null +++ b/spec/serializers/tag_serializer_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TagSerializer do + let(:tag) { create(:tag, name: 'Home', icon: 'šŸ ', color: '#4CAF50', privacy_radius_meters: 500) } + let!(:place) { create(:place, name: 'My Place', latitude: 10.0, longitude: 20.0) } + + before do + tag.places << place + end + + subject { described_class.new(tag).call } + + it 'returns the correct JSON structure' do + expect(subject).to eq({ + tag_id: tag.id, + tag_name: 'Home', + tag_icon: 'šŸ ', + tag_color: '#4CAF50', + radius_meters: 500, + places: [ + { + id: place.id, + name: 'My Place', + latitude: 10.0, + longitude: 20.0 + } + ] + }) + end +end diff --git a/verify_places_integration.rb b/verify_places_integration.rb deleted file mode 100755 index 2b82fe32..00000000 --- a/verify_places_integration.rb +++ /dev/null @@ -1,105 +0,0 @@ -# Run with: bundle exec rails runner verify_places_integration.rb - -puts "šŸ” Verifying Places Integration..." -puts "=" * 50 - -# Check files exist -files_to_check = [ - 'app/javascript/maps/places.js', - 'app/javascript/controllers/stat_page_controller.js', - 'app/javascript/controllers/place_creation_controller.js', - 'app/views/stats/_month.html.erb', - 'app/views/shared/_place_creation_modal.html.erb' -] - -puts "\nšŸ“ Checking Files:" -files_to_check.each do |file| - if File.exist?(file) - puts " āœ… #{file}" - else - puts " āŒ MISSING: #{file}" - end -end - -# Check view has our changes -puts "\nšŸŽØ Checking View Changes:" -month_view = File.read('app/views/stats/_month.html.erb') - -if month_view.include?('placesBtn') - puts " āœ… Places button found in view" -else - puts " āŒ Places button NOT found in view" -end - -if month_view.include?('Filter Places by Tags') - puts " āœ… Tag filter section found in view" -else - puts " āŒ Tag filter section NOT found in view" -end - -if month_view.include?('place_creation_modal') - puts " āœ… Place creation modal included" -else - puts " āŒ Place creation modal NOT included" -end - -# Check JavaScript has our changes -puts "\nšŸ’» Checking JavaScript Changes:" -controller_js = File.read('app/javascript/controllers/stat_page_controller.js') - -if controller_js.include?('PlacesManager') - puts " āœ… PlacesManager imported" -else - puts " āŒ PlacesManager NOT imported" -end - -if controller_js.include?('togglePlaces()') - puts " āœ… togglePlaces() method found" -else - puts " āŒ togglePlaces() method NOT found" -end - -if controller_js.include?('filterPlacesByTags') - puts " āœ… filterPlacesByTags() method found" -else - puts " āŒ filterPlacesByTags() method NOT found" -end - -# Check database -puts "\nšŸ—„ļø Checking Database:" -user = User.first -if user - puts " āœ… Found user: #{user.email}" - puts " Tags: #{user.tags.count}" - puts " Places: #{user.places.count}" - - if user.tags.any? - puts "\n šŸ“Œ Your Tags:" - user.tags.limit(5).each do |tag| - puts " #{tag.icon} #{tag.name} (#{tag.places.count} places)" - end - else - puts " āš ļø No tags created yet. Create some at /tags" - end - - if user.places.any? - puts "\n šŸ“ Your Places:" - user.places.limit(5).each do |place| - puts " #{place.name} - #{place.tags.map(&:name).join(', ')}" - end - else - puts " āš ļø No places created yet. Use the API or create via console." - end -else - puts " āŒ No users found" -end - -puts "\n" + "=" * 50 -puts "āœ… Integration files are in place!" -puts "\nšŸ“‹ Next Steps:" -puts " 1. Restart your Rails server" -puts " 2. Hard refresh your browser (Cmd+Shift+R)" -puts " 3. Navigate to /stats/#{Date.today.year}/#{Date.today.month}" -puts " 4. Look for 'Places' button next to 'Heatmap' and 'Points'" -puts " 5. Create tags at /tags if you haven't already" -puts " 6. Create places via API with those tags"