diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..722dcc68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Dawarich is a Rails 8 monolith. Controllers, models, jobs, services, policies, and Stimulus/Turbo JS live in `app/`, while shared POROs sit in `lib/`. Configuration, credentials, and cron/Sidekiq settings live in `config/`; API documentation assets are in `swagger/`. Database migrations and seeds live in `db/`, Docker tooling sits in `docker/`, and docs or media live in `docs/` and `screenshots/`. Runtime artifacts in `storage/`, `tmp/`, and `log/` stay untracked. + +## Architecture & Key Services +The stack pairs Rails 8 with PostgreSQL + PostGIS, Redis-backed Sidekiq, Devise/Pundit, Tailwind + DaisyUI, and Leaflet/Chartkick. Imports, exports, sharing, and trip analytics lean on PostGIS geometries plus workers, so queue anything non-trivial instead of blocking requests. + +## Build, Test, and Development Commands +- `docker compose -f docker/docker-compose.yml up` — launches the full stack for smoke tests. +- `bundle exec rails db:prepare` — create/migrate the PostGIS database. +- `bundle exec bin/dev` and `bundle exec sidekiq` — start the web/Vite/Tailwind stack and workers locally. +- `make test` — runs Playwright (`npx playwright test e2e --workers=1`) then `bundle exec rspec`. +- `bundle exec rubocop` / `npx prettier --check app/javascript` — enforce formatting before commits. + +## Coding Style & Naming Conventions +Use two-space indentation, snake_case filenames, and CamelCase classes. Keep Stimulus controllers under `app/javascript/controllers/*_controller.ts` so names match DOM `data-controller` hooks. Prefer service objects in `app/services/` for multi-step imports/exports, and let migrations named like `202405061210_add_indexes_to_events` manage schema changes. Follow Tailwind ordering conventions and avoid bespoke CSS unless necessary. + +## Testing Guidelines +RSpec mirrors the app hierarchy inside `spec/` with files suffixed `_spec.rb`; rely on FactoryBot/FFaker for data, WebMock for HTTP, and SimpleCov for coverage. Browser journeys live in `e2e/` and should use `data-testid` selectors plus seeded demo data to reset state. Run `make test` before pushing and document intentional gaps when coverage dips. + +## Commit & Pull Request Guidelines +Write short, imperative commit subjects (`Add globe_projection setting`) and include the PR/issue reference like `(#2138)` when relevant. Target `dev`, describe migrations, configs, and verification steps, and attach screenshots or curl examples for UI/API work. Link related Discussions for larger changes and request review from domain owners (imports, sharing, trips, etc.). + +## Security & Configuration Tips +Start from `.env.example` or `.env.template` and store secrets in encrypted Rails credentials; never commit files from `gps-env/` or real trace data. Rotate API keys, scrub sensitive coordinates in fixtures, and use the synthetic traces in `db/seeds.rb` when demonstrating imports. diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b4e351..0066e76e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [0.37.3] - Unreleased + +## Fixed + +- Routes are now being drawn the very same way on Map V2 as in Map V1. #2132 #2086 +- RailsPulse performance monitoring is now disabled for self-hosted instances. It fixes poor performance on Synology. #2139 + +## Changed + +- Map V2 points loading is significantly sped up. +- Points size on Map V2 was reduced to prevent overlapping. +- Points sent from Owntracks and Overland are now being created synchronously to instantly reflect success or failure of point creation. + # [0.37.2] - 2026-01-04 ## Fixed @@ -12,6 +25,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Time spent in a country and city is now calculated correctly for the year-end digest email. #2104 - Updated Trix to fix a XSS vulnerability. #2102 - Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085 + +## Added - In Map v2 settings, you can now enable map to be rendered as a globe. # [0.37.1] - 2025-12-30 diff --git a/CLAUDE.md b/CLAUDE.md index bea64b39..c40f2e9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,47 @@ bundle exec bundle-audit # Dependency security - Respect expiration settings and disable sharing when expired - Only expose minimal necessary data in public sharing contexts +### Route Drawing Implementation (Critical) + +⚠️ **IMPORTANT: Unit Mismatch in Route Splitting Logic** + +Both Map v1 (Leaflet) and Map v2 (MapLibre) contain an **intentional unit mismatch** in route drawing that must be preserved for consistency: + +**The Issue**: +- `haversineDistance()` function returns distance in **kilometers** (e.g., 0.5 km) +- Route splitting threshold is stored and compared as **meters** (e.g., 500) +- The code compares them directly: `0.5 > 500` = always **FALSE** + +**Result**: +- The distance threshold (`meters_between_routes` setting) is **effectively disabled** +- Routes only split on **time gaps** (default: 60 minutes between points) +- This creates longer, more continuous routes that users expect + +**Code Locations**: +- **Map v1**: `app/javascript/maps/polylines.js:390` + - Uses `haversineDistance()` from `maps/helpers.js` (returns km) + - Compares to `distanceThresholdMeters` variable (value in meters) + +- **Map v2**: `app/javascript/maps_maplibre/layers/routes_layer.js:82-104` + - Has built-in `haversineDistance()` method (returns km) + - Intentionally skips `/1000` conversion to replicate v1 behavior + - Comment explains this is matching v1's unit mismatch + +**Critical Rules**: +1. ❌ **DO NOT "fix" the unit mismatch** - this would break user expectations +2. ✅ **Keep both versions synchronized** - they must behave identically +3. ✅ **Document any changes** - route drawing changes affect all users +4. ⚠️ If you ever fix this bug: + - You MUST update both v1 and v2 simultaneously + - You MUST migrate user settings (multiply existing values by 1000 or divide by 1000 depending on direction) + - You MUST communicate the breaking change to users + +**Additional Route Drawing Details**: +- **Time threshold**: 60 minutes (default) - actually functional +- **Distance threshold**: 500 meters (default) - currently non-functional due to unit bug +- **Sorting**: Map v2 sorts points by timestamp client-side; v1 relies on backend ASC order +- **API ordering**: Map v2 must request `order: 'asc'` to match v1's chronological data flow + ## Contributing - **Main Branch**: `master` diff --git a/app/controllers/api/v1/overland/batches_controller.rb b/app/controllers/api/v1/overland/batches_controller.rb index 18ea576a..9abd1679 100644 --- a/app/controllers/api/v1/overland/batches_controller.rb +++ b/app/controllers/api/v1/overland/batches_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Overland::BatchesController < ApiController before_action :validate_points_limit, only: %i[create] def create - Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id) + Overland::PointsCreator.new(batch_params, current_api_user.id).call render json: { result: 'ok' }, status: :created end diff --git a/app/controllers/api/v1/owntracks/points_controller.rb b/app/controllers/api/v1/owntracks/points_controller.rb index 6f97cfe9..42ce9d7e 100644 --- a/app/controllers/api/v1/owntracks/points_controller.rb +++ b/app/controllers/api/v1/owntracks/points_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Owntracks::PointsController < ApiController before_action :validate_points_limit, only: %i[create] def create - Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id) + OwnTracks::PointCreator.new(point_params, current_api_user.id).call render json: {}, status: :ok end diff --git a/app/controllers/api/v1/places_controller.rb b/app/controllers/api/v1/places_controller.rb index 97035526..41d4dac8 100644 --- a/app/controllers/api/v1/places_controller.rb +++ b/app/controllers/api/v1/places_controller.rb @@ -16,11 +16,11 @@ module Api include_untagged = tag_ids.include?('untagged') if numeric_tag_ids.any? && include_untagged - # Both tagged and untagged: return union (OR logic) - tagged = current_api_user.places.includes(:tags, :visits).with_tags(numeric_tag_ids) - untagged = current_api_user.places.includes(:tags, :visits).without_tags - @places = Place.from("(#{tagged.to_sql} UNION #{untagged.to_sql}) AS places") - .includes(:tags, :visits) + # Both tagged and untagged: use OR logic to preserve eager loading + tagged_ids = current_api_user.places.with_tags(numeric_tag_ids).pluck(:id) + untagged_ids = current_api_user.places.without_tags.pluck(:id) + combined_ids = (tagged_ids + untagged_ids).uniq + @places = current_api_user.places.includes(:tags, :visits).where(id: combined_ids) elsif numeric_tag_ids.any? # Only tagged places with ANY of the selected tags (OR logic) @places = @places.with_tags(numeric_tag_ids) @@ -30,6 +30,16 @@ module Api end end + # Support optional pagination (backward compatible - returns all if no page param) + if params[:page].present? + per_page = [params[:per_page]&.to_i || 100, 500].min + @places = @places.page(params[:page]).per(per_page) + + response.set_header('X-Current-Page', @places.current_page.to_s) + response.set_header('X-Total-Pages', @places.total_pages.to_s) + response.set_header('X-Total-Count', @places.total_count.to_s) + end + render json: @places.map { |place| serialize_place(place) } end @@ -120,7 +130,7 @@ module Api note: place.note, icon: place.tags.first&.icon, color: place.tags.first&.color, - visits_count: place.visits.count, + visits_count: place.visits.size, created_at: place.created_at, tags: place.tags.map do |tag| { diff --git a/app/controllers/api/v1/tracks_controller.rb b/app/controllers/api/v1/tracks_controller.rb new file mode 100644 index 00000000..98c49bb6 --- /dev/null +++ b/app/controllers/api/v1/tracks_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::TracksController < ApiController + def index + tracks_query = Tracks::IndexQuery.new(user: current_api_user, params: params) + paginated_tracks = tracks_query.call + + geojson = Tracks::GeojsonSerializer.new(paginated_tracks).call + + tracks_query.pagination_headers(paginated_tracks).each do |header, value| + response.set_header(header, value) + end + + render json: geojson + end +end diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 1002536d..0110a97f 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -3,6 +3,17 @@ class Api::V1::VisitsController < ApiController def index visits = Visits::Finder.new(current_api_user, params).call + + # Support optional pagination (backward compatible - returns all if no page param) + if params[:page].present? + per_page = [params[:per_page]&.to_i || 100, 500].min + visits = visits.page(params[:page]).per(per_page) + + response.set_header('X-Current-Page', visits.current_page.to_s) + response.set_header('X-Total-Pages', visits.total_pages.to_s) + response.set_header('X-Total-Count', visits.total_count.to_s) + end + serialized_visits = visits.map do |visit| Api::VisitSerializer.new(visit).call end diff --git a/app/controllers/map/leaflet_controller.rb b/app/controllers/map/leaflet_controller.rb index 660b9615..0c425a57 100644 --- a/app/controllers/map/leaflet_controller.rb +++ b/app/controllers/map/leaflet_controller.rb @@ -41,19 +41,31 @@ class Map::LeafletController < ApplicationController end def calculate_distance - return 0 if @coordinates.size < 2 + return 0 if @points.count(:id) < 2 - total_distance = 0 + # Use PostGIS window function for efficient distance calculation + # This is O(1) database operation vs O(n) Ruby iteration + sql = <<~SQL.squish + SELECT COALESCE(SUM(distance_m) / 1000.0, 0) as total_km FROM ( + SELECT ST_Distance( + lonlat::geography, + LAG(lonlat::geography) OVER (ORDER BY timestamp) + ) as distance_m + FROM points + WHERE user_id = :user_id + AND timestamp >= :start_at + AND timestamp <= :end_at + ) distances + SQL - @coordinates.each_cons(2) do - distance_km = Geocoder::Calculations.distance_between( - [_1[0], _1[1]], [_2[0], _2[1]], units: :km - ) + result = Point.connection.select_value( + ActiveRecord::Base.sanitize_sql_array([ + sql, + { user_id: current_user.id, start_at: start_at, end_at: end_at } + ]) + ) - total_distance += distance_km - end - - total_distance.round + result&.to_f&.round || 0 end def parsed_start_at diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 8d735acf..e12c7e9d 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -80,8 +80,14 @@ class StatsController < ApplicationController end def build_stats - current_user.stats.group_by(&:year).transform_values do |stats| - stats.sort_by(&:updated_at).reverse - end.sort.reverse + # Select only needed columns - avoid loading large JSONB fields + # daily_distance and h3_hex_ids are never needed on index page + columns = [:id, :year, :month, :distance, :updated_at, :user_id] + columns << :toponyms if DawarichSettings.reverse_geocoding_enabled? + + current_user.stats + .select(columns) + .order(year: :desc, updated_at: :desc) + .group_by(&:year) end end diff --git a/app/javascript/controllers/maps/maplibre/area_selection_manager.js b/app/javascript/controllers/maps/maplibre/area_selection_manager.js index 027689ca..c6a25a79 100644 --- a/app/javascript/controllers/maps/maplibre/area_selection_manager.js +++ b/app/javascript/controllers/maps/maplibre/area_selection_manager.js @@ -23,8 +23,6 @@ export class AreaSelectionManager { * Start area selection mode */ async startSelectArea() { - console.log('[Maps V2] Starting area selection mode') - // Initialize selection layer if not exists if (!this.selectionLayer) { this.selectionLayer = new SelectionLayer(this.map, { @@ -36,8 +34,6 @@ export class AreaSelectionManager { type: 'FeatureCollection', features: [] }) - - console.log('[Maps V2] Selection layer initialized') } // Initialize selected points layer if not exists @@ -50,8 +46,6 @@ export class AreaSelectionManager { type: 'FeatureCollection', features: [] }) - - console.log('[Maps V2] Selected points layer initialized') } // Enable selection mode @@ -76,8 +70,6 @@ export class AreaSelectionManager { * Handle area selection completion */ async handleAreaSelected(bounds) { - console.log('[Maps V2] Area selected:', bounds) - try { Toast.info('Fetching data in selected area...') @@ -298,7 +290,6 @@ export class AreaSelectionManager { Toast.success('Visit declined') await this.refreshSelectedVisits() } catch (error) { - console.error('[Maps V2] Failed to decline visit:', error) Toast.error('Failed to decline visit') } } @@ -327,7 +318,6 @@ export class AreaSelectionManager { this.replaceVisitsWithMerged(visitIds, mergedVisit) this.updateBulkActions() } catch (error) { - console.error('[Maps V2] Failed to merge visits:', error) Toast.error('Failed to merge visits') } } @@ -346,7 +336,6 @@ export class AreaSelectionManager { this.selectedVisitIds.clear() await this.refreshSelectedVisits() } catch (error) { - console.error('[Maps V2] Failed to confirm visits:', error) Toast.error('Failed to confirm visits') } } @@ -451,8 +440,6 @@ export class AreaSelectionManager { * Cancel area selection */ cancelAreaSelection() { - console.log('[Maps V2] Cancelling area selection') - if (this.selectionLayer) { this.selectionLayer.disableSelectionMode() this.selectionLayer.clearSelection() @@ -515,14 +502,10 @@ export class AreaSelectionManager { if (!confirmed) return - console.log('[Maps V2] Deleting', pointIds.length, 'points') - try { Toast.info('Deleting points...') const result = await this.api.bulkDeletePoints(pointIds) - console.log('[Maps V2] Deleted', result.count, 'points') - this.cancelAreaSelection() await this.controller.loadMapData({ diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js index f4e266fb..5745a6b7 100644 --- a/app/javascript/controllers/maps/maplibre/data_loader.js +++ b/app/javascript/controllers/maps/maplibre/data_loader.js @@ -39,7 +39,7 @@ export class DataLoader { performanceMonitor.mark('transform-geojson') data.pointsGeoJSON = pointsToGeoJSON(data.points) data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, { - distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000, + distanceThresholdMeters: this.settings.metersBetweenRoutes || 500, timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60 }) performanceMonitor.measure('transform-geojson') @@ -105,10 +105,16 @@ export class DataLoader { } data.placesGeoJSON = this.placesToGeoJSON(data.places) - // Tracks - DISABLED: Backend API not yet implemented - // TODO: Re-enable when /api/v1/tracks endpoint is created - data.tracks = [] - data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks) + // Fetch tracks + try { + data.tracksGeoJSON = await this.api.fetchTracks({ + start_at: startDate, + end_at: endDate + }) + } catch (error) { + console.warn('[Tracks] Failed to fetch tracks (non-blocking):', error.message) + data.tracksGeoJSON = { type: 'FeatureCollection', features: [] } + } return data } diff --git a/app/javascript/controllers/maps/maplibre/event_handlers.js b/app/javascript/controllers/maps/maplibre/event_handlers.js index be214d13..4c450822 100644 --- a/app/javascript/controllers/maps/maplibre/event_handlers.js +++ b/app/javascript/controllers/maps/maplibre/event_handlers.js @@ -1,4 +1,6 @@ import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers' +import { formatDistance, formatSpeed, minutesToDaysHoursMinutes } from 'maps/helpers' +import maplibregl from 'maplibre-gl' /** * Handles map interaction events (clicks, info display) @@ -7,6 +9,8 @@ export class EventHandlers { constructor(map, controller) { this.map = map this.controller = controller + this.selectedRouteFeature = null + this.routeMarkers = [] // Store start/end markers for routes } /** @@ -126,4 +130,242 @@ export class EventHandlers { this.controller.showInfo(properties.name || 'Area', content, actions) } + + /** + * Handle route hover + */ + handleRouteHover(e) { + const clickedFeature = e.features[0] + if (!clickedFeature) return + + const routesLayer = this.controller.layerManager.getLayer('routes') + if (!routesLayer) return + + // Get the full feature from source (not the clipped tile version) + // Fallback to clipped feature if full feature not found + const fullFeature = this._getFullRouteFeature(clickedFeature.properties) || clickedFeature + + // If a route is selected and we're hovering over a different route, show both + if (this.selectedRouteFeature) { + // Check if we're hovering over the same route that's selected + const isSameRoute = this._areFeaturesSame(this.selectedRouteFeature, fullFeature) + + if (!isSameRoute) { + // Show both selected and hovered routes + const features = [this.selectedRouteFeature, fullFeature] + routesLayer.setHoverRoute({ + type: 'FeatureCollection', + features: features + }) + // Create markers for both routes + this._createRouteMarkers(features) + } + } else { + // No selection, just show hovered route + routesLayer.setHoverRoute(fullFeature) + // Create markers for hovered route + this._createRouteMarkers(fullFeature) + } + } + + /** + * Handle route mouse leave + */ + handleRouteMouseLeave(e) { + const routesLayer = this.controller.layerManager.getLayer('routes') + if (!routesLayer) return + + // If a route is selected, keep showing only the selected route + if (this.selectedRouteFeature) { + routesLayer.setHoverRoute(this.selectedRouteFeature) + // Keep markers for selected route only + this._createRouteMarkers(this.selectedRouteFeature) + } else { + // No selection, clear hover and markers + routesLayer.setHoverRoute(null) + this._clearRouteMarkers() + } + } + + /** + * Get full route feature from source data (not clipped tile version) + * MapLibre returns clipped geometries from queryRenderedFeatures() + * We need the full geometry from the source for proper highlighting + */ + _getFullRouteFeature(properties) { + const routesLayer = this.controller.layerManager.getLayer('routes') + if (!routesLayer) return null + + const source = this.map.getSource(routesLayer.sourceId) + if (!source) return null + + // Get the source data (GeoJSON FeatureCollection) + // Try multiple ways to access the data + let sourceData = null + + // Method 1: Internal _data property (most common) + if (source._data) { + sourceData = source._data + } + // Method 2: Serialize and deserialize (fallback) + else if (source.serialize) { + const serialized = source.serialize() + sourceData = serialized.data + } + // Method 3: Use cached data from layer + else if (routesLayer.data) { + sourceData = routesLayer.data + } + + if (!sourceData || !sourceData.features) return null + + // Find the matching feature by properties + return sourceData.features.find(feature => { + const props = feature.properties + return props.startTime === properties.startTime && + props.endTime === properties.endTime && + props.pointCount === properties.pointCount + }) + } + + /** + * Compare two features to see if they represent the same route + */ + _areFeaturesSame(feature1, feature2) { + if (!feature1 || !feature2) return false + + // Compare by start/end times and point count (unique enough for routes) + const props1 = feature1.properties + const props2 = feature2.properties + + return props1.startTime === props2.startTime && + props1.endTime === props2.endTime && + props1.pointCount === props2.pointCount + } + + /** + * Create start/end markers for route(s) + * @param {Array|Object} features - Single feature or array of features + */ + _createRouteMarkers(features) { + // Clear existing markers first + this._clearRouteMarkers() + + // Ensure we have an array + const featureArray = Array.isArray(features) ? features : [features] + + featureArray.forEach(feature => { + if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return + + const coords = feature.geometry.coordinates + if (coords.length < 2) return + + // Start marker (🚥) + const startCoord = coords[0] + const startMarker = this._createEmojiMarker('🚥') + startMarker.setLngLat(startCoord).addTo(this.map) + this.routeMarkers.push(startMarker) + + // End marker (🏁) + const endCoord = coords[coords.length - 1] + const endMarker = this._createEmojiMarker('🏁') + endMarker.setLngLat(endCoord).addTo(this.map) + this.routeMarkers.push(endMarker) + }) + } + + /** + * Create an emoji marker + * @param {String} emoji - The emoji to display + * @returns {maplibregl.Marker} + */ + _createEmojiMarker(emoji) { + const el = document.createElement('div') + el.className = 'route-emoji-marker' + el.textContent = emoji + el.style.fontSize = '24px' + el.style.cursor = 'pointer' + el.style.userSelect = 'none' + + return new maplibregl.Marker({ element: el, anchor: 'center' }) + } + + /** + * Clear all route markers + */ + _clearRouteMarkers() { + this.routeMarkers.forEach(marker => marker.remove()) + this.routeMarkers = [] + } + + /** + * Handle route click + */ + handleRouteClick(e) { + const clickedFeature = e.features[0] + const properties = clickedFeature.properties + + // Get the full feature from source (not the clipped tile version) + // Fallback to clipped feature if full feature not found + const fullFeature = this._getFullRouteFeature(properties) || clickedFeature + + // Store selected route (use full feature) + this.selectedRouteFeature = fullFeature + + // Update hover layer to show selected route + const routesLayer = this.controller.layerManager.getLayer('routes') + if (routesLayer) { + routesLayer.setHoverRoute(fullFeature) + } + + // Create markers for selected route + this._createRouteMarkers(fullFeature) + + // Calculate duration + const durationSeconds = properties.endTime - properties.startTime + const durationMinutes = Math.floor(durationSeconds / 60) + const durationFormatted = minutesToDaysHoursMinutes(durationMinutes) + + // Calculate average speed + let avgSpeed = properties.speed + if (!avgSpeed && properties.distance > 0 && durationSeconds > 0) { + avgSpeed = (properties.distance / durationSeconds) * 3600 // km/h + } + + // Get user preferences + const distanceUnit = this.controller.settings.distance_unit || 'km' + + // Prepare route data object + const routeData = { + startTime: formatTimestamp(properties.startTime, this.controller.timezoneValue), + endTime: formatTimestamp(properties.endTime, this.controller.timezoneValue), + duration: durationFormatted, + distance: formatDistance(properties.distance, distanceUnit), + speed: avgSpeed ? formatSpeed(avgSpeed, distanceUnit) : null, + pointCount: properties.pointCount + } + + // Call controller method to display route info + this.controller.showRouteInfo(routeData) + } + + /** + * Clear route selection + */ + clearRouteSelection() { + if (!this.selectedRouteFeature) return + + this.selectedRouteFeature = null + + const routesLayer = this.controller.layerManager.getLayer('routes') + if (routesLayer) { + routesLayer.setHoverRoute(null) + } + + // Clear markers + this._clearRouteMarkers() + + // Close info panel + this.controller.closeInfo() + } } diff --git a/app/javascript/controllers/maps/maplibre/layer_manager.js b/app/javascript/controllers/maps/maplibre/layer_manager.js index 2968713e..d6d31abb 100644 --- a/app/javascript/controllers/maps/maplibre/layer_manager.js +++ b/app/javascript/controllers/maps/maplibre/layer_manager.js @@ -21,6 +21,7 @@ export class LayerManager { this.settings = settings this.api = api this.layers = {} + this.eventHandlersSetup = false } /** @@ -30,7 +31,7 @@ export class LayerManager { performanceMonitor.mark('add-layers') // Layer order matters - layers added first render below layers added later - // Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points -> recent-point (top) -> fog (canvas overlay) + // Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes (visual) -> visits -> places -> photos -> family -> points -> routes-hit (interaction) -> recent-point (top) -> fog (canvas overlay) await this._addScratchLayer(pointsGeoJSON) this._addHeatmapLayer(pointsGeoJSON) @@ -49,6 +50,7 @@ export class LayerManager { this._addFamilyLayer() this._addPointsLayer(pointsGeoJSON) + this._addRoutesHitLayer() // Add hit target layer after points for better interactivity this._addRecentPointLayer() this._addFogLayer(pointsGeoJSON) @@ -57,8 +59,13 @@ export class LayerManager { /** * Setup event handlers for layer interactions + * Only sets up handlers once to prevent duplicates */ setupLayerEventHandlers(handlers) { + if (this.eventHandlersSetup) { + return + } + // Click handlers this.map.on('click', 'points', handlers.handlePointClick) this.map.on('click', 'visits', handlers.handleVisitClick) @@ -69,6 +76,11 @@ export class LayerManager { this.map.on('click', 'areas-outline', handlers.handleAreaClick) this.map.on('click', 'areas-labels', handlers.handleAreaClick) + // Route handlers - use routes-hit layer for better interactivity + this.map.on('click', 'routes-hit', handlers.handleRouteClick) + this.map.on('mouseenter', 'routes-hit', handlers.handleRouteHover) + this.map.on('mouseleave', 'routes-hit', handlers.handleRouteMouseLeave) + // Cursor change on hover this.map.on('mouseenter', 'points', () => { this.map.getCanvas().style.cursor = 'pointer' @@ -94,6 +106,13 @@ export class LayerManager { this.map.on('mouseleave', 'places', () => { this.map.getCanvas().style.cursor = '' }) + // Route cursor handlers - use routes-hit layer + this.map.on('mouseenter', 'routes-hit', () => { + this.map.getCanvas().style.cursor = 'pointer' + }) + this.map.on('mouseleave', 'routes-hit', () => { + this.map.getCanvas().style.cursor = '' + }) // Areas hover handlers for all sub-layers const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels'] areaLayers.forEach(layerId => { @@ -107,6 +126,16 @@ export class LayerManager { }) } }) + + // Map-level click to deselect routes + this.map.on('click', (e) => { + const routeFeatures = this.map.queryRenderedFeatures(e.point, { layers: ['routes-hit'] }) + if (routeFeatures.length === 0) { + handlers.clearRouteSelection() + } + }) + + this.eventHandlersSetup = true } /** @@ -132,6 +161,7 @@ export class LayerManager { */ clearLayerReferences() { this.layers = {} + this.eventHandlersSetup = false } // Private methods for individual layer management @@ -197,6 +227,32 @@ export class LayerManager { } } + _addRoutesHitLayer() { + // Add invisible hit target layer for routes after points layer + // This ensures route interactions work even when points are on top + if (!this.map.getLayer('routes-hit') && this.map.getSource('routes-source')) { + this.map.addLayer({ + id: 'routes-hit', + type: 'line', + source: 'routes-source', + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + paint: { + 'line-color': 'transparent', + 'line-width': 20, // Much wider for easier clicking/hovering + 'line-opacity': 0 + } + }) + // Match visibility with routes layer + const routesLayer = this.layers.routesLayer + if (routesLayer && !routesLayer.visible) { + this.map.setLayoutProperty('routes-hit', 'visibility', 'none') + } + } + } + _addVisitsLayer(visitsGeoJSON) { if (!this.layers.visitsLayer) { this.layers.visitsLayer = new VisitsLayer(this.map, { diff --git a/app/javascript/controllers/maps/maplibre/map_data_manager.js b/app/javascript/controllers/maps/maplibre/map_data_manager.js index 88d13462..507f152b 100644 --- a/app/javascript/controllers/maps/maplibre/map_data_manager.js +++ b/app/javascript/controllers/maps/maplibre/map_data_manager.js @@ -90,22 +90,31 @@ export class MapDataManager { data.placesGeoJSON ) + // Setup event handlers after layers are added this.layerManager.setupLayerEventHandlers({ handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers), handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers), handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers), handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers), - handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers) + handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers), + handleRouteClick: this.eventHandlers.handleRouteClick.bind(this.eventHandlers), + handleRouteHover: this.eventHandlers.handleRouteHover.bind(this.eventHandlers), + handleRouteMouseLeave: this.eventHandlers.handleRouteMouseLeave.bind(this.eventHandlers), + clearRouteSelection: this.eventHandlers.clearRouteSelection.bind(this.eventHandlers) }) } - if (this.map.loaded()) { - await addAllLayers() - } else { - this.map.once('load', async () => { - await addAllLayers() - }) - } + // Always use Promise-based approach for consistent timing + await new Promise((resolve) => { + if (this.map.loaded()) { + addAllLayers().then(resolve) + } else { + this.map.once('load', async () => { + await addAllLayers() + resolve() + }) + } + }) } /** diff --git a/app/javascript/controllers/maps/maplibre/places_manager.js b/app/javascript/controllers/maps/maplibre/places_manager.js index fd33c0a8..4f1922ca 100644 --- a/app/javascript/controllers/maps/maplibre/places_manager.js +++ b/app/javascript/controllers/maps/maplibre/places_manager.js @@ -216,8 +216,6 @@ export class PlacesManager { * Start create place mode */ startCreatePlace() { - console.log('[Maps V2] Starting create place mode') - if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) { this.controller.toggleSettings() } @@ -242,8 +240,6 @@ export class PlacesManager { * Handle place creation event - reload places and update layer */ async handlePlaceCreated(event) { - console.log('[Maps V2] Place created, reloading places...', event.detail) - try { const selectedTags = this.getSelectedPlaceTags() @@ -251,8 +247,6 @@ export class PlacesManager { tag_ids: selectedTags }) - console.log('[Maps V2] Fetched places:', places.length) - const placesGeoJSON = this.dataLoader.placesToGeoJSON(places) console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features') @@ -260,7 +254,6 @@ export class PlacesManager { const placesLayer = this.layerManager.getLayer('places') if (placesLayer) { placesLayer.update(placesGeoJSON) - console.log('[Maps V2] Places layer updated successfully') } else { console.warn('[Maps V2] Places layer not found, cannot update') } @@ -273,9 +266,6 @@ export class PlacesManager { * Handle place update event - reload places and update layer */ async handlePlaceUpdated(event) { - console.log('[Maps V2] Place updated, reloading places...', event.detail) - - // Reuse the same logic as creation await this.handlePlaceCreated(event) } } diff --git a/app/javascript/controllers/maps/maplibre/visits_manager.js b/app/javascript/controllers/maps/maplibre/visits_manager.js index 82120584..1c4ccc4c 100644 --- a/app/javascript/controllers/maps/maplibre/visits_manager.js +++ b/app/javascript/controllers/maps/maplibre/visits_manager.js @@ -65,8 +65,6 @@ export class VisitsManager { * Start create visit mode */ startCreateVisit() { - console.log('[Maps V2] Starting create visit mode') - if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) { this.controller.toggleSettings() } @@ -87,12 +85,9 @@ export class VisitsManager { * Open visit creation modal */ openVisitCreationModal(lat, lng) { - console.log('[Maps V2] Opening visit creation modal', { lat, lng }) - const modalElement = document.querySelector('[data-controller="visit-creation-v2"]') if (!modalElement) { - console.error('[Maps V2] Visit creation modal not found') Toast.error('Visit creation modal not available') return } @@ -105,7 +100,6 @@ export class VisitsManager { if (controller) { controller.open(lat, lng, this.controller) } else { - console.error('[Maps V2] Visit creation controller not found') Toast.error('Visit creation controller not available') } } @@ -114,8 +108,6 @@ export class VisitsManager { * Handle visit creation event - reload visits and update layer */ async handleVisitCreated(event) { - console.log('[Maps V2] Visit created, reloading visits...', event.detail) - try { const visits = await this.api.fetchVisits({ start_at: this.controller.startDateValue, @@ -132,7 +124,6 @@ export class VisitsManager { const visitsLayer = this.layerManager.getLayer('visits') if (visitsLayer) { visitsLayer.update(visitsGeoJSON) - console.log('[Maps V2] Visits layer updated successfully') } else { console.warn('[Maps V2] Visits layer not found, cannot update') } @@ -145,9 +136,6 @@ export class VisitsManager { * Handle visit update event - reload visits and update layer */ async handleVisitUpdated(event) { - console.log('[Maps V2] Visit updated, reloading visits...', event.detail) - - // Reuse the same logic as creation await this.handleVisitCreated(event) } } diff --git a/app/javascript/controllers/maps/maplibre_controller.js b/app/javascript/controllers/maps/maplibre_controller.js index 57fbe5b4..4924a07c 100644 --- a/app/javascript/controllers/maps/maplibre_controller.js +++ b/app/javascript/controllers/maps/maplibre_controller.js @@ -79,7 +79,16 @@ export default class extends Controller { 'infoDisplay', 'infoTitle', 'infoContent', - 'infoActions' + 'infoActions', + // Route info template + 'routeInfoTemplate', + 'routeStartTime', + 'routeEndTime', + 'routeDuration', + 'routeDistance', + 'routeSpeed', + 'routeSpeedContainer', + 'routePoints' ] async connect() { @@ -132,7 +141,6 @@ export default class extends Controller { // Format initial dates this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue)) this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue)) - console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue) this.loadMapData() } @@ -172,8 +180,6 @@ export default class extends Controller { this.searchManager = new SearchManager(this.map, this.apiKeyValue) this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget) - - console.log('[Maps V2] Search manager initialized') } /** @@ -198,7 +204,6 @@ export default class extends Controller { this.startDateValue = startDate this.endDateValue = endDate - console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue) this.loadMapData() } @@ -267,8 +272,6 @@ export default class extends Controller { // Area creation startCreateArea() { - console.log('[Maps V2] Starting create area mode') - if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) { this.toggleSettings() } @@ -280,37 +283,26 @@ export default class extends Controller { ) if (drawerController) { - console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map) drawerController.startDrawing(this.map) } else { - console.error('[Maps V2] Area drawer controller not found') Toast.error('Area drawer controller not available') } } async handleAreaCreated(event) { - console.log('[Maps V2] Area created:', event.detail.area) - try { // Fetch all areas from API const areas = await this.api.fetchAreas() - console.log('[Maps V2] Fetched areas:', areas.length) // Convert to GeoJSON const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas) - console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features') - if (areasGeoJSON.features.length > 0) { - console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2)) - } // Get or create the areas layer let areasLayer = this.layerManager.getLayer('areas') - console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible) if (areasLayer) { // Update existing layer areasLayer.update(areasGeoJSON) - console.log('[Maps V2] Areas layer updated') } else { // Create the layer if it doesn't exist yet console.log('[Maps V2] Creating areas layer') @@ -322,7 +314,6 @@ export default class extends Controller { // Enable the layer if it wasn't already if (areasLayer) { if (!areasLayer.visible) { - console.log('[Maps V2] Showing areas layer') areasLayer.show() this.settings.layers.areas = true this.settingsController.saveSetting('layers.areas', true) @@ -338,7 +329,6 @@ export default class extends Controller { Toast.success('Area created successfully!') } catch (error) { - console.error('[Maps V2] Failed to reload areas:', error) Toast.error('Failed to reload areas') } } @@ -369,7 +359,6 @@ export default class extends Controller { if (!response.ok) { if (response.status === 403) { - console.warn('[Maps V2] Family feature not enabled or user not in family') Toast.info('Family feature not available') return } @@ -487,9 +476,46 @@ export default class extends Controller { this.switchToToolsTab() } + showRouteInfo(routeData) { + if (!this.hasRouteInfoTemplateTarget) return + + // Clone the template + const template = this.routeInfoTemplateTarget.content.cloneNode(true) + + // Populate the template with data + const fragment = document.createDocumentFragment() + fragment.appendChild(template) + + fragment.querySelector('[data-maps--maplibre-target="routeStartTime"]').textContent = routeData.startTime + fragment.querySelector('[data-maps--maplibre-target="routeEndTime"]').textContent = routeData.endTime + fragment.querySelector('[data-maps--maplibre-target="routeDuration"]').textContent = routeData.duration + fragment.querySelector('[data-maps--maplibre-target="routeDistance"]').textContent = routeData.distance + fragment.querySelector('[data-maps--maplibre-target="routePoints"]').textContent = routeData.pointCount + + // Handle optional speed field + const speedContainer = fragment.querySelector('[data-maps--maplibre-target="routeSpeedContainer"]') + if (routeData.speed) { + fragment.querySelector('[data-maps--maplibre-target="routeSpeed"]').textContent = routeData.speed + speedContainer.style.display = '' + } else { + speedContainer.style.display = 'none' + } + + // Convert fragment to HTML string for showInfo + const div = document.createElement('div') + div.appendChild(fragment) + + this.showInfo('Route Information', div.innerHTML) + } + closeInfo() { if (!this.hasInfoDisplayTarget) return this.infoDisplayTarget.classList.add('hidden') + + // Clear route selection when info panel is closed + if (this.eventHandlers) { + this.eventHandlers.clearRouteSelection() + } } /** @@ -500,7 +526,6 @@ export default class extends Controller { const id = button.dataset.id const entityType = button.dataset.entityType - console.log('[Maps V2] Opening edit for', entityType, id) switch (entityType) { case 'visit': @@ -522,8 +547,6 @@ export default class extends Controller { const id = button.dataset.id const entityType = button.dataset.entityType - console.log('[Maps V2] Deleting', entityType, id) - switch (entityType) { case 'area': this.deleteArea(id) @@ -559,7 +582,6 @@ export default class extends Controller { }) document.dispatchEvent(event) } catch (error) { - console.error('[Maps V2] Failed to load visit:', error) Toast.error('Failed to load visit details') } } @@ -596,7 +618,6 @@ export default class extends Controller { Toast.success('Area deleted successfully') } catch (error) { - console.error('[Maps V2] Failed to delete area:', error) Toast.error('Failed to delete area') } } @@ -627,7 +648,6 @@ export default class extends Controller { }) document.dispatchEvent(event) } catch (error) { - console.error('[Maps V2] Failed to load place:', error) Toast.error('Failed to load place details') } } diff --git a/app/javascript/maps/marker_factory.js b/app/javascript/maps/marker_factory.js index b4c257d5..8b7338b5 100644 --- a/app/javascript/maps/marker_factory.js +++ b/app/javascript/maps/marker_factory.js @@ -28,7 +28,7 @@ const MARKER_DATA_INDICES = { * @param {number} size - Icon size in pixels (default: 8) * @returns {L.DivIcon} Leaflet divIcon instance */ -export function createStandardIcon(color = 'blue', size = 8) { +export function createStandardIcon(color = 'blue', size = 4) { return L.divIcon({ className: 'custom-div-icon', html: `
Show saved tracks
Show backend-calculated tracks in red