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) - }) -}