From ab62e03ca18015eb324a0cb1d2ec4088a29c494d Mon Sep 17 00:00:00 2001 From: Connor McFarlane Date: Sat, 11 Jul 2026 18:24:11 +0100 Subject: [PATCH 1/4] Only connect if needed --- web/src/components/map.ts | 4 +++- web/src/vehicles.ts | 31 +++++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/web/src/components/map.ts b/web/src/components/map.ts index 0f0f2d0..59b33d5 100644 --- a/web/src/components/map.ts +++ b/web/src/components/map.ts @@ -112,7 +112,9 @@ 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('Tracking', [ + new Layer('V', 'Vehicles', 'vehicles_', true) + ]), new LayerGroup('Infrastructure', [ new Layer('w', 'Power', 'power_'), new Layer('n', 'Network', 'network_'), diff --git a/web/src/vehicles.ts b/web/src/vehicles.ts index eac0b4c..d4a0d66 100644 --- a/web/src/vehicles.ts +++ b/web/src/vehicles.ts @@ -44,14 +44,36 @@ async function loadSnapshot(map: maplibregl.Map) { render(map) } -function subscribe(map: maplibregl.Map) { - const stream = new EventSource(`${TRACKING_HOST}/lorawan`) +let stream: EventSource | undefined + +function trackingVisible(map: maplibregl.Map): boolean { + if (!map.getLayer(LAYER)) return false + return map.getLayoutProperty(LAYER, 'visibility') !== 'none' +} + +function connect(map: maplibregl.Map) { + if (stream) return + loadSnapshot(map) + stream = new EventSource(`${TRACKING_HOST}/lorawan`) stream.onmessage = (e) => { upsert(JSON.parse(e.data)) render(map) } } +function disconnect() { + stream?.close() + stream = undefined +} + +function syncConnection(map: maplibregl.Map) { + if (trackingVisible(map)) { + connect(map) + } else { + disconnect() + } +} + function popupContent(props: Record) { const content = el('.vehicles-popup', el('h3', props.deviceName)) if (props.battery != null) { @@ -84,8 +106,9 @@ export function setupVehicles(map: maplibregl.Map) { }) map.on('load', () => { - loadSnapshot(map) - subscribe(map) + syncConnection(map) setInterval(() => render(map), 60_000) }) + + map.on('styledata', () => syncConnection(map)) } From e8caf6997b3e54bba7b2c5d5248ad1464100ea0e Mon Sep 17 00:00:00 2001 From: Connor McFarlane Date: Sun, 12 Jul 2026 00:08:02 +0100 Subject: [PATCH 2/4] Add bus tracking & centralise tracking stream --- web/src/components/map.ts | 7 +- web/src/icons.ts | 1 + web/src/icons/bus.svg | 1 + web/src/style/emf.ts | 12 ++ web/src/style/map_style.ts | 4 + web/src/{vehicles.css => tracking.css} | 6 +- web/src/tracking.ts | 188 +++++++++++++++++++++++++ web/src/vehicles.ts | 114 --------------- 8 files changed, 213 insertions(+), 120 deletions(-) create mode 100644 web/src/icons/bus.svg rename web/src/{vehicles.css => tracking.css} (60%) create mode 100644 web/src/tracking.ts delete mode 100644 web/src/vehicles.ts diff --git a/web/src/components/map.ts b/web/src/components/map.ts index 59b33d5..0f4d98c 100644 --- a/web/src/components/map.ts +++ b/web/src/components/map.ts @@ -9,7 +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 { setupTracking } from '../tracking.ts' import { loadIcons } from '../icons.ts' import { LngLat, LngLatLike } from 'maplibre-gl' @@ -113,7 +113,8 @@ export class EMFMap extends LitElement { new Layer('i', 'Noise prediction', 'noise', false), ]), new LayerGroup('Tracking', [ - new Layer('V', 'Vehicles', 'vehicles_', true) + new Layer('V', 'Vehicles', 'vehicles_', true), + new Layer('B', 'Busses', 'bus_', true), ]), new LayerGroup('Infrastructure', [ new Layer('w', 'Power', 'power_'), @@ -224,7 +225,7 @@ export class EMFMap extends LitElement { loadIcons(map) setupPhones(map) - setupVehicles(map) + setupTracking(map) map.touchZoomRotate.disableRotation() diff --git a/web/src/icons.ts b/web/src/icons.ts index d4e9b65..e9436fe 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -28,6 +28,7 @@ export async function loadIcons(map: maplibregl.Map) { 'network-switch-down', 'phone', 'golf-buggy', + 'bus', ] Promise.all( diff --git a/web/src/icons/bus.svg b/web/src/icons/bus.svg new file mode 100644 index 0000000..96fb7c3 --- /dev/null +++ b/web/src/icons/bus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/style/emf.ts b/web/src/style/emf.ts index 75668a6..2c89cef 100644 --- a/web/src/style/emf.ts +++ b/web/src/style/emf.ts @@ -849,6 +849,18 @@ export const layers: LayerSpecificationWithZIndex[] = [ 'icon-anchor': 'center', }, }, + { + id: 'bus_symbol', + type: 'symbol', + source: 'bus', + minzoom: 16, + layout: { + 'icon-image': 'bus', + 'icon-size': ['interpolate', ['linear'], ['zoom'], 17, 0.05, 22, 0.15], + 'icon-allow-overlap': true, + 'icon-anchor': 'center', + }, + }, { id: 'labels_camping', type: 'symbol', diff --git a/web/src/style/map_style.ts b/web/src/style/map_style.ts index ac112d2..579c699 100644 --- a/web/src/style/map_style.ts +++ b/web/src/style/map_style.ts @@ -58,6 +58,10 @@ const style: StyleSpecification = { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }, + bus: { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }, grist_markers: { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, diff --git a/web/src/vehicles.css b/web/src/tracking.css similarity index 60% rename from web/src/vehicles.css rename to web/src/tracking.css index 1a94234..60cf009 100644 --- a/web/src/vehicles.css +++ b/web/src/tracking.css @@ -1,12 +1,12 @@ -.vehicles-popup { +.tracking-popup { min-width: 150px; margin-top: -10px; } -.vehicles-popup h3 { +.tracking-popup h3 { font-size: 1.5em; } -.vehicles-popup p { +.tracking-popup p { margin: 0.3em 0 0; } diff --git a/web/src/tracking.ts b/web/src/tracking.ts new file mode 100644 index 0000000..73ee45c --- /dev/null +++ b/web/src/tracking.ts @@ -0,0 +1,188 @@ +import maplibregl from 'maplibre-gl' +import type { Feature, FeatureCollection } from 'geojson' +import { el, mount } from 'redom' +import './tracking.css' + +const TRACKING_HOST = import.meta.env.DEV ? 'http://localhost:3000' : 'https://emf.eventwan.net' + +const TTL_MS = 3600 * 1000 +const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] +const MOVING_MS = 0.5 + +interface TrackingLayer { + type: string + layer: string + source: string + snapshot: string + id: (props: Record) => string | undefined + popup: (props: Record) => HTMLElement + features: Map +} + +function lastSeenLine(props: Record) { + if (props.lastSeen == null) return undefined + return el('p', `Last seen ${new Date(props.lastSeen).toLocaleString()}`) +} + +function vehiclePopup(props: Record) { + const vehicleName = `${props.vehicleType}${props.registration ? ` (${props.registration})` : ''}` + + const content = el('.tracking-popup', el('h3', vehicleName)) + if (props.registration != null) { + mount(content, el('p', `Tracker ${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`)) + } + const lastSeen = lastSeenLine(props) + if (lastSeen) mount(content, lastSeen) + return content +} + +function busPopup(props: Record) { + const content = el('.tracking-popup', el('h3', props.name)) + if (props.address != null) { + mount(content, el('p', props.address)) + } + if (props.speed != null) { + mount(content, el('p', `Speed ${Math.round(props.speed * 2.237)} mph`)) + } + if (props.course != null && props.speed > MOVING_MS) { + const point = COMPASS[Math.round(props.course / 45) % 8] + mount(content, el('p', `Heading ${Math.round(props.course)}° (${point})`)) + } + const lastSeen = lastSeenLine(props) + if (lastSeen) mount(content, lastSeen) + return content +} + +const trackingLayers: TrackingLayer[] = [ + { + type: 'lorawan', + layer: 'vehicles_symbol', + source: 'vehicles', + snapshot: 'vehicles.geojson', + id: (props) => props.devEui, + popup: vehiclePopup, + features: new Map(), + }, + { + type: 'bus', + layer: 'bus_symbol', + source: 'bus', + snapshot: 'bus.geojson', + id: (props) => props.assetId?.toString(), + popup: busPopup, + features: new Map(), + }, +] + +function fresh(feature: Feature): boolean { + const lastSeen = Date.parse(feature.properties?.lastSeen) + return isNaN(lastSeen) || Date.now() - lastSeen < TTL_MS +} + +function render(map: maplibregl.Map, layer: TrackingLayer) { + for (const [id, feature] of layer.features) { + if (!fresh(feature)) layer.features.delete(id) + } + const source = map.getSource(layer.source) as maplibregl.GeoJSONSource | undefined + if (!source) return + const collection: FeatureCollection = { + type: 'FeatureCollection', + features: [...layer.features.values()], + } + source.setData(collection) +} + +function upsert(layer: TrackingLayer, feature: Feature) { + const props = feature.properties + if (!props) return + const id = layer.id(props) + if (id == null) return + layer.features.set(id, feature) +} + +async function loadSnapshot(map: maplibregl.Map, layer: TrackingLayer) { + const response = await fetch(`${TRACKING_HOST}/${layer.snapshot}`) + const collection: FeatureCollection = await response.json() + for (const feature of collection.features) { + upsert(layer, feature) + } + render(map, layer) +} + +let stream: EventSource | undefined +let streamTypes = '' + +function visible(map: maplibregl.Map, layer: TrackingLayer): boolean { + if (!map.getLayer(layer.layer)) return false + return map.getLayoutProperty(layer.layer, 'visibility') !== 'none' +} + +function disconnect() { + stream?.close() + stream = undefined + streamTypes = '' +} + +function syncConnection(map: maplibregl.Map) { + const enabled = trackingLayers.filter((layer) => visible(map, layer)) + const types = enabled.map((layer) => layer.type).join(',') + if (types === streamTypes) return + + disconnect() + + for (const layer of trackingLayers) { + if (enabled.includes(layer)) continue + layer.features.clear() + render(map, layer) + } + + if (!types) return + streamTypes = types + + for (const layer of enabled) { + loadSnapshot(map, layer) + } + + stream = new EventSource(`${TRACKING_HOST}/stream?type=${types}`) + for (const layer of enabled) { + stream.addEventListener(layer.type, (e) => { + upsert(layer, JSON.parse(e.data)) + render(map, layer) + }) + } +} + +export function setupTracking(map: maplibregl.Map) { + let old_cursor = '' + + for (const layer of trackingLayers) { + map.on('click', layer.layer, (e: maplibregl.MapLayerMouseEvent) => { + const props = e.features![0].properties + new maplibregl.Popup().setLngLat(e.lngLat).setDOMContent(layer.popup(props)).addTo(map) + }) + + map.on('mouseenter', layer.layer, () => { + old_cursor = map.getCanvas().style.cursor + map.getCanvas().style.cursor = 'pointer' + }) + + map.on('mouseleave', layer.layer, () => { + map.getCanvas().style.cursor = old_cursor + }) + } + + map.on('load', () => { + syncConnection(map) + setInterval(() => { + for (const layer of trackingLayers) render(map, layer) + }, 60_000) + }) + + map.on('styledata', () => syncConnection(map)) +} diff --git a/web/src/vehicles.ts b/web/src/vehicles.ts deleted file mode 100644 index d4a0d66..0000000 --- a/web/src/vehicles.ts +++ /dev/null @@ -1,114 +0,0 @@ -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) -} - -let stream: EventSource | undefined - -function trackingVisible(map: maplibregl.Map): boolean { - if (!map.getLayer(LAYER)) return false - return map.getLayoutProperty(LAYER, 'visibility') !== 'none' -} - -function connect(map: maplibregl.Map) { - if (stream) return - loadSnapshot(map) - stream = new EventSource(`${TRACKING_HOST}/lorawan`) - stream.onmessage = (e) => { - upsert(JSON.parse(e.data)) - render(map) - } -} - -function disconnect() { - stream?.close() - stream = undefined -} - -function syncConnection(map: maplibregl.Map) { - if (trackingVisible(map)) { - connect(map) - } else { - disconnect() - } -} - -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', () => { - syncConnection(map) - setInterval(() => render(map), 60_000) - }) - - map.on('styledata', () => syncConnection(map)) -} From 4e0e1bf976c69143647dbc5ec4117b97f8e74c51 Mon Sep 17 00:00:00 2001 From: Connor McFarlane Date: Sun, 12 Jul 2026 00:30:26 +0100 Subject: [PATCH 3/4] Show last seen as timeago --- web/src/tracking.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/web/src/tracking.ts b/web/src/tracking.ts index 73ee45c..fd08d03 100644 --- a/web/src/tracking.ts +++ b/web/src/tracking.ts @@ -19,9 +19,29 @@ interface TrackingLayer { features: Map } +const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +function timeAgo(lastSeen: string): string { + const seconds = Math.round((Date.parse(lastSeen) - Date.now()) / 1000) + if (seconds > -60) return relativeTime.format(seconds, 'second') + if (seconds > -3600) return relativeTime.format(Math.round(seconds / 60), 'minute') + return relativeTime.format(Math.round(seconds / 3600), 'hour') +} + function lastSeenLine(props: Record) { if (props.lastSeen == null) return undefined - return el('p', `Last seen ${new Date(props.lastSeen).toLocaleString()}`) + const time = el( + 'time', + { datetime: props.lastSeen, title: new Date(props.lastSeen).toLocaleString() }, + timeAgo(props.lastSeen) + ) + return el('p', 'Last seen ', time) +} + +function refreshTimes(content: HTMLElement) { + content.querySelectorAll('time').forEach((time) => { + time.textContent = timeAgo(time.dateTime) + }) } function vehiclePopup(props: Record) { @@ -163,8 +183,11 @@ export function setupTracking(map: maplibregl.Map) { for (const layer of trackingLayers) { map.on('click', layer.layer, (e: maplibregl.MapLayerMouseEvent) => { - const props = e.features![0].properties - new maplibregl.Popup().setLngLat(e.lngLat).setDOMContent(layer.popup(props)).addTo(map) + const content = layer.popup(e.features![0].properties) + const popup = new maplibregl.Popup().setLngLat(e.lngLat).setDOMContent(content).addTo(map) + + const ticker = setInterval(() => refreshTimes(content), 1000) + popup.on('close', () => clearInterval(ticker)) }) map.on('mouseenter', layer.layer, () => { From b32aa55fc84e1d21181be5ad03ac10af77b37979 Mon Sep 17 00:00:00 2001 From: Connor McFarlane Date: Sun, 12 Jul 2026 12:52:59 +0100 Subject: [PATCH 4/4] Add people tracking --- web/src/components/map.ts | 1 + web/src/icons.ts | 1 + web/src/icons/bus.svg | 2 +- web/src/icons/person.svg | 1 + web/src/style/emf.ts | 12 ++++++++++++ web/src/style/map_style.ts | 4 ++++ web/src/tracking.ts | 30 +++++++++++++++++++++++++++--- 7 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 web/src/icons/person.svg diff --git a/web/src/components/map.ts b/web/src/components/map.ts index 0f4d98c..52e88d5 100644 --- a/web/src/components/map.ts +++ b/web/src/components/map.ts @@ -115,6 +115,7 @@ export class EMFMap extends LitElement { new LayerGroup('Tracking', [ new Layer('V', 'Vehicles', 'vehicles_', true), new Layer('B', 'Busses', 'bus_', true), + new Layer('P', 'People', 'people_', false), ]), new LayerGroup('Infrastructure', [ new Layer('w', 'Power', 'power_'), diff --git a/web/src/icons.ts b/web/src/icons.ts index e9436fe..0dd2eb2 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -29,6 +29,7 @@ export async function loadIcons(map: maplibregl.Map) { 'phone', 'golf-buggy', 'bus', + 'person', ] Promise.all( diff --git a/web/src/icons/bus.svg b/web/src/icons/bus.svg index 96fb7c3..331eb4e 100644 --- a/web/src/icons/bus.svg +++ b/web/src/icons/bus.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/web/src/icons/person.svg b/web/src/icons/person.svg new file mode 100644 index 0000000..ae454b8 --- /dev/null +++ b/web/src/icons/person.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/style/emf.ts b/web/src/style/emf.ts index 2c89cef..aab37be 100644 --- a/web/src/style/emf.ts +++ b/web/src/style/emf.ts @@ -861,6 +861,18 @@ export const layers: LayerSpecificationWithZIndex[] = [ 'icon-anchor': 'center', }, }, + { + id: 'people_symbol', + type: 'symbol', + source: 'people', + minzoom: 16, + layout: { + 'icon-image': 'person', + 'icon-size': ['interpolate', ['linear'], ['zoom'], 17, 0.05, 22, 0.15], + 'icon-allow-overlap': true, + 'icon-anchor': 'center', + }, + }, { id: 'labels_camping', type: 'symbol', diff --git a/web/src/style/map_style.ts b/web/src/style/map_style.ts index 579c699..d2cfc07 100644 --- a/web/src/style/map_style.ts +++ b/web/src/style/map_style.ts @@ -62,6 +62,10 @@ const style: StyleSpecification = { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }, + people: { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }, grist_markers: { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, diff --git a/web/src/tracking.ts b/web/src/tracking.ts index fd08d03..2439455 100644 --- a/web/src/tracking.ts +++ b/web/src/tracking.ts @@ -45,10 +45,12 @@ function refreshTimes(content: HTMLElement) { } function vehiclePopup(props: Record) { - const vehicleName = `${props.vehicleType}${props.registration ? ` (${props.registration})` : ''}` + const vehicleName = props.vehicleType + ? `${props.vehicleType}${props.registration ? ` (${props.registration})` : ''}` + : `Tracker ${props.deviceName}` const content = el('.tracking-popup', el('h3', vehicleName)) - if (props.registration != null) { + if (props.vehicleType != null) { mount(content, el('p', `Tracker ${props.deviceName}`)) } if (props.battery != null) { @@ -79,9 +81,22 @@ function busPopup(props: Record) { return content } +function peoplePopup(props: Record) { + const content = el('.tracking-popup', el('h3', props.name ?? 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`)) + } + const lastSeen = lastSeenLine(props) + if (lastSeen) mount(content, lastSeen) + return content +} + const trackingLayers: TrackingLayer[] = [ { - type: 'lorawan', + type: 'vehicles', layer: 'vehicles_symbol', source: 'vehicles', snapshot: 'vehicles.geojson', @@ -98,6 +113,15 @@ const trackingLayers: TrackingLayer[] = [ popup: busPopup, features: new Map(), }, + { + type: 'people', + layer: 'people_symbol', + source: 'people', + snapshot: 'people.geojson', + id: (props) => props.devEui, + popup: peoplePopup, + features: new Map(), + }, ] function fresh(feature: Feature): boolean {