mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 16:57:12 -05:00
Compare commits
2 commits
6770bd5e12
...
657fa6e197
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
657fa6e197 | ||
|
|
b6eeba4370 |
14 changed files with 1071 additions and 48 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -4,6 +4,17 @@ 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
|
||||
|
||||
## Changed
|
||||
|
||||
- Map V2 points loading is significantly sped up.
|
||||
- Points size on Map V2 was reduced to prevent overlapping.
|
||||
|
||||
# [0.37.2] - 2026-01-04
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
41
CLAUDE.md
41
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`
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,193 @@ export class EventHandlers {
|
|||
|
||||
this.controller.showInfo(properties.name || 'Area', content, actions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle route hover
|
||||
*/
|
||||
handleRouteHover(e) {
|
||||
const feature = e.features[0]
|
||||
if (!feature) return
|
||||
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (!routesLayer) return
|
||||
|
||||
// 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, feature)
|
||||
|
||||
if (!isSameRoute) {
|
||||
// Show both selected and hovered routes
|
||||
const features = [this.selectedRouteFeature, feature]
|
||||
routesLayer.setHoverRoute({
|
||||
type: 'FeatureCollection',
|
||||
features: features
|
||||
})
|
||||
// Create markers for both routes
|
||||
this._createRouteMarkers(features)
|
||||
}
|
||||
} else {
|
||||
// No selection, just show hovered route
|
||||
routesLayer.setHoverRoute(feature)
|
||||
// Create markers for hovered route
|
||||
this._createRouteMarkers(feature)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
// Store selected route
|
||||
this.selectedRouteFeature = feature
|
||||
|
||||
// Update hover layer to show selected route
|
||||
const routesLayer = this.controller.layerManager.getLayer('routes')
|
||||
if (routesLayer) {
|
||||
routesLayer.setHoverRoute(feature)
|
||||
}
|
||||
|
||||
// Create markers for selected route
|
||||
this._createRouteMarkers(feature)
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,11 @@ export class LayerManager {
|
|||
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
|
||||
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
|
||||
|
||||
// Route handlers
|
||||
this.map.on('click', 'routes', handlers.handleRouteClick)
|
||||
this.map.on('mouseenter', 'routes', handlers.handleRouteHover)
|
||||
this.map.on('mouseleave', 'routes', handlers.handleRouteMouseLeave)
|
||||
|
||||
// Cursor change on hover
|
||||
this.map.on('mouseenter', 'points', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
|
|
@ -94,6 +99,13 @@ export class LayerManager {
|
|||
this.map.on('mouseleave', 'places', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
// Route cursor handlers
|
||||
this.map.on('mouseenter', 'routes', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'routes', () => {
|
||||
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 +119,14 @@ export class LayerManager {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Map-level click to deselect routes
|
||||
this.map.on('click', (e) => {
|
||||
const routeFeatures = this.map.queryRenderedFeatures(e.point, { layers: ['routes'] })
|
||||
if (routeFeatures.length === 0) {
|
||||
handlers.clearRouteSelection()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ export class MapDataManager {
|
|||
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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -467,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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: `<div style='background-color: ${color}; width: ${size}px; height: ${size}px; border-radius: 50%;'></div>`,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export class RoutesLayer extends BaseLayer {
|
|||
constructor(map, options = {}) {
|
||||
super(map, { id: 'routes', ...options })
|
||||
this.maxGapHours = options.maxGapHours || 5 // Max hours between points to connect
|
||||
this.hoverSourceId = 'routes-hover-source'
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
|
|
@ -20,6 +21,36 @@ export class RoutesLayer extends BaseLayer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override add() to create both main and hover sources
|
||||
*/
|
||||
add(data) {
|
||||
this.data = data
|
||||
|
||||
// Add main source
|
||||
if (!this.map.getSource(this.sourceId)) {
|
||||
this.map.addSource(this.sourceId, this.getSourceConfig())
|
||||
}
|
||||
|
||||
// Add hover source (initially empty)
|
||||
if (!this.map.getSource(this.hoverSourceId)) {
|
||||
this.map.addSource(this.hoverSourceId, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
})
|
||||
}
|
||||
|
||||
// Add layers
|
||||
const layers = this.getLayerConfigs()
|
||||
layers.forEach(layerConfig => {
|
||||
if (!this.map.getLayer(layerConfig.id)) {
|
||||
this.map.addLayer(layerConfig)
|
||||
}
|
||||
})
|
||||
|
||||
this.setVisibility(this.visible)
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
{
|
||||
|
|
@ -41,10 +72,71 @@ export class RoutesLayer extends BaseLayer {
|
|||
'line-width': 3,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'routes-hover',
|
||||
type: 'line',
|
||||
source: this.hoverSourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#ffff00', // Yellow highlight
|
||||
'line-width': 8,
|
||||
'line-opacity': 1.0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hover layer with route geometry
|
||||
* @param {Object|null} feature - Route feature, FeatureCollection, or null to clear
|
||||
*/
|
||||
setHoverRoute(feature) {
|
||||
const hoverSource = this.map.getSource(this.hoverSourceId)
|
||||
if (!hoverSource) return
|
||||
|
||||
if (feature) {
|
||||
// Handle both single feature and FeatureCollection
|
||||
if (feature.type === 'FeatureCollection') {
|
||||
hoverSource.setData(feature)
|
||||
} else {
|
||||
hoverSource.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature]
|
||||
})
|
||||
}
|
||||
} else {
|
||||
hoverSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override remove() to clean up hover source
|
||||
*/
|
||||
remove() {
|
||||
// Remove layers
|
||||
this.getLayerIds().forEach(layerId => {
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.removeLayer(layerId)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove main source
|
||||
if (this.map.getSource(this.sourceId)) {
|
||||
this.map.removeSource(this.sourceId)
|
||||
}
|
||||
|
||||
// Remove hover source
|
||||
if (this.map.getSource(this.hoverSourceId)) {
|
||||
this.map.removeSource(this.hoverSourceId)
|
||||
}
|
||||
|
||||
this.data = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate haversine distance between two points in kilometers
|
||||
* @param {number} lat1 - First point latitude
|
||||
|
|
@ -67,6 +159,7 @@ export class RoutesLayer extends BaseLayer {
|
|||
/**
|
||||
* Convert points to route LineStrings with splitting
|
||||
* Matches V1's route splitting logic for consistency
|
||||
* Also handles International Date Line (IDL) crossings
|
||||
* @param {Array} points - Points from API
|
||||
* @param {Object} options - Splitting options
|
||||
* @returns {Object} GeoJSON FeatureCollection
|
||||
|
|
@ -77,7 +170,9 @@ export class RoutesLayer extends BaseLayer {
|
|||
}
|
||||
|
||||
// Default thresholds (matching V1 defaults from polylines.js)
|
||||
const distanceThresholdKm = (options.distanceThresholdMeters || 500) / 1000
|
||||
// Note: V1 has a unit mismatch bug where it compares km to meters directly
|
||||
// We replicate this behavior for consistency with V1
|
||||
const distanceThresholdKm = options.distanceThresholdMeters || 500
|
||||
const timeThresholdMinutes = options.timeThresholdMinutes || 60
|
||||
|
||||
// Sort by timestamp
|
||||
|
|
@ -100,7 +195,7 @@ export class RoutesLayer extends BaseLayer {
|
|||
// Calculate time difference in minutes
|
||||
const timeDiff = (curr.timestamp - prev.timestamp) / 60
|
||||
|
||||
// Split if either threshold is exceeded (matching V1 logic)
|
||||
// Split if any threshold is exceeded
|
||||
if (distance > distanceThresholdKm || timeDiff > timeThresholdMinutes) {
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
|
|
@ -117,7 +212,36 @@ export class RoutesLayer extends BaseLayer {
|
|||
|
||||
// Convert segments to LineStrings
|
||||
const features = segments.map(segment => {
|
||||
const coordinates = segment.map(p => [p.longitude, p.latitude])
|
||||
// Unwrap coordinates to handle International Date Line (IDL) crossings
|
||||
// This ensures routes draw the short way across IDL instead of wrapping around globe
|
||||
const coordinates = []
|
||||
let offset = 0 // Cumulative longitude offset for unwrapping
|
||||
|
||||
for (let i = 0; i < segment.length; i++) {
|
||||
const point = segment[i]
|
||||
let lon = point.longitude + offset
|
||||
|
||||
// Check for IDL crossing between consecutive points
|
||||
if (i > 0) {
|
||||
const prevLon = coordinates[i - 1][0]
|
||||
const lonDiff = lon - prevLon
|
||||
|
||||
// If longitude jumps more than 180°, we crossed the IDL
|
||||
if (lonDiff > 180) {
|
||||
// Crossed from east to west (e.g., 170° to -170°)
|
||||
// Subtract 360° to make it continuous (e.g., 170° to -170° becomes 170° to -170°-360° = -530°)
|
||||
offset -= 360
|
||||
lon -= 360
|
||||
} else if (lonDiff < -180) {
|
||||
// Crossed from west to east (e.g., -170° to 170°)
|
||||
// Add 360° to make it continuous (e.g., -170° to 170° becomes -170° to 170°+360° = 530°)
|
||||
offset += 360
|
||||
lon += 360
|
||||
}
|
||||
}
|
||||
|
||||
coordinates.push([lon, point.latitude])
|
||||
}
|
||||
|
||||
// Calculate total distance for the segment
|
||||
let totalDistance = 0
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ export class ApiClient {
|
|||
end_at,
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString(),
|
||||
slim: 'true'
|
||||
slim: 'true',
|
||||
order: 'asc'
|
||||
})
|
||||
|
||||
const response = await fetch(`${this.baseURL}/points?${params}`, {
|
||||
|
|
@ -40,36 +41,69 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch all points for date range (handles pagination)
|
||||
* @param {Object} options - { start_at, end_at, onProgress }
|
||||
* Fetch all points for date range (handles pagination with parallel requests)
|
||||
* @param {Object} options - { start_at, end_at, onProgress, maxConcurrent }
|
||||
* @returns {Promise<Array>} All points
|
||||
*/
|
||||
async fetchAllPoints({ start_at, end_at, onProgress = null }) {
|
||||
const allPoints = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
do {
|
||||
const { points, currentPage, totalPages: total } =
|
||||
await this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
|
||||
|
||||
allPoints.push(...points)
|
||||
totalPages = total
|
||||
page++
|
||||
async fetchAllPoints({ start_at, end_at, onProgress = null, maxConcurrent = 3 }) {
|
||||
// First fetch to get total pages
|
||||
const firstPage = await this.fetchPoints({ start_at, end_at, page: 1, per_page: 1000 })
|
||||
const totalPages = firstPage.totalPages
|
||||
|
||||
// If only one page, return immediately
|
||||
if (totalPages === 1) {
|
||||
if (onProgress) {
|
||||
// Avoid division by zero - if no pages, progress is 100%
|
||||
const progress = totalPages > 0 ? currentPage / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: allPoints.length,
|
||||
currentPage,
|
||||
loaded: firstPage.points.length,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
progress: 1.0
|
||||
})
|
||||
}
|
||||
return firstPage.points
|
||||
}
|
||||
|
||||
// Initialize results array with first page
|
||||
const pageResults = [{ page: 1, points: firstPage.points }]
|
||||
let completedPages = 1
|
||||
|
||||
// Create array of remaining page numbers
|
||||
const remainingPages = Array.from(
|
||||
{ length: totalPages - 1 },
|
||||
(_, i) => i + 2
|
||||
)
|
||||
|
||||
// Process pages in batches of maxConcurrent
|
||||
for (let i = 0; i < remainingPages.length; i += maxConcurrent) {
|
||||
const batch = remainingPages.slice(i, i + maxConcurrent)
|
||||
|
||||
// Fetch batch in parallel
|
||||
const batchPromises = batch.map(page =>
|
||||
this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
|
||||
.then(result => ({ page, points: result.points }))
|
||||
)
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
pageResults.push(...batchResults)
|
||||
completedPages += batchResults.length
|
||||
|
||||
// Call progress callback after each batch
|
||||
if (onProgress) {
|
||||
const progress = totalPages > 0 ? completedPages / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: pageResults.reduce((sum, r) => sum + r.points.length, 0),
|
||||
currentPage: completedPages,
|
||||
totalPages,
|
||||
progress
|
||||
})
|
||||
}
|
||||
} while (page <= totalPages)
|
||||
}
|
||||
|
||||
return allPoints
|
||||
// Sort by page number to ensure correct order
|
||||
pageResults.sort((a, b) => a.page - b.page)
|
||||
|
||||
// Flatten into single array
|
||||
return pageResults.flatMap(r => r.points)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const DEFAULT_SETTINGS = {
|
|||
routeOpacity: 0.6,
|
||||
fogOfWarRadius: 100,
|
||||
fogOfWarThreshold: 1,
|
||||
metersBetweenRoutes: 1000,
|
||||
metersBetweenRoutes: 500,
|
||||
minutesBetweenRoutes: 60,
|
||||
pointsRenderingMode: 'raw',
|
||||
speedColoredRoutes: false,
|
||||
|
|
|
|||
|
|
@ -6,14 +6,7 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
def perform(user_id, email_type, **options)
|
||||
user = User.find(user_id)
|
||||
|
||||
if should_skip_email?(user, email_type)
|
||||
ExceptionReporter.call(
|
||||
'Users::MailerSendingJob',
|
||||
"Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
|
||||
)
|
||||
|
||||
return
|
||||
end
|
||||
return if should_skip_email?(user, email_type)
|
||||
|
||||
params = { user: user }.merge(options)
|
||||
|
||||
|
|
@ -37,15 +30,4 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
false
|
||||
end
|
||||
end
|
||||
|
||||
def skip_reason(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
'user is already subscribed'
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? ? 'user is subscribed' : 'user is not in trial state'
|
||||
else
|
||||
'unknown reason'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -620,6 +620,36 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden template for route info display -->
|
||||
<template data-maps--maplibre-target="routeInfoTemplate">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="font-semibold">Start:</span>
|
||||
<span data-maps--maplibre-target="routeStartTime"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold">End:</span>
|
||||
<span data-maps--maplibre-target="routeEndTime"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold">Duration:</span>
|
||||
<span data-maps--maplibre-target="routeDuration"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold">Distance:</span>
|
||||
<span data-maps--maplibre-target="routeDistance"></span>
|
||||
</div>
|
||||
<div data-maps--maplibre-target="routeSpeedContainer">
|
||||
<span class="font-semibold">Avg Speed:</span>
|
||||
<span data-maps--maplibre-target="routeSpeed"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold">Points:</span>
|
||||
<span data-maps--maplibre-target="routePoints"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Selection Actions (shown after area is selected) -->
|
||||
<div class="hidden mt-4 space-y-2" data-maps--maplibre-target="selectionActions">
|
||||
<button type="button"
|
||||
|
|
|
|||
|
|
@ -61,4 +61,542 @@ test.describe('Map Interactions', () => {
|
|||
await expect(mapContainer).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Route Interactions', () => {
|
||||
test('route hover layer exists', async ({ page }) => {
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
return controller?.map?.getLayer('routes-hover') !== undefined
|
||||
}, { timeout: 10000 }).catch(() => false)
|
||||
|
||||
const hasHoverLayer = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
return controller?.map?.getLayer('routes-hover') !== undefined
|
||||
})
|
||||
|
||||
expect(hasHoverLayer).toBe(true)
|
||||
})
|
||||
|
||||
test('route hover shows yellow highlight', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Get first route's bounding box and hover over its center
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
|
||||
// Get middle coordinate of route
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
|
||||
// Project to pixel coordinates
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
// Get the canvas element and hover over the route
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.hover({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check if hover source has data (route is highlighted)
|
||||
const isHighlighted = 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 hoverSource = controller.map.getSource('routes-hover-source')
|
||||
return hoverSource && hoverSource._data?.features?.length > 0
|
||||
})
|
||||
|
||||
expect(isHighlighted).toBe(true)
|
||||
|
||||
// Check for emoji markers (start 🚥 and end 🏁)
|
||||
const startMarker = page.locator('.route-emoji-marker:has-text("🚥")')
|
||||
const endMarker = page.locator('.route-emoji-marker:has-text("🏁")')
|
||||
await expect(startMarker).toBeVisible()
|
||||
await expect(endMarker).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('route click opens info panel with route details', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Get first route's center and click on it
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
// Click on the route
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.click({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check if info panel is visible
|
||||
const infoDisplay = page.locator('[data-maps--maplibre-target="infoDisplay"]')
|
||||
await expect(infoDisplay).not.toHaveClass(/hidden/)
|
||||
|
||||
// Check if info panel has route information title
|
||||
const infoTitle = page.locator('[data-maps--maplibre-target="infoTitle"]')
|
||||
await expect(infoTitle).toHaveText('Route Information')
|
||||
|
||||
// Check if route details are displayed
|
||||
const infoContent = page.locator('[data-maps--maplibre-target="infoContent"]')
|
||||
const content = await infoContent.textContent()
|
||||
|
||||
expect(content).toContain('Start:')
|
||||
expect(content).toContain('End:')
|
||||
expect(content).toContain('Duration:')
|
||||
expect(content).toContain('Distance:')
|
||||
expect(content).toContain('Points:')
|
||||
|
||||
// Check for emoji markers (start 🚥 and end 🏁)
|
||||
const startMarker = page.locator('.route-emoji-marker:has-text("🚥")')
|
||||
const endMarker = page.locator('.route-emoji-marker:has-text("🏁")')
|
||||
await expect(startMarker).toBeVisible()
|
||||
await expect(endMarker).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('clicked route stays highlighted after mouse moves away', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click on a route
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.click({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Move mouse away from route
|
||||
await canvas.hover({ position: { x: 100, y: 100 } })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check if route is still highlighted
|
||||
const isStillHighlighted = 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 hoverSource = controller.map.getSource('routes-hover-source')
|
||||
return hoverSource && hoverSource._data?.features?.length > 0
|
||||
})
|
||||
|
||||
expect(isStillHighlighted).toBe(true)
|
||||
|
||||
// Check if info panel is still visible
|
||||
const infoDisplay = page.locator('[data-maps--maplibre-target="infoDisplay"]')
|
||||
await expect(infoDisplay).not.toHaveClass(/hidden/)
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking elsewhere on map deselects route', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click on a route first
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.click({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify route is selected
|
||||
const infoDisplay = page.locator('[data-maps--maplibre-target="infoDisplay"]')
|
||||
await expect(infoDisplay).not.toHaveClass(/hidden/)
|
||||
|
||||
// Click elsewhere on map (far from route)
|
||||
await canvas.click({ position: { x: 100, y: 100 } })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check if route is deselected (hover source cleared)
|
||||
const isDeselected = 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 hoverSource = controller.map.getSource('routes-hover-source')
|
||||
return hoverSource && hoverSource._data?.features?.length === 0
|
||||
})
|
||||
|
||||
expect(isDeselected).toBe(true)
|
||||
|
||||
// Check if info panel is hidden
|
||||
await expect(infoDisplay).toHaveClass(/hidden/)
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking close button on info panel deselects route', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click on a route
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.click({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify info panel is open
|
||||
const infoDisplay = page.locator('[data-maps--maplibre-target="infoDisplay"]')
|
||||
await expect(infoDisplay).not.toHaveClass(/hidden/)
|
||||
|
||||
// Click the close button
|
||||
const closeButton = page.locator('button[data-action="click->maps--maplibre#closeInfo"]')
|
||||
await closeButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check if route is deselected
|
||||
const isDeselected = 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 hoverSource = controller.map.getSource('routes-hover-source')
|
||||
return hoverSource && hoverSource._data?.features?.length === 0
|
||||
})
|
||||
|
||||
expect(isDeselected).toBe(true)
|
||||
|
||||
// Check if info panel is hidden
|
||||
await expect(infoDisplay).toHaveClass(/hidden/)
|
||||
}
|
||||
})
|
||||
|
||||
test('route cursor changes to pointer on hover', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Hover over a route
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.hover({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check cursor style
|
||||
const cursor = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
return controller.map.getCanvas().style.cursor
|
||||
})
|
||||
|
||||
expect(cursor).toBe('pointer')
|
||||
}
|
||||
})
|
||||
|
||||
test('hovering over different route while one is selected shows both highlighted', async ({ page }) => {
|
||||
// Wait for multiple routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length >= 2
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Get centers of two different routes
|
||||
const routeCenters = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length >= 2) return null
|
||||
|
||||
const route1 = source._data.features[0]
|
||||
const route2 = source._data.features[1]
|
||||
|
||||
const coords1 = route1.geometry.coordinates
|
||||
const coords2 = route2.geometry.coordinates
|
||||
|
||||
const midCoord1 = coords1[Math.floor(coords1.length / 2)]
|
||||
const midCoord2 = coords2[Math.floor(coords2.length / 2)]
|
||||
|
||||
const point1 = controller.map.project(midCoord1)
|
||||
const point2 = controller.map.project(midCoord2)
|
||||
|
||||
return {
|
||||
route1: { x: point1.x, y: point1.y },
|
||||
route2: { x: point2.x, y: point2.y }
|
||||
}
|
||||
})
|
||||
|
||||
if (routeCenters) {
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
|
||||
// Click on first route to select it
|
||||
await canvas.click({
|
||||
position: { x: routeCenters.route1.x, y: routeCenters.route1.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify first route is selected
|
||||
const infoDisplay = page.locator('[data-maps--maplibre-target="infoDisplay"]')
|
||||
await expect(infoDisplay).not.toHaveClass(/hidden/)
|
||||
|
||||
// Hover over second route
|
||||
await canvas.hover({
|
||||
position: { x: routeCenters.route2.x, y: routeCenters.route2.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check that hover source now has 2 features (both routes highlighted)
|
||||
const featureCount = 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 hoverSource = controller.map.getSource('routes-hover-source')
|
||||
return hoverSource && hoverSource._data?.features?.length
|
||||
})
|
||||
|
||||
expect(featureCount).toBe(2)
|
||||
|
||||
// Move mouse away from both routes
|
||||
await canvas.hover({ position: { x: 100, y: 100 } })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check that only selected route remains highlighted (1 feature)
|
||||
const featureCountAfterLeave = 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 hoverSource = controller.map.getSource('routes-hover-source')
|
||||
return hoverSource && hoverSource._data?.features?.length
|
||||
})
|
||||
|
||||
expect(featureCountAfterLeave).toBe(1)
|
||||
|
||||
// Check that markers are present for the selected route only
|
||||
const markerCount = await page.locator('.route-emoji-marker').count()
|
||||
expect(markerCount).toBe(2) // Start and end marker for selected route
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking elsewhere removes emoji markers', async ({ page }) => {
|
||||
// Wait for routes to be loaded
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
}, { timeout: 20000 })
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click on a route
|
||||
const routeCenter = 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 source = controller.map.getSource('routes-source')
|
||||
|
||||
if (!source._data?.features?.length) return null
|
||||
|
||||
const route = source._data.features[0]
|
||||
const coords = route.geometry.coordinates
|
||||
const midCoord = coords[Math.floor(coords.length / 2)]
|
||||
const point = controller.map.project(midCoord)
|
||||
|
||||
return { x: point.x, y: point.y }
|
||||
})
|
||||
|
||||
if (routeCenter) {
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await canvas.click({
|
||||
position: { x: routeCenter.x, y: routeCenter.y }
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify markers are present
|
||||
let markerCount = await page.locator('.route-emoji-marker').count()
|
||||
expect(markerCount).toBe(2)
|
||||
|
||||
// Click elsewhere on map
|
||||
await canvas.click({ position: { x: 100, y: 100 } })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify markers are removed
|
||||
markerCount = await page.locator('.route-emoji-marker').count()
|
||||
expect(markerCount).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue