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:
Evgenii Burmakin 2025-12-10 19:58:31 +01:00 committed by GitHub
parent 8af032a215
commit 2a4ed8bf82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 764 additions and 73 deletions

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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')
}

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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

View file

@ -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)
})
})
})

View file

@ -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')
}
})
})