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
3 changes: 3 additions & 0 deletions web/src/components/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

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

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

map.touchZoomRotate.disableRotation()

Expand Down
3 changes: 2 additions & 1 deletion web/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function loadIcons(map: maplibregl.Map) {
'network-switch-active',
'network-switch-down',
'phone',
'golf-buggy',
]

Promise.all(
Expand 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`)
Expand Down
12 changes: 12 additions & 0 deletions web/src/style/emf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions web/src/vehicles.css
Original file line number Diff line number Diff line change
@@ -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;
}
91 changes: 91 additions & 0 deletions web/src/vehicles.ts
Original file line number Diff line number Diff line change
@@ -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<string, Feature>()

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