dawarich/app/javascript/controllers/maps/maplibre_realtime_controller.js
Evgenii Burmakin 8934c29fce
0.36.2 (#2007)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-06 20:54:49 +01:00

323 lines
9.1 KiB
JavaScript

import { Controller } from '@hotwired/stimulus'
import { createMapChannel } from 'maps_maplibre/channels/map_channel'
import { WebSocketManager } from 'maps_maplibre/utils/websocket_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Real-time controller
* Manages ActionCable connection and real-time updates
*/
export default class extends Controller {
static targets = ['liveModeToggle']
static values = {
enabled: { type: Boolean, default: true },
liveMode: { type: Boolean, default: false }
}
connect() {
console.log('[Realtime Controller] Connecting...')
if (!this.enabledValue) {
console.log('[Realtime Controller] Disabled, skipping setup')
return
}
try {
this.connectedChannels = new Set()
this.liveModeEnabled = false // Start with live mode disabled
// Delay channel setup to ensure ActionCable is ready
// This prevents race condition with page initialization
setTimeout(() => {
try {
this.setupChannels()
} catch (error) {
console.error('[Realtime Controller] Failed to setup channels in setTimeout:', error)
this.updateConnectionIndicator(false)
}
}, 1000)
// Initialize toggle state from settings
if (this.hasLiveModeToggleTarget) {
this.liveModeToggleTarget.checked = this.liveModeEnabled
}
} catch (error) {
console.error('[Realtime Controller] Failed to initialize:', error)
// Don't throw - allow page to continue loading
}
}
disconnect() {
this.channels?.unsubscribeAll()
}
/**
* Setup ActionCable channels
* Family channel is always enabled when family feature is on
* Points channel (live mode) is controlled by user toggle
*/
setupChannels() {
try {
console.log('[Realtime Controller] Setting up channels...')
this.channels = createMapChannel({
connected: this.handleConnected.bind(this),
disconnected: this.handleDisconnected.bind(this),
received: this.handleReceived.bind(this),
enableLiveMode: this.liveModeEnabled // Control points channel
})
console.log('[Realtime Controller] Channels setup complete')
} catch (error) {
console.error('[Realtime Controller] Failed to setup channels:', error)
console.error('[Realtime Controller] Error stack:', error.stack)
this.updateConnectionIndicator(false)
// Don't throw - page should continue to work
}
}
/**
* Toggle live mode (new points appearing in real-time)
*/
toggleLiveMode(event) {
this.liveModeEnabled = event.target.checked
// Update recent point layer visibility
this.updateRecentPointLayerVisibility()
// Reconnect channels with new settings
if (this.channels) {
this.channels.unsubscribeAll()
}
this.setupChannels()
const message = this.liveModeEnabled ? 'Live mode enabled' : 'Live mode disabled'
Toast.info(message)
}
/**
* Update recent point layer visibility based on live mode state
*/
updateRecentPointLayerVisibility() {
const mapsController = this.mapsV2Controller
if (!mapsController) {
return
}
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
if (!recentPointLayer) {
return
}
if (this.liveModeEnabled) {
recentPointLayer.show()
} else {
recentPointLayer.hide()
recentPointLayer.clear()
}
}
/**
* Handle connection
*/
handleConnected(channelName) {
this.connectedChannels.add(channelName)
// Only show toast when at least one channel is connected
if (this.connectedChannels.size === 1) {
Toast.success('Connected to real-time updates')
this.updateConnectionIndicator(true)
}
}
/**
* Handle disconnection
*/
handleDisconnected(channelName) {
this.connectedChannels.delete(channelName)
// Show warning only when all channels are disconnected
if (this.connectedChannels.size === 0) {
Toast.warning('Disconnected from real-time updates')
this.updateConnectionIndicator(false)
}
}
/**
* Handle received data
*/
handleReceived(data) {
switch (data.type) {
case 'new_point':
this.handleNewPoint(data.point)
break
case 'family_location':
this.handleFamilyLocation(data.member)
break
case 'notification':
this.handleNotification(data.notification)
break
}
}
/**
* Get the maps--maplibre controller (on same element)
*/
get mapsV2Controller() {
const element = this.element
const app = this.application
return app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
}
/**
* Handle new point
* Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name]
*/
handleNewPoint(pointData) {
const mapsController = this.mapsV2Controller
if (!mapsController) {
console.warn('[Realtime Controller] Maps controller not found')
return
}
console.log('[Realtime Controller] Received point data:', pointData)
// Parse point data from array format
const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData
// Get points layer from layer manager
const pointsLayer = mapsController.layerManager?.getLayer('points')
if (!pointsLayer) {
console.warn('[Realtime Controller] Points layer not found')
return
}
// Get current data
const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] }
const features = [...(currentData.features || [])]
// Add new point
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [parseFloat(lon), parseFloat(lat)]
},
properties: {
id: parseInt(id),
latitude: parseFloat(lat),
longitude: parseFloat(lon),
battery: parseFloat(battery) || null,
altitude: parseFloat(altitude) || null,
timestamp: timestamp,
velocity: parseFloat(velocity) || null,
country_name: countryName || null
}
})
// Update layer with new data
pointsLayer.update({
type: 'FeatureCollection',
features
})
console.log('[Realtime Controller] Added new point to map:', id)
// Update recent point marker (always visible in live mode)
this.updateRecentPoint(parseFloat(lon), parseFloat(lat), {
id: parseInt(id),
battery: parseFloat(battery) || null,
altitude: parseFloat(altitude) || null,
timestamp: timestamp,
velocity: parseFloat(velocity) || null,
country_name: countryName || null
})
// Zoom to the new point
this.zoomToPoint(parseFloat(lon), parseFloat(lat))
Toast.info('New location recorded')
}
/**
* Handle family member location update
*/
handleFamilyLocation(member) {
const mapsController = this.mapsV2Controller
if (!mapsController) return
const familyLayer = mapsController.familyLayer
if (familyLayer) {
familyLayer.updateMember(member)
}
}
/**
* Handle notification
*/
handleNotification(notification) {
Toast.info(notification.message || 'New notification')
}
/**
* Update the recent point marker
* This marker is always visible in live mode, independent of points layer visibility
*/
updateRecentPoint(longitude, latitude, properties = {}) {
const mapsController = this.mapsV2Controller
if (!mapsController) {
console.warn('[Realtime Controller] Maps controller not found')
return
}
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
if (!recentPointLayer) {
console.warn('[Realtime Controller] Recent point layer not found')
return
}
// Show the layer if live mode is enabled and update with new point
if (this.liveModeEnabled) {
recentPointLayer.show()
recentPointLayer.updateRecentPoint(longitude, latitude, properties)
console.log('[Realtime Controller] Updated recent point marker:', longitude, latitude)
}
}
/**
* Zoom map to a specific point
*/
zoomToPoint(longitude, latitude) {
const mapsController = this.mapsV2Controller
if (!mapsController || !mapsController.map) {
console.warn('[Realtime Controller] Map not available for zooming')
return
}
const map = mapsController.map
// Fly to the new point with a smooth animation
map.flyTo({
center: [longitude, latitude],
zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher
duration: 2000, // 2 second animation
essential: true // This animation is considered essential with respect to prefers-reduced-motion
})
console.log('[Realtime Controller] Zoomed to point:', longitude, latitude)
}
/**
* Update connection indicator
*/
updateConnectionIndicator(connected) {
const indicator = document.querySelector('.connection-indicator')
if (indicator) {
// Show the indicator when connection is attempted
indicator.classList.add('active')
indicator.classList.toggle('connected', connected)
indicator.classList.toggle('disconnected', !connected)
}
}
}