From 8ed3cf45572b536a9c7ca3fbac5ad4f87d435cc3 Mon Sep 17 00:00:00 2001 From: Connor McFarlane Date: Sat, 11 Jul 2026 18:05:10 +0100 Subject: [PATCH] Add real-time vehicle tracking --- web/src/components/map.ts | 3 ++ web/src/icons.ts | 3 +- web/src/style/emf.ts | 12 ++++++ web/src/vehicles.css | 12 ++++++ web/src/vehicles.ts | 91 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 web/src/vehicles.css create mode 100644 web/src/vehicles.ts diff --git a/web/src/components/map.ts b/web/src/components/map.ts index 7c25286..0f0f2d0 100644 --- a/web/src/components/map.ts +++ b/web/src/components/map.ts @@ -9,6 +9,7 @@ import { Layer, LayerGroup, LayerSwitcher, URLHash } from '@russss/maplibregl-la import ContextMenu from './contextmenu.ts' import { roundPosition } from '../util.ts' import { setupPhones } from '../phones.ts' +import { setupVehicles } from '../vehicles.ts' import { loadIcons } from '../icons.ts' import { LngLat, LngLatLike } from 'maplibre-gl' @@ -111,6 +112,7 @@ export class EMFMap extends LitElement { new Layer('r', 'Phones', 'phones_', true), new Layer('i', 'Noise prediction', 'noise', false), ]), + new LayerGroup('Tracking', [new Layer('V', 'Vehicles', 'vehicles_', true)]), new LayerGroup('Infrastructure', [ new Layer('w', 'Power', 'power_'), new Layer('n', 'Network', 'network_'), @@ -220,6 +222,7 @@ export class EMFMap extends LitElement { loadIcons(map) setupPhones(map) + setupVehicles(map) map.touchZoomRotate.disableRotation() diff --git a/web/src/icons.ts b/web/src/icons.ts index 64c0e74..d4e9b65 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -27,6 +27,7 @@ export async function loadIcons(map: maplibregl.Map) { 'network-switch-active', 'network-switch-down', 'phone', + 'golf-buggy', ] Promise.all( @@ -38,7 +39,7 @@ export async function loadIcons(map: maplibregl.Map) { .map((f) => f()) ) - const sdfs = ['telehandler', 'golf-buggy', 'cherrypicker'] + const sdfs = ['telehandler', 'cherrypicker'] for (const sdf of sdfs) { const img = await map.loadImage(`${hostname}/sdf/${sdf}.png`) diff --git a/web/src/style/emf.ts b/web/src/style/emf.ts index 6fd2c8a..75668a6 100644 --- a/web/src/style/emf.ts +++ b/web/src/style/emf.ts @@ -837,6 +837,18 @@ export const layers: LayerSpecificationWithZIndex[] = [ 'icon-anchor': 'center', }, }, + { + id: 'vehicles_symbol', + type: 'symbol', + source: 'vehicles', + minzoom: 16, + layout: { + 'icon-image': 'golf-buggy', + 'icon-size': ['interpolate', ['linear'], ['zoom'], 17, 0.08, 22, 0.22], + 'icon-allow-overlap': true, + 'icon-anchor': 'center', + }, + }, { id: 'labels_camping', type: 'symbol', diff --git a/web/src/vehicles.css b/web/src/vehicles.css new file mode 100644 index 0000000..1a94234 --- /dev/null +++ b/web/src/vehicles.css @@ -0,0 +1,12 @@ +.vehicles-popup { + min-width: 150px; + margin-top: -10px; +} + +.vehicles-popup h3 { + font-size: 1.5em; +} + +.vehicles-popup p { + margin: 0.3em 0 0; +} diff --git a/web/src/vehicles.ts b/web/src/vehicles.ts new file mode 100644 index 0000000..eac0b4c --- /dev/null +++ b/web/src/vehicles.ts @@ -0,0 +1,91 @@ +import maplibregl from 'maplibre-gl' +import type { Feature, FeatureCollection } from 'geojson' +import { el, mount } from 'redom' +import './vehicles.css' + +const TRACKING_HOST = 'https://emf.eventwan.net' + +const LAYER = 'vehicles_symbol' +const SOURCE = 'vehicles' +const TTL_MS = 3600 * 1000 + +const devices = new Map() + +function fresh(feature: Feature): boolean { + const lastSeen = feature.properties?.lastSeen + return typeof lastSeen !== 'number' || Date.now() - lastSeen < TTL_MS +} + +function render(map: maplibregl.Map) { + for (const [devEui, feature] of devices) { + if (!fresh(feature)) devices.delete(devEui) + } + const source = map.getSource(SOURCE) as maplibregl.GeoJSONSource | undefined + if (!source) return + const collection: FeatureCollection = { + type: 'FeatureCollection', + features: [...devices.values()], + } + source.setData(collection) +} + +function upsert(feature: Feature) { + const props = feature.properties + if (!props || props.type !== 'vehicles' || props.devEui == null) return + devices.set(props.devEui, feature) +} + +async function loadSnapshot(map: maplibregl.Map) { + const response = await fetch(`${TRACKING_HOST}/lorawan.geojson`) + const collection: FeatureCollection = await response.json() + for (const feature of collection.features) { + upsert(feature) + } + render(map) +} + +function subscribe(map: maplibregl.Map) { + const stream = new EventSource(`${TRACKING_HOST}/lorawan`) + stream.onmessage = (e) => { + upsert(JSON.parse(e.data)) + render(map) + } +} + +function popupContent(props: Record) { + const content = el('.vehicles-popup', el('h3', props.deviceName)) + if (props.battery != null) { + mount(content, el('p', `Battery ${props.battery}%`)) + } + if (props.temperature != null) { + mount(content, el('p', `Temperature ${props.temperature}°C`)) + } + if (props.lastSeen != null) { + mount(content, el('p', `Last seen ${new Date(props.lastSeen).toLocaleString()}`)) + } + return content +} + +export function setupVehicles(map: maplibregl.Map) { + map.on('click', LAYER, (e: maplibregl.MapLayerMouseEvent) => { + const props = e.features![0].properties + new maplibregl.Popup().setLngLat(e.lngLat).setDOMContent(popupContent(props)).addTo(map) + }) + + let old_cursor = '' + + map.on('mouseenter', LAYER, () => { + old_cursor = map.getCanvas().style.cursor + map.getCanvas().style.cursor = 'pointer' + }) + + map.on('mouseleave', LAYER, () => { + map.getCanvas().style.cursor = old_cursor + }) + + map.on('load', () => { + loadSnapshot(map) + subscribe(map) + setInterval(() => render(map), 60_000) + }) +}