Update speed routes

This commit is contained in:
Eugene Burmakin 2025-11-26 21:43:59 +01:00
parent 529eee775a
commit 541488e6ce
6 changed files with 590 additions and 5 deletions

File diff suppressed because one or more lines are too long

View file

@ -50,7 +50,12 @@ export default class extends Controller {
'areasToggle',
// 'tracksToggle',
'fogToggle',
'scratchToggle'
'scratchToggle',
// Speed-colored routes
'routesOptions',
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput'
]
async connect() {
@ -118,7 +123,8 @@ export default class extends Controller {
areasToggle: 'areasEnabled',
// tracksToggle: 'tracksEnabled',
fogToggle: 'fogEnabled',
scratchToggle: 'scratchEnabled'
scratchToggle: 'scratchEnabled',
speedColoredToggle: 'speedColoredRoutesEnabled'
}
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
@ -173,6 +179,16 @@ export default class extends Controller {
}
}
// Sync speed-colored routes settings
if (this.hasSpeedColorScaleInputTarget) {
const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.speedColorScaleInputTarget.value = colorScale
}
if (this.hasSpeedColorScaleContainerTarget && this.hasSpeedColoredToggleTarget) {
const isEnabled = this.speedColoredToggleTarget.checked
this.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled)
}
// Sync points rendering mode radio buttons
const pointsRenderingRadios = this.element.querySelectorAll('input[name="pointsRenderingMode"]')
pointsRenderingRadios.forEach(radio => {
@ -426,6 +442,11 @@ export default class extends Controller {
routesLayer.toggle(visible)
}
// Show/hide routes options panel
if (this.hasRoutesOptionsTarget) {
this.routesOptionsTarget.style.display = visible ? 'block' : 'none'
}
// Save setting
SettingsManager.updateSetting('routesVisible', visible)
}
@ -747,4 +768,211 @@ export default class extends Controller {
Toast.error('Failed to load scratch layer')
}
}
/**
* Toggle speed-colored routes
*/
async toggleSpeedColoredRoutes(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled)
// Show/hide color scale container
if (this.hasSpeedColorScaleContainerTarget) {
this.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled)
}
// Reload routes with speed colors
await this.reloadRoutes()
}
/**
* Open speed color editor modal
*/
openSpeedColorEditor() {
const currentScale = this.speedColorScaleInputTarget.value || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
// Create modal if it doesn't exist
let modal = document.getElementById('speed-color-editor-modal')
if (!modal) {
modal = this.createSpeedColorEditorModal(currentScale)
document.body.appendChild(modal)
} else {
// Update existing modal with current scale
const controller = this.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor')
if (controller) {
controller.colorStopsValue = currentScale
controller.loadColorStops()
}
}
// Show modal
const checkbox = modal.querySelector('.modal-toggle')
if (checkbox) {
checkbox.checked = true
}
}
/**
* Create speed color editor modal element
*/
createSpeedColorEditorModal(currentScale) {
const modal = document.createElement('div')
modal.id = 'speed-color-editor-modal'
modal.setAttribute('data-controller', 'speed-color-editor')
modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale)
modal.setAttribute('data-action', 'speed-color-editor:save->maps-v2#handleSpeedColorSave')
modal.innerHTML = `
<input type="checkbox" id="speed-color-editor-toggle" class="modal-toggle" />
<div class="modal" role="dialog" data-speed-color-editor-target="modal">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold mb-4">Edit Speed Color Gradient</h3>
<div class="space-y-4">
<!-- Gradient Preview -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Preview</span>
</label>
<div class="h-12 rounded-lg border-2 border-base-300"
data-speed-color-editor-target="preview"></div>
<label class="label">
<span class="label-text-alt">This gradient will be applied to routes based on speed</span>
</label>
</div>
<!-- Color Stops List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Color Stops</span>
</label>
<div class="space-y-2" data-speed-color-editor-target="stopsList"></div>
</div>
<!-- Add Stop Button -->
<button type="button"
class="btn btn-sm btn-outline w-full"
data-action="click->speed-color-editor#addStop">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Color Stop
</button>
</div>
<div class="modal-action">
<button type="button"
class="btn btn-ghost"
data-action="click->speed-color-editor#resetToDefault">
Reset to Default
</button>
<button type="button"
class="btn"
data-action="click->speed-color-editor#close">
Cancel
</button>
<button type="button"
class="btn btn-primary"
data-action="click->speed-color-editor#save">
Save
</button>
</div>
</div>
<label class="modal-backdrop" for="speed-color-editor-toggle"></label>
</div>
`
return modal
}
/**
* Handle speed color save event from editor
*/
handleSpeedColorSave(event) {
const newScale = event.detail.colorStops
// Save to settings
this.speedColorScaleInputTarget.value = newScale
SettingsManager.updateSetting('speedColorScale', newScale)
// Reload routes if speed colors are enabled
if (this.speedColoredToggleTarget.checked) {
this.reloadRoutes()
}
}
/**
* Reload routes layer
*/
async reloadRoutes() {
this.showLoading('Reloading routes...')
try {
const pointsLayer = this.layerManager.getLayer('points')
const points = pointsLayer?.data?.features?.map(f => ({
latitude: f.geometry.coordinates[1],
longitude: f.geometry.coordinates[0],
timestamp: f.properties.timestamp
})) || []
// Get route generation settings
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
// Import speed colors utility
const { calculateSpeed, getSpeedColor } = await import('maps_v2/utils/speed_colors')
// Generate routes with speed coloring if enabled
const routesGeoJSON = await this.generateRoutesWithSpeedColors(
points,
{ distanceThresholdMeters, timeThresholdMinutes },
calculateSpeed,
getSpeedColor
)
// Update routes layer
this.layerManager.updateLayer('routes', routesGeoJSON)
} catch (error) {
console.error('Failed to reload routes:', error)
Toast.error('Failed to reload routes')
} finally {
this.hideLoading()
}
}
/**
* Generate routes with speed coloring
*/
async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) {
const { RoutesLayer } = await import('maps_v2/layers/routes_layer')
const useSpeedColors = this.settings.speedColoredRoutesEnabled || false
const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
// Use RoutesLayer static method to generate basic routes
const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options)
if (!useSpeedColors) {
return routesGeoJSON
}
// Add speed colors to route segments
routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => {
const segment = points.slice(
points.findIndex(p => p.timestamp === feature.properties.startTime),
points.findIndex(p => p.timestamp === feature.properties.endTime) + 1
)
if (segment.length >= 2) {
const speed = calculateSpeed(segment[0], segment[segment.length - 1])
const color = getSpeedColor(speed, useSpeedColors, speedColorScale)
feature.properties.speed = speed
feature.properties.color = color
}
return feature
})
return routesGeoJSON
}
}

