mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Implement moving points in map v2 and fix route rendering logic to ma… (#2027)
* Implement moving points in map v2 and fix route rendering logic to match map v1. * Fix route spec
This commit is contained in:
parent
8af032a215
commit
2a4ed8bf82
12 changed files with 764 additions and 73 deletions
|
|
@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
## Added
|
||||
|
||||
- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false.
|
||||
- In map v2, user can now move points. #2024
|
||||
- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026
|
||||
|
||||
## Fixed
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Redis client now also being configured so that it could connect via unix socket. #1970
|
||||
- Importing KML files now creates points with correct timestamps. #1988
|
||||
- Importing KMZ files now works correctly.
|
||||
- Map settings are now being respected in map v2. #2012
|
||||
|
||||
|
||||
# [0.36.2] - 2025-12-06
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -7,9 +7,17 @@ import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
|||
* Handles loading and transforming data from API
|
||||
*/
|
||||
export class DataLoader {
|
||||
constructor(api, apiKey) {
|
||||
constructor(api, apiKey, settings = {}) {
|
||||
this.api = api
|
||||
this.apiKey = apiKey
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings (called when user changes settings)
|
||||
*/
|
||||
updateSettings(settings) {
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30,7 +38,10 @@ export class DataLoader {
|
|||
// Transform points to GeoJSON
|
||||
performanceMonitor.mark('transform-geojson')
|
||||
data.pointsGeoJSON = pointsToGeoJSON(data.points)
|
||||
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points)
|
||||
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, {
|
||||
distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000,
|
||||
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
|
||||
})
|
||||
performanceMonitor.measure('transform-geojson')
|
||||
|
||||
// Fetch visits
|
||||
|
|
|
|||
|
|
@ -247,7 +247,9 @@ export class LayerManager {
|
|||
_addPointsLayer(pointsGeoJSON) {
|
||||
if (!this.layers.pointsLayer) {
|
||||
this.layers.pointsLayer = new PointsLayer(this.map, {
|
||||
visible: this.settings.pointsVisible !== false // Default true unless explicitly false
|
||||
visible: this.settings.pointsVisible !== false, // Default true unless explicitly false
|
||||
apiClient: this.api,
|
||||
layerManager: this
|
||||
})
|
||||
this.layers.pointsLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -22,12 +22,17 @@ export class SettingsController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load settings (sync from backend and localStorage)
|
||||
* Load settings (sync from backend)
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = await SettingsManager.sync()
|
||||
this.controller.settings = this.settings
|
||||
console.log('[Maps V2] Settings loaded:', this.settings)
|
||||
|
||||
// Update dataLoader with new settings
|
||||
if (this.controller.dataLoader) {
|
||||
this.controller.dataLoader.updateSettings(this.settings)
|
||||
}
|
||||
|
||||
return this.settings
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +139,6 @@ export class SettingsController {
|
|||
if (speedColoredRoutesToggle) {
|
||||
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
|
||||
}
|
||||
|
||||
console.log('[Maps V2] UI controls synced with settings')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -154,7 +157,6 @@ export class SettingsController {
|
|||
|
||||
// Reload layers after style change
|
||||
this.map.once('style.load', () => {
|
||||
console.log('Style loaded, reloading map data')
|
||||
this.controller.loadMapData()
|
||||
})
|
||||
}
|
||||
|
|
@ -203,11 +205,17 @@ export class SettingsController {
|
|||
// Apply settings to current map
|
||||
await this.applySettingsToMap(settings)
|
||||
|
||||
// Save to backend and localStorage
|
||||
// Save to backend
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await SettingsManager.updateSetting(key, value)
|
||||
}
|
||||
|
||||
// Update controller settings and dataLoader
|
||||
this.controller.settings = { ...this.controller.settings, ...settings }
|
||||
if (this.controller.dataLoader) {
|
||||
this.controller.dataLoader.updateSettings(this.controller.settings)
|
||||
}
|
||||
|
||||
Toast.success('Settings updated successfully')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export default class extends Controller {
|
|||
|
||||
// Initialize managers
|
||||
this.layerManager = new LayerManager(this.map, this.settings, this.api)
|
||||
this.dataLoader = new DataLoader(this.api, this.apiKeyValue)
|
||||
this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings)
|
||||
this.eventHandlers = new EventHandlers(this.map, this)
|
||||
this.filterManager = new FilterManager(this.dataLoader)
|
||||
this.mapDataManager = new MapDataManager(this)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import { BaseLayer } from './base_layer'
|
|||
|
||||
/**
|
||||
* Points layer for displaying individual location points
|
||||
* Supports dragging points to update their positions
|
||||
*/
|
||||
export class PointsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'points', ...options })
|
||||
this.apiClient = options.apiClient
|
||||
this.layerManager = options.layerManager
|
||||
this.isDragging = false
|
||||
this.draggedFeature = null
|
||||
this.canvas = null
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
|
|
@ -34,4 +40,218 @@ export class PointsLayer extends BaseLayer {
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable dragging for points
|
||||
*/
|
||||
enableDragging() {
|
||||
if (this.draggingEnabled) return
|
||||
|
||||
this.draggingEnabled = true
|
||||
this.canvas = this.map.getCanvasContainer()
|
||||
|
||||
// Change cursor to pointer when hovering over points
|
||||
this.map.on('mouseenter', this.id, this.onMouseEnter.bind(this))
|
||||
this.map.on('mouseleave', this.id, this.onMouseLeave.bind(this))
|
||||
|
||||
// Handle drag events
|
||||
this.map.on('mousedown', this.id, this.onMouseDown.bind(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable dragging for points
|
||||
*/
|
||||
disableDragging() {
|
||||
if (!this.draggingEnabled) return
|
||||
|
||||
this.draggingEnabled = false
|
||||
|
||||
this.map.off('mouseenter', this.id, this.onMouseEnter.bind(this))
|
||||
this.map.off('mouseleave', this.id, this.onMouseLeave.bind(this))
|
||||
this.map.off('mousedown', this.id, this.onMouseDown.bind(this))
|
||||
}
|
||||
|
||||
onMouseEnter() {
|
||||
this.canvas.style.cursor = 'move'
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this.isDragging) {
|
||||
this.canvas.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDown(e) {
|
||||
// Prevent default map drag behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Store the feature being dragged
|
||||
this.draggedFeature = e.features[0]
|
||||
this.isDragging = true
|
||||
this.canvas.style.cursor = 'grabbing'
|
||||
|
||||
// Bind mouse move and up events
|
||||
this.map.on('mousemove', this.onMouseMove.bind(this))
|
||||
this.map.once('mouseup', this.onMouseUp.bind(this))
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
if (!this.isDragging || !this.draggedFeature) return
|
||||
|
||||
// Get the new coordinates
|
||||
const coords = e.lngLat
|
||||
|
||||
// Update the feature's coordinates in the source
|
||||
const source = this.map.getSource(this.sourceId)
|
||||
if (source) {
|
||||
const data = source._data
|
||||
const feature = data.features.find(f => f.properties.id === this.draggedFeature.properties.id)
|
||||
if (feature) {
|
||||
feature.geometry.coordinates = [coords.lng, coords.lat]
|
||||
source.setData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMouseUp(e) {
|
||||
if (!this.isDragging || !this.draggedFeature) return
|
||||
|
||||
const coords = e.lngLat
|
||||
const pointId = this.draggedFeature.properties.id
|
||||
const originalCoords = this.draggedFeature.geometry.coordinates
|
||||
|
||||
// Clean up drag state
|
||||
this.isDragging = false
|
||||
this.canvas.style.cursor = ''
|
||||
this.map.off('mousemove', this.onMouseMove.bind(this))
|
||||
|
||||
// Update the point on the backend
|
||||
try {
|
||||
await this.updatePointPosition(pointId, coords.lat, coords.lng)
|
||||
|
||||
// Update routes after successful point update
|
||||
await this.updateConnectedRoutes(pointId, originalCoords, [coords.lng, coords.lat])
|
||||
} catch (error) {
|
||||
console.error('Failed to update point:', error)
|
||||
// Revert the point position on error
|
||||
const source = this.map.getSource(this.sourceId)
|
||||
if (source) {
|
||||
const data = source._data
|
||||
const feature = data.features.find(f => f.properties.id === pointId)
|
||||
if (feature && originalCoords) {
|
||||
feature.geometry.coordinates = originalCoords
|
||||
source.setData(data)
|
||||
}
|
||||
}
|
||||
alert('Failed to update point position. Please try again.')
|
||||
}
|
||||
|
||||
this.draggedFeature = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update point position via API
|
||||
*/
|
||||
async updatePointPosition(pointId, latitude, longitude) {
|
||||
if (!this.apiClient) {
|
||||
throw new Error('API client not configured')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/points/${pointId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiClient.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
point: {
|
||||
latitude: latitude.toString(),
|
||||
longitude: longitude.toString()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connected route segments when a point is moved
|
||||
*/
|
||||
async updateConnectedRoutes(pointId, oldCoords, newCoords) {
|
||||
if (!this.layerManager) {
|
||||
console.warn('LayerManager not configured, cannot update routes')
|
||||
return
|
||||
}
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (!routesLayer) {
|
||||
console.warn('Routes layer not found')
|
||||
return
|
||||
}
|
||||
|
||||
const routesSource = this.map.getSource(routesLayer.sourceId)
|
||||
if (!routesSource) {
|
||||
console.warn('Routes source not found')
|
||||
return
|
||||
}
|
||||
|
||||
const routesData = routesSource._data
|
||||
if (!routesData || !routesData.features) {
|
||||
return
|
||||
}
|
||||
|
||||
// Tolerance for coordinate comparison (account for floating point precision)
|
||||
const tolerance = 0.0001
|
||||
let routesUpdated = false
|
||||
|
||||
// Find and update route segments that contain the moved point
|
||||
routesData.features.forEach(feature => {
|
||||
if (feature.geometry.type === 'LineString') {
|
||||
const coordinates = feature.geometry.coordinates
|
||||
|
||||
// Check each coordinate in the line
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
const coord = coordinates[i]
|
||||
|
||||
// Check if this coordinate matches the old position
|
||||
if (Math.abs(coord[0] - oldCoords[0]) < tolerance &&
|
||||
Math.abs(coord[1] - oldCoords[1]) < tolerance) {
|
||||
// Update to new position
|
||||
coordinates[i] = newCoords
|
||||
routesUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update the routes source if any routes were modified
|
||||
if (routesUpdated) {
|
||||
routesSource.setData(routesData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override add method to enable dragging when layer is added
|
||||
*/
|
||||
add(data) {
|
||||
super.add(data)
|
||||
|
||||
// Wait for next tick to ensure layers are fully added before enabling dragging
|
||||
setTimeout(() => {
|
||||
this.enableDragging()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override remove method to clean up dragging handlers
|
||||
*/
|
||||
remove() {
|
||||
this.disableDragging()
|
||||
super.remove()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,13 @@ export class RoutesLayer extends BaseLayer {
|
|||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#f97316', // Solid orange color
|
||||
// Use color from feature properties if available, otherwise default blue
|
||||
'line-color': [
|
||||
'case',
|
||||
['has', 'color'],
|
||||
['get', 'color'],
|
||||
'#0000ff' // Default blue color (matching v1)
|
||||
],
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
/**
|
||||
* Settings manager for persisting user preferences
|
||||
* Supports both localStorage (fallback) and backend API (primary)
|
||||
* Loads settings from backend API only (no localStorage)
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'dawarich-maps-maplibre-settings'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
mapStyle: 'light',
|
||||
enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map
|
||||
// Advanced settings
|
||||
routeOpacity: 1.0,
|
||||
fogOfWarRadius: 1000,
|
||||
// Advanced settings (matching v1 naming)
|
||||
routeOpacity: 0.6,
|
||||
fogOfWarRadius: 100,
|
||||
fogOfWarThreshold: 1,
|
||||
metersBetweenRoutes: 500,
|
||||
metersBetweenRoutes: 1000,
|
||||
minutesBetweenRoutes: 60,
|
||||
pointsRenderingMode: 'raw',
|
||||
speedColoredRoutes: false
|
||||
speedColoredRoutes: false,
|
||||
speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
}
|
||||
|
||||
// Mapping between v2 layer names and v1 layer names in enabled_map_layers array
|
||||
|
|
@ -34,7 +33,15 @@ const LAYER_NAME_MAP = {
|
|||
// Mapping between frontend settings and backend API keys
|
||||
const BACKEND_SETTINGS_MAP = {
|
||||
mapStyle: 'maps_maplibre_style',
|
||||
enabledMapLayers: 'enabled_map_layers'
|
||||
enabledMapLayers: 'enabled_map_layers',
|
||||
routeOpacity: 'route_opacity',
|
||||
fogOfWarRadius: 'fog_of_war_meters',
|
||||
fogOfWarThreshold: 'fog_of_war_threshold',
|
||||
metersBetweenRoutes: 'meters_between_routes',
|
||||
minutesBetweenRoutes: 'minutes_between_routes',
|
||||
pointsRenderingMode: 'points_rendering_mode',
|
||||
speedColoredRoutes: 'speed_colored_routes',
|
||||
speedColorScale: 'speed_color_scale'
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
|
|
@ -51,9 +58,8 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all settings (localStorage first, then merge with defaults)
|
||||
* Get all settings from cache or defaults
|
||||
* Converts enabled_map_layers array to individual boolean flags
|
||||
* Uses cached settings if available to avoid race conditions
|
||||
* @returns {Object} Settings object
|
||||
*/
|
||||
static getSettings() {
|
||||
|
|
@ -62,21 +68,11 @@ export class SettingsManager {
|
|||
return { ...this.cachedSettings }
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(DEFAULT_SETTINGS)
|
||||
this.cachedSettings = expandedSettings
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(settings)
|
||||
|
||||
// Cache the settings
|
||||
this.cachedSettings = expandedSettings
|
||||
|
||||
return { ...expandedSettings }
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
return { ...expandedSettings }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -141,14 +137,31 @@ export class SettingsManager {
|
|||
const frontendSettings = {}
|
||||
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
|
||||
if (backendKey in backendSettings) {
|
||||
frontendSettings[frontendKey] = backendSettings[backendKey]
|
||||
let value = backendSettings[backendKey]
|
||||
|
||||
// Convert backend values to correct types
|
||||
if (frontendKey === 'routeOpacity') {
|
||||
value = parseFloat(value) || DEFAULT_SETTINGS.routeOpacity
|
||||
} else if (frontendKey === 'fogOfWarRadius') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.fogOfWarRadius
|
||||
} else if (frontendKey === 'fogOfWarThreshold') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.fogOfWarThreshold
|
||||
} else if (frontendKey === 'metersBetweenRoutes') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.metersBetweenRoutes
|
||||
} else if (frontendKey === 'minutesBetweenRoutes') {
|
||||
value = parseInt(value) || DEFAULT_SETTINGS.minutesBetweenRoutes
|
||||
} else if (frontendKey === 'speedColoredRoutes') {
|
||||
value = value === true || value === 'true'
|
||||
}
|
||||
|
||||
frontendSettings[frontendKey] = value
|
||||
}
|
||||
})
|
||||
|
||||
// Merge with defaults, but prioritize backend's enabled_map_layers completely
|
||||
// Merge with defaults
|
||||
const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings }
|
||||
|
||||
// If backend has enabled_map_layers, use it as-is (don't merge with defaults)
|
||||
// If backend has enabled_map_layers, use it as-is
|
||||
if (backendSettings.enabled_map_layers) {
|
||||
mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers
|
||||
}
|
||||
|
|
@ -156,8 +169,8 @@ export class SettingsManager {
|
|||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(mergedSettings)
|
||||
|
||||
// Save to localStorage and cache
|
||||
this.saveToLocalStorage(expandedSettings)
|
||||
// Cache the settings
|
||||
this.cachedSettings = expandedSettings
|
||||
|
||||
return expandedSettings
|
||||
} catch (error) {
|
||||
|
|
@ -167,18 +180,11 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Save all settings to localStorage and update cache
|
||||
* Update cache with new settings
|
||||
* @param {Object} settings - Settings object
|
||||
*/
|
||||
static saveToLocalStorage(settings) {
|
||||
try {
|
||||
// Update cache first
|
||||
this.cachedSettings = { ...settings }
|
||||
// Then save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings to localStorage:', error)
|
||||
}
|
||||
static updateCache(settings) {
|
||||
this.cachedSettings = { ...settings }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,7 +209,19 @@ export class SettingsManager {
|
|||
// Use the collapsed array
|
||||
backendSettings[backendKey] = enabledMapLayers
|
||||
} else if (frontendKey in settings) {
|
||||
backendSettings[backendKey] = settings[frontendKey]
|
||||
let value = settings[frontendKey]
|
||||
|
||||
// Convert frontend values to backend format
|
||||
if (frontendKey === 'routeOpacity') {
|
||||
value = parseFloat(value).toString()
|
||||
} else if (frontendKey === 'fogOfWarRadius' || frontendKey === 'fogOfWarThreshold' ||
|
||||
frontendKey === 'metersBetweenRoutes' || frontendKey === 'minutesBetweenRoutes') {
|
||||
value = parseInt(value).toString()
|
||||
} else if (frontendKey === 'speedColoredRoutes') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
|
||||
backendSettings[backendKey] = value
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -220,7 +238,6 @@ export class SettingsManager {
|
|||
throw new Error(`Failed to save settings: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[Settings] Saved to backend successfully:', backendSettings)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save to backend:', error)
|
||||
|
|
@ -238,7 +255,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update a specific setting (saves to both localStorage and backend)
|
||||
* Update a specific setting and save to backend
|
||||
* @param {string} key - Setting key
|
||||
* @param {*} value - New value
|
||||
*/
|
||||
|
|
@ -253,28 +270,23 @@ export class SettingsManager {
|
|||
settings.enabledMapLayers = this._collapseLayerSettings(settings)
|
||||
}
|
||||
|
||||
// Save to localStorage immediately
|
||||
this.saveToLocalStorage(settings)
|
||||
// Update cache immediately
|
||||
this.updateCache(settings)
|
||||
|
||||
// Save to backend (non-blocking)
|
||||
this.saveToBackend(settings).catch(error => {
|
||||
console.warn('[Settings] Backend save failed, but localStorage updated:', error)
|
||||
})
|
||||
// Save to backend
|
||||
await this.saveToBackend(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to defaults
|
||||
*/
|
||||
static resetToDefaults() {
|
||||
static async resetToDefaults() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
this.cachedSettings = null // Clear cache
|
||||
|
||||
// Also reset on backend
|
||||
// Reset on backend
|
||||
if (this.apiKey) {
|
||||
this.saveToBackend(DEFAULT_SETTINGS).catch(error => {
|
||||
console.warn('[Settings] Failed to reset backend settings:', error)
|
||||
})
|
||||
await this.saveToBackend(DEFAULT_SETTINGS)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset settings:', error)
|
||||
|
|
@ -282,9 +294,9 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sync settings: load from backend and merge with localStorage
|
||||
* Sync settings: load from backend
|
||||
* Call this on app initialization
|
||||
* @returns {Promise<Object>} Merged settings
|
||||
* @returns {Promise<Object>} Settings from backend
|
||||
*/
|
||||
static async sync() {
|
||||
const backendSettings = await this.loadFromBackend()
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||
*/
|
||||
export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) {
|
||||
if (!useSpeedColors) {
|
||||
return '#f97316' // Default orange color
|
||||
return '#0000ff' // Default blue color (matching v1)
|
||||
}
|
||||
|
||||
let colorStops
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import {
|
|||
navigateToMapsV2WithDate,
|
||||
waitForLoadingComplete,
|
||||
hasLayer,
|
||||
getPointsSourceData
|
||||
getPointsSourceData,
|
||||
getRoutesSourceData
|
||||
} from '../../helpers/setup.js'
|
||||
|
||||
test.describe('Points Layer', () => {
|
||||
|
|
@ -68,4 +69,424 @@ test.describe('Points Layer', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Dragging', () => {
|
||||
test('allows dragging points to new positions', async ({ page }) => {
|
||||
// Wait for points to load
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('points-source')
|
||||
return source?._data?.features?.length > 0
|
||||
}, { timeout: 15000 })
|
||||
|
||||
// Get initial point data
|
||||
const initialData = await getPointsSourceData(page)
|
||||
expect(initialData.features.length).toBeGreaterThan(0)
|
||||
|
||||
|
||||
// Get the map canvas bounds
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
const canvasBounds = await canvas.boundingBox()
|
||||
expect(canvasBounds).not.toBeNull()
|
||||
|
||||
// Ensure points layer is visible before testing dragging
|
||||
const layerState = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const pointsLayer = controller?.layerManager?.layers?.pointsLayer
|
||||
|
||||
if (!pointsLayer) {
|
||||
return { exists: false, visibleBefore: false, visibleAfter: false, draggingEnabled: false }
|
||||
}
|
||||
|
||||
const visibilityBefore = controller.map.getLayoutProperty('points', 'visibility')
|
||||
const isVisibleBefore = visibilityBefore === 'visible' || visibilityBefore === undefined
|
||||
|
||||
// If not visible, make it visible
|
||||
if (!isVisibleBefore) {
|
||||
pointsLayer.show()
|
||||
}
|
||||
|
||||
// Check again after calling show
|
||||
const visibilityAfter = controller.map.getLayoutProperty('points', 'visibility')
|
||||
const isVisibleAfter = visibilityAfter === 'visible' || visibilityAfter === undefined
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
visibleBefore: isVisibleBefore,
|
||||
visibleAfter: isVisibleAfter,
|
||||
draggingEnabled: pointsLayer.draggingEnabled || false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Wait longer for layer to render after visibility change
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Find a rendered point feature on the map and get its pixel coordinates
|
||||
const renderedPoint = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
|
||||
// Get all rendered point features
|
||||
const features = controller.map.queryRenderedFeatures(undefined, { layers: ['points'] })
|
||||
|
||||
if (features.length === 0) {
|
||||
return { found: false, totalFeatures: 0 }
|
||||
}
|
||||
|
||||
// Pick the first rendered point
|
||||
const feature = features[0]
|
||||
const coords = feature.geometry.coordinates
|
||||
const point = controller.map.project(coords)
|
||||
|
||||
// Get the canvas position on the page
|
||||
const canvas = controller.map.getCanvas()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
found: true,
|
||||
totalFeatures: features.length,
|
||||
pointId: feature.properties.id,
|
||||
coords: coords,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
pageX: rect.left + point.x,
|
||||
pageY: rect.top + point.y
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
expect(renderedPoint.found).toBe(true)
|
||||
expect(renderedPoint.totalFeatures).toBeGreaterThan(0)
|
||||
|
||||
const pointId = renderedPoint.pointId
|
||||
const initialCoords = renderedPoint.coords
|
||||
const pointPixel = {
|
||||
x: renderedPoint.x,
|
||||
y: renderedPoint.y,
|
||||
pageX: renderedPoint.pageX,
|
||||
pageY: renderedPoint.pageY
|
||||
}
|
||||
|
||||
|
||||
// Drag the point by 100 pixels to the right and 100 down (larger movement for visibility)
|
||||
const dragOffset = { x: 100, y: 100 }
|
||||
const startX = pointPixel.pageX
|
||||
const startY = pointPixel.pageY
|
||||
const endX = startX + dragOffset.x
|
||||
const endY = startY + dragOffset.y
|
||||
|
||||
|
||||
// Check cursor style on hover
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const cursorStyle = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.maplibregl-canvas-container')
|
||||
return window.getComputedStyle(canvas).cursor
|
||||
})
|
||||
|
||||
// Perform the drag operation with slower movement
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.move(endX, endY, { steps: 20 })
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.up()
|
||||
|
||||
// Wait for API call to complete
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Get updated point data
|
||||
const updatedData = await getPointsSourceData(page)
|
||||
const updatedPoint = updatedData.features.find(f => f.properties.id === pointId)
|
||||
|
||||
expect(updatedPoint).toBeDefined()
|
||||
const updatedCoords = updatedPoint.geometry.coordinates
|
||||
|
||||
|
||||
// Verify the point has moved (parse coordinates as numbers)
|
||||
const updatedLng = parseFloat(updatedCoords[0])
|
||||
const updatedLat = parseFloat(updatedCoords[1])
|
||||
const initialLng = parseFloat(initialCoords[0])
|
||||
const initialLat = parseFloat(initialCoords[1])
|
||||
|
||||
expect(updatedLng).not.toBeCloseTo(initialLng, 5)
|
||||
expect(updatedLat).not.toBeCloseTo(initialLat, 5)
|
||||
})
|
||||
|
||||
test('updates connected route segments when point is dragged', async ({ page }) => {
|
||||
// Wait for both points and routes to load
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const pointsSource = controller?.map?.getSource('points-source')
|
||||
const routesSource = controller?.map?.getSource('routes-source')
|
||||
return pointsSource?._data?.features?.length > 0 &&
|
||||
routesSource?._data?.features?.length > 0
|
||||
}, { timeout: 15000 })
|
||||
|
||||
// Ensure points layer is visible
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const pointsLayer = controller?.layerManager?.layers?.pointsLayer
|
||||
if (pointsLayer) {
|
||||
const visibility = controller.map.getLayoutProperty('points', 'visibility')
|
||||
if (visibility === 'none') {
|
||||
pointsLayer.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Get initial data
|
||||
const initialRoutesData = await getRoutesSourceData(page)
|
||||
expect(initialRoutesData.features.length).toBeGreaterThan(0)
|
||||
|
||||
// Find a rendered point feature on the map
|
||||
const renderedPoint = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
|
||||
// Get all rendered point features
|
||||
const features = controller.map.queryRenderedFeatures(undefined, { layers: ['points'] })
|
||||
|
||||
if (features.length === 0) {
|
||||
return { found: false }
|
||||
}
|
||||
|
||||
// Pick the first rendered point
|
||||
const feature = features[0]
|
||||
const coords = feature.geometry.coordinates
|
||||
const point = controller.map.project(coords)
|
||||
|
||||
// Get the canvas position on the page
|
||||
const canvas = controller.map.getCanvas()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
found: true,
|
||||
pointId: feature.properties.id,
|
||||
coords: coords,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
pageX: rect.left + point.x,
|
||||
pageY: rect.top + point.y
|
||||
}
|
||||
})
|
||||
|
||||
expect(renderedPoint.found).toBe(true)
|
||||
|
||||
const pointId = renderedPoint.pointId
|
||||
const initialCoords = renderedPoint.coords
|
||||
const pointPixel = {
|
||||
x: renderedPoint.x,
|
||||
y: renderedPoint.y,
|
||||
pageX: renderedPoint.pageX,
|
||||
pageY: renderedPoint.pageY
|
||||
}
|
||||
|
||||
// Find routes that contain this point
|
||||
const connectedRoutes = initialRoutesData.features.filter(route => {
|
||||
return route.geometry.coordinates.some(coord =>
|
||||
Math.abs(coord[0] - initialCoords[0]) < 0.0001 &&
|
||||
Math.abs(coord[1] - initialCoords[1]) < 0.0001
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
const dragOffset = { x: 100, y: 100 }
|
||||
const startX = pointPixel.pageX
|
||||
const startY = pointPixel.pageY
|
||||
const endX = startX + dragOffset.x
|
||||
const endY = startY + dragOffset.y
|
||||
|
||||
// Perform drag with slower movement
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.move(endX, endY, { steps: 20 })
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.up()
|
||||
|
||||
// Wait for updates
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Get updated data
|
||||
const updatedPointsData = await getPointsSourceData(page)
|
||||
const updatedRoutesData = await getRoutesSourceData(page)
|
||||
|
||||
const updatedPoint = updatedPointsData.features.find(f => f.properties.id === pointId)
|
||||
const updatedCoords = updatedPoint.geometry.coordinates
|
||||
|
||||
// Verify routes have been updated
|
||||
const updatedConnectedRoutes = updatedRoutesData.features.filter(route => {
|
||||
return route.geometry.coordinates.some(coord =>
|
||||
Math.abs(coord[0] - updatedCoords[0]) < 0.0001 &&
|
||||
Math.abs(coord[1] - updatedCoords[1]) < 0.0001
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
// Routes that were originally connected should now be at the new position
|
||||
if (connectedRoutes.length > 0) {
|
||||
expect(updatedConnectedRoutes.length).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// The point moved, so verify the coordinates actually changed
|
||||
const lngChanged = Math.abs(parseFloat(updatedCoords[0]) - initialCoords[0]) > 0.0001
|
||||
const latChanged = Math.abs(parseFloat(updatedCoords[1]) - initialCoords[1]) > 0.0001
|
||||
|
||||
expect(lngChanged || latChanged).toBe(true)
|
||||
|
||||
// Since the route segments update is best-effort (depends on coordinate matching),
|
||||
// we'll just verify that routes exist and the point moved
|
||||
})
|
||||
|
||||
test('persists point position after page reload', async ({ page }) => {
|
||||
// Wait for points to load
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('points-source')
|
||||
return source?._data?.features?.length > 0
|
||||
}, { timeout: 15000 })
|
||||
|
||||
// Ensure points layer is visible
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const pointsLayer = controller?.layerManager?.layers?.pointsLayer
|
||||
if (pointsLayer) {
|
||||
const visibility = controller.map.getLayoutProperty('points', 'visibility')
|
||||
if (visibility === 'none') {
|
||||
pointsLayer.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Find a rendered point feature on the map
|
||||
const renderedPoint = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
|
||||
// Get all rendered point features
|
||||
const features = controller.map.queryRenderedFeatures(undefined, { layers: ['points'] })
|
||||
|
||||
if (features.length === 0) {
|
||||
return { found: false }
|
||||
}
|
||||
|
||||
// Pick the first rendered point
|
||||
const feature = features[0]
|
||||
const coords = feature.geometry.coordinates
|
||||
const point = controller.map.project(coords)
|
||||
|
||||
// Get the canvas position on the page
|
||||
const canvas = controller.map.getCanvas()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
found: true,
|
||||
pointId: feature.properties.id,
|
||||
coords: coords,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
pageX: rect.left + point.x,
|
||||
pageY: rect.top + point.y
|
||||
}
|
||||
})
|
||||
|
||||
expect(renderedPoint.found).toBe(true)
|
||||
|
||||
const pointId = renderedPoint.pointId
|
||||
const initialCoords = renderedPoint.coords
|
||||
const pointPixel = {
|
||||
x: renderedPoint.x,
|
||||
y: renderedPoint.y,
|
||||
pageX: renderedPoint.pageX,
|
||||
pageY: renderedPoint.pageY
|
||||
}
|
||||
|
||||
|
||||
const dragOffset = { x: 100, y: 100 }
|
||||
const startX = pointPixel.pageX
|
||||
const startY = pointPixel.pageY
|
||||
const endX = startX + dragOffset.x
|
||||
const endY = startY + dragOffset.y
|
||||
|
||||
// Perform drag with slower movement
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.move(endX, endY, { steps: 20 })
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.up()
|
||||
|
||||
// Wait for API call
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Get the new position
|
||||
const afterDragData = await getPointsSourceData(page)
|
||||
const afterDragPoint = afterDragData.features.find(f => f.properties.id === pointId)
|
||||
const afterDragCoords = afterDragPoint.geometry.coordinates
|
||||
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
await closeOnboardingModal(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
// Wait for points to reload
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('points-source')
|
||||
return source?._data?.features?.length > 0
|
||||
}, { timeout: 15000 })
|
||||
|
||||
// Get point after reload
|
||||
const afterReloadData = await getPointsSourceData(page)
|
||||
const afterReloadPoint = afterReloadData.features.find(f => f.properties.id === pointId)
|
||||
const afterReloadCoords = afterReloadPoint.geometry.coordinates
|
||||
|
||||
|
||||
// Verify the position persisted (parse coordinates as numbers)
|
||||
const reloadLng = parseFloat(afterReloadCoords[0])
|
||||
const reloadLat = parseFloat(afterReloadCoords[1])
|
||||
const dragLng = parseFloat(afterDragCoords[0])
|
||||
const dragLat = parseFloat(afterDragCoords[1])
|
||||
const initialLng = parseFloat(initialCoords[0])
|
||||
const initialLat = parseFloat(initialCoords[1])
|
||||
|
||||
// Position after reload should match position after drag (high precision)
|
||||
expect(reloadLng).toBeCloseTo(dragLng, 5)
|
||||
expect(reloadLat).toBeCloseTo(dragLat, 5)
|
||||
|
||||
// And it should be different from the initial position (lower precision - just verify it moved)
|
||||
const lngDiff = Math.abs(reloadLng - initialLng)
|
||||
const latDiff = Math.abs(reloadLat - initialLat)
|
||||
const moved = lngDiff > 0.00001 || latDiff > 0.00001
|
||||
|
||||
expect(moved).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -124,8 +124,16 @@ test.describe('Routes Layer', () => {
|
|||
|
||||
expect(routeLayerInfo).toBeTruthy()
|
||||
expect(routeLayerInfo.exists).toBe(true)
|
||||
expect(routeLayerInfo.isArray).toBe(false)
|
||||
expect(routeLayerInfo.value).toBe('#f97316')
|
||||
|
||||
// Route color is now a MapLibre expression that supports dynamic colors
|
||||
// Format: ['case', ['has', 'color'], ['get', 'color'], '#0000ff']
|
||||
if (routeLayerInfo.isArray) {
|
||||
// It's a MapLibre expression, check the default color (last element)
|
||||
expect(routeLayerInfo.value[routeLayerInfo.value.length - 1]).toBe('#0000ff')
|
||||
} else {
|
||||
// Solid color (fallback)
|
||||
expect(routeLayerInfo.value).toBe('#0000ff')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue