diff --git a/web/src/components/map.ts b/web/src/components/map.ts
index 0f0f2d0..52e88d5 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'
@@ -112,7 +112,11 @@ 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 Layer('B', 'Busses', 'bus_', true),
+ new Layer('P', 'People', 'people_', false),
+ ]),
new LayerGroup('Infrastructure', [
new Layer('w', 'Power', 'power_'),
new Layer('n', 'Network', 'network_'),
@@ -222,7 +226,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..0dd2eb2 100644
--- a/web/src/icons.ts
+++ b/web/src/icons.ts
@@ -28,6 +28,8 @@ export async function loadIcons(map: maplibregl.Map) {
'network-switch-down',
'phone',
'golf-buggy',
+ 'bus',
+ 'person',
]
Promise.all(
diff --git a/web/src/icons/bus.svg b/web/src/icons/bus.svg
new file mode 100644
index 0000000..331eb4e
--- /dev/null
+++ b/web/src/icons/bus.svg
@@ -0,0 +1 @@
+
\ 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 75668a6..aab37be 100644
--- a/web/src/style/emf.ts
+++ b/web/src/style/emf.ts
@@ -849,6 +849,30 @@ 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: '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 ac112d2..d2cfc07 100644
--- a/web/src/style/map_style.ts
+++ b/web/src/style/map_style.ts
@@ -58,6 +58,14 @@ const style: StyleSpecification = {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
},
+ bus: {
+ 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/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..2439455
--- /dev/null
+++ b/web/src/tracking.ts
@@ -0,0 +1,235 @@
+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
+}
+
+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
+ 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) {
+ const vehicleName = props.vehicleType
+ ? `${props.vehicleType}${props.registration ? ` (${props.registration})` : ''}`
+ : `Tracker ${props.deviceName}`
+
+ const content = el('.tracking-popup', el('h3', vehicleName))
+ if (props.vehicleType != 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
+}
+
+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: 'vehicles',
+ 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(),
+ },
+ {
+ type: 'people',
+ layer: 'people_symbol',
+ source: 'people',
+ snapshot: 'people.geojson',
+ id: (props) => props.devEui,
+ popup: peoplePopup,
+ 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 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, () => {
+ 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 eac0b4c..0000000
--- a/web/src/vehicles.ts
+++ /dev/null
@@ -1,91 +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)
-}
-
-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)
- })
-}