View file

@ -0,0 +1,184 @@
import { Controller } from '@hotwired/stimulus'
/**
* Speed Color Editor Controller
* Manages the gradient editor modal for speed-colored routes
*/
export default class extends Controller {
static targets = ['modal', 'stopsList', 'preview']
static values = {
colorStops: String
}
connect() {
this.loadColorStops()
}
loadColorStops() {
const stopsString = this.colorStopsValue || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.stops = this.parseColorStops(stopsString)
this.renderStops()
this.updatePreview()
}
parseColorStops(stopsString) {
return stopsString.split('|').map(segment => {
const [speed, color] = segment.split(':')
return { speed: Number(speed), color }
})
}
serializeColorStops() {
return this.stops.map(stop => `${stop.speed}:${stop.color}`).join('|')
}
renderStops() {
if (!this.hasStopsListTarget) return
this.stopsListTarget.innerHTML = this.stops.map((stop, index) => `
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg" data-index="${index}">
<div class="flex-1">
<label class="label">
<span class="label-text text-sm">Speed (km/h)</span>
</label>
<input type="number"
class="input input-bordered input-sm w-full"
value="${stop.speed}"
min="0"
max="200"
data-action="input->speed-color-editor#updateSpeed"
data-index="${index}" />
</div>
<div class="flex-1">
<label class="label">
<span class="label-text text-sm">Color</span>
</label>
<div class="flex gap-2 items-center">
<input type="color"
class="w-12 h-10 rounded cursor-pointer border-2 border-base-300"
value="${stop.color}"
data-action="input->speed-color-editor#updateColor"
data-index="${index}" />
<input type="text"
class="input input-bordered input-sm w-24 font-mono text-xs"
value="${stop.color}"
pattern="^#[0-9A-Fa-f]{6}$"
data-action="input->speed-color-editor#updateColorText"
data-index="${index}" />
</div>
</div>
<button type="button"
class="btn btn-sm btn-ghost btn-circle text-error mt-6"
data-action="click->speed-color-editor#removeStop"
data-index="${index}"
${this.stops.length <= 2 ? 'disabled' : ''}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
`).join('')
}
updateSpeed(event) {
const index = parseInt(event.target.dataset.index)
this.stops[index].speed = Number(event.target.value)
this.updatePreview()
}
updateColor(event) {
const index = parseInt(event.target.dataset.index)
const color = event.target.value
this.stops[index].color = color
// Update text input
const textInput = event.target.parentElement.querySelector('input[type="text"]')
if (textInput) {
textInput.value = color
}
this.updatePreview()
}
updateColorText(event) {
const index = parseInt(event.target.dataset.index)
const color = event.target.value
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.stops[index].color = color
// Update color picker
const colorInput = event.target.parentElement.querySelector('input[type="color"]')
if (colorInput) {
colorInput.value = color
}
this.updatePreview()
}
}
addStop() {
// Find a good speed value between existing stops
const lastStop = this.stops[this.stops.length - 1]
const newSpeed = lastStop.speed + 10
this.stops.push({
speed: newSpeed,
color: '#ff0000'
})
// Sort by speed
this.stops.sort((a, b) => a.speed - b.speed)
this.renderStops()
this.updatePreview()
}
removeStop(event) {
const index = parseInt(event.target.dataset.index)
if (this.stops.length > 2) {
this.stops.splice(index, 1)
this.renderStops()
this.updatePreview()
}
}
updatePreview() {
if (!this.hasPreviewTarget) return
const gradient = this.stops.map((stop, index) => {
const percentage = (index / (this.stops.length - 1)) * 100
return `${stop.color} ${percentage}%`
}).join(', ')
this.previewTarget.style.background = `linear-gradient(to right, ${gradient})`
}
save() {
const serialized = this.serializeColorStops()
// Dispatch event with the new color stops
this.dispatch('save', {
detail: { colorStops: serialized }
})
this.close()
}
close() {
if (this.hasModalTarget) {
const checkbox = this.modalTarget.querySelector('.modal-toggle')
if (checkbox) {
checkbox.checked = false
}
}
}
resetToDefault() {
this.colorStopsValue = '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.loadColorStops()
}
}

