Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions web/src/components/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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_'),
Expand Down Expand Up @@ -222,7 +226,7 @@ export class EMFMap extends LitElement {

loadIcons(map)
setupPhones(map)
setupVehicles(map)
setupTracking(map)

map.touchZoomRotate.disableRotation()

Expand Down
2 changes: 2 additions & 0 deletions web/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export async function loadIcons(map: maplibregl.Map) {
'network-switch-down',
'phone',
'golf-buggy',
'bus',
'person',
]

Promise.all(
Expand Down
1 change: 1 addition & 0 deletions web/src/icons/bus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions web/src/icons/person.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions web/src/style/emf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions web/src/style/map_style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand Down
6 changes: 3 additions & 3 deletions web/src/vehicles.css → web/src/tracking.css
Original file line number Diff line number Diff line change
@@ -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;
}
235 changes: 235 additions & 0 deletions web/src/tracking.ts
Original file line number Diff line number Diff line change
@@ -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, any>) => string | undefined
popup: (props: Record<string, any>) => HTMLElement
features: Map<string, Feature>
}

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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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))
}
Loading