View file

@ -31,7 +31,14 @@ export class RoutesLayer extends BaseLayer {
'line-cap': 'round'
},
paint: {
'line-color': '#f97316', // Orange color (more visible than blue)
// Use color from feature properties if available (for speed-colored routes)
// Otherwise fall back to default orange
'line-color': [
'case',
['has', 'color'],
['get', 'color'],
'#f97316' // Default orange color
],
'line-width': 3,
'line-opacity': 0.8
}

View file

@ -0,0 +1,140 @@
/**
* Speed color utilities for route visualization
* Provides speed calculation and color interpolation for route segments
*/
// Default color stops for speed visualization
export const colorStopsFallback = [
{ speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
{ speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
{ speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
{ speed: 50, color: '#ffff00' }, // Urban driving (yellow)
{ speed: 100, color: '#ff3300' } // Highway driving (red)
]
/**
* Encode color stops array to string format for storage
* @param {Array} arr - Array of {speed, color} objects
* @returns {string} Encoded string (e.g., "0:#00ff00|15:#00ffff")
*/
export function colorFormatEncode(arr) {
return arr.map(item => `${item.speed}:${item.color}`).join('|')
}
/**
* Decode color stops string to array format
* @param {string} str - Encoded color stops string
* @returns {Array} Array of {speed, color} objects
*/
export function colorFormatDecode(str) {
return str.split('|').map(segment => {
const [speed, color] = segment.split(':')
return { speed: Number(speed), color }
})
}
/**
* Convert hex color to RGB object
* @param {string} hex - Hex color (e.g., "#ff0000")
* @returns {Object} RGB object {r, g, b}
*/
function hexToRGB(hex) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
/**
* Calculate speed between two points
* @param {Object} point1 - First point with lat, lon, timestamp
* @param {Object} point2 - Second point with lat, lon, timestamp
* @returns {number} Speed in km/h
*/
export function calculateSpeed(point1, point2) {
if (!point1 || !point2 || !point1.timestamp || !point2.timestamp) {
return 0
}
const distanceKm = haversineDistance(
point1.latitude, point1.longitude,
point2.latitude, point2.longitude
)
const timeDiffSeconds = point2.timestamp - point1.timestamp
// Handle edge cases
if (timeDiffSeconds <= 0 || distanceKm <= 0) {
return 0
}
const speedKmh = (distanceKm / timeDiffSeconds) * 3600
// Cap speed at reasonable maximum (150 km/h)
const MAX_SPEED = 150
return Math.min(speedKmh, MAX_SPEED)
}
/**
* Calculate haversine distance between two points
* @param {number} lat1 - First point latitude
* @param {number} lon1 - First point longitude
* @param {number} lat2 - Second point latitude
* @param {number} lon2 - Second point longitude
* @returns {number} Distance in kilometers
*/
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371 // Earth's radius in kilometers
const dLat = (lat2 - lat1) * Math.PI / 180
const dLon = (lon2 - lon1) * Math.PI / 180
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
/**
* Get color for a given speed with interpolation
* @param {number} speedKmh - Speed in km/h
* @param {boolean} useSpeedColors - Whether to use speed-based coloring
* @param {string} speedColorScale - Encoded color scale string
* @returns {string} RGB color string (e.g., "rgb(255, 0, 0)")
*/
export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) {
if (!useSpeedColors) {
return '#f97316' // Default orange color
}
let colorStops
try {
colorStops = colorFormatDecode(speedColorScale).map(stop => ({
...stop,
rgb: hexToRGB(stop.color)
}))
} catch (error) {
// If user has given invalid values, use fallback
colorStops = colorStopsFallback.map(stop => ({
...stop,
rgb: hexToRGB(stop.color)
}))
}
// Find the appropriate color segment and interpolate
for (let i = 1; i < colorStops.length; i++) {
if (speedKmh <= colorStops[i].speed) {
const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed)
const color1 = colorStops[i-1].rgb
const color2 = colorStops[i].rgb
const r = Math.round(color1.r + (color2.r - color1.r) * ratio)
const g = Math.round(color1.g + (color2.g - color1.g) * ratio)
const b = Math.round(color1.b + (color2.b - color1.b) * ratio)
return `rgb(${r}, ${g}, ${b})`
}
}
// If speed exceeds all stops, return the last color
return colorStops[colorStops.length - 1].color
}

View file

@ -106,6 +106,32 @@
<p class="text-sm text-base-content/60 ml-14">Show connected route lines</p>
</div>
<!-- Speed-Colored Routes Options (conditionally shown) -->
<div class="ml-14 space-y-3" data-maps-v2-target="routesOptions" style="display: none;">
<div class="form-control">
<label class="label cursor-pointer py-2">
<span class="label-text text-sm">Color by speed</span>
<input type="checkbox"
class="toggle toggle-sm toggle-primary"
data-maps-v2-target="speedColoredToggle"
data-action="change->maps-v2#toggleSpeedColoredRoutes" />
</label>
</div>
<!-- Speed Color Scale Editor (shown when speed colors enabled) -->
<div class="hidden" data-maps-v2-target="speedColorScaleContainer">
<button type="button"
class="btn btn-sm btn-outline w-full"
data-action="click->maps-v2#openSpeedColorEditor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Edit Color Gradient
</button>
<input type="hidden" data-maps-v2-target="speedColorScaleInput" value="" />
</div>
</div>
<div class="divider my-2"></div>
<!-- Heatmap Layer -->