Files
kestrelos/app/components/KestrelMap.vue
Keli Grubb 17f28401ba
All checks were successful
ci/woodpecker/push/push Pipeline was successful
minor: heavily simplify server and app content. unify styling (#4)
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
2026-02-14 04:52:18 +00:00

440 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
ref="mapRef"
data-testid="kestrel-map"
class="kestrel-map-container relative h-full w-full min-h-[300px]"
>
<div
v-if="contextMenu.type"
ref="contextMenuRef"
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-context"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<template v-if="contextMenu.type === 'map'">
<button
type="button"
class="kestrel-context-menu-item"
@click="openAddPoiModal(contextMenu.latlng)"
>
Add POI here
</button>
</template>
<template v-else-if="contextMenu.type === 'poi'">
<button
type="button"
class="kestrel-context-menu-item"
@click="openEditPoiModal(contextMenu.poi)"
>
Edit
</button>
<button
type="button"
class="kestrel-context-menu-item-danger"
@click="openDeletePoiModal(contextMenu.poi)"
>
Delete
</button>
</template>
</div>
<PoiModal
:show="showPoiModal"
:mode="poiModalMode"
:form="poiForm"
:edit-poi="editPoi"
:delete-poi="deletePoi"
@close="closePoiModal"
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
</div>
</template>
<script setup>
import 'leaflet/dist/leaflet.css'
const props = defineProps({
devices: {
type: Array,
default: () => [],
},
pois: {
type: Array,
default: () => [],
},
liveSessions: {
type: Array,
default: () => [],
},
canEditPois: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
const mapRef = ref(null)
const contextMenuRef = ref(null)
const leafletRef = shallowRef(null)
const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
const poiModalMode = ref('add') // 'add' | 'edit' | 'delete'
const addPoiLatlng = ref(null)
const editPoi = ref(null)
const deletePoi = ref(null)
const poiForm = ref({ label: '', iconType: 'pin' })
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
const TILE_SUBDOMAINS = 'abcd'
const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
const DEFAULT_VIEW = [37.7749, -122.4194]
const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/'
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const ICON_SIZE = 28
/** Embedded SVGs so each POI type has a distinct marker (no network, always correct). */
function getPoiIconSvg(type) {
const c = POI_ICON_COLORS[type] || POI_ICON_COLORS.pin
const shapes = {
pin: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8c0 4.5-6 9-6 9s-6-4.5-6-9a6 6 0 0 1 12 0z"/><circle cx="12" cy="8" r="2" fill="${c}"/></svg>`,
flag: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v16"/><path d="M6 6h10l4 4-4 4H6"/></svg>`,
waypoint: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${c}"/></svg>`,
}
return shapes[type] || shapes.pin
}
const VALID_POI_TYPES = ['pin', 'flag', 'waypoint']
function getPoiIcon(L, poi) {
const type = VALID_POI_TYPES.includes(poi.icon_type) ? poi.icon_type : 'pin'
const html = getPoiIconSvg(type)
return L.divIcon({
className: 'poi-div-icon',
html: `<span class="poi-icon-svg poi-icon-${type}">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent JS string for Leaflet SVG */
function getLiveSessionIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon live-session-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
function createMap(initialCenter) {
const { L, offlineApi } = leafletRef.value || {}
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
const center = Array.isArray(initialCenter) && initialCenter.length === 2
? initialCenter
: DEFAULT_VIEW
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
L.control.zoom({ position: 'topleft' }).addTo(map)
const locateControl = L.control({ position: 'topleft' })
locateControl.onAdd = function () {
const el = document.createElement('button')
el.type = 'button'
el.className = 'leaflet-bar leaflet-control-locate'
el.title = 'Center on my location'
el.setAttribute('aria-label', 'Center on my location')
el.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>`
el.addEventListener('click', () => {
if (!navigator?.geolocation) return
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords
map.setView([latitude, longitude], DEFAULT_ZOOM, { animate: true })
},
() => {},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
)
})
return el
}
locateControl.addTo(map)
const baseLayer = L.tileLayer(TILE_URL, {
attribution: ATTRIBUTION,
subdomains: TILE_SUBDOMAINS,
minZoom: 1,
maxZoom: 19,
})
baseLayer.addTo(map)
const control = (() => {
if (!offlineApi?.tileLayerOffline || !offlineApi.savetiles) return null
const layer = offlineApi.tileLayerOffline(TILE_URL, { attribution: ATTRIBUTION, subdomains: TILE_SUBDOMAINS, minZoom: 1, maxZoom: 19 })
const c = offlineApi.savetiles(layer, {
zoomlevels: [10, 11, 12, 13, 14, 15],
position: 'topleft',
saveText: 'Save tiles',
rmText: 'Clear tiles',
})
c.addTo(map)
return c
})()
map.on('contextmenu', (e) => {
if (!props.canEditPois) return
e.originalEvent?.preventDefault()
const pt = map.latLngToContainerPoint(e.latlng)
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
})
mapContext.value = { map, layer: baseLayer, control, locateControl }
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
}
function updateMarkers() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
markersRef.value.forEach((m) => {
if (m) m.remove()
})
const validSources = (props.devices || []).filter(f => typeof f?.lat === 'number' && typeof f?.lng === 'number')
markersRef.value = validSources.map(item =>
L.marker([item.lat, item.lng]).addTo(ctx.map).on('click', () => emit('select', item)),
)
}
function updatePoiMarkers() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const pois = (props.pois || []).filter(p => typeof p?.lat === 'number' && typeof p?.lng === 'number' && p?.id)
const byId = Object.fromEntries(pois.map(p => [p.id, p]))
const prev = poiMarkersRef.value
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = pois.reduce((acc, poi) => {
const existing = prev[poi.id]
const icon = getPoiIcon(L, poi)
if (existing) {
existing.setLatLng([poi.lat, poi.lng])
if (icon) existing.setIcon(icon)
existing.getTooltip()?.setContent(poi.label || '')
if (!existing.getTooltip()?.isOpen() && (poi.label || props.canEditPois)) existing.bindTooltip(poi.label || poi.id, { permanent: false, className: POI_TOOLTIP_CLASS })
return { ...acc, [poi.id]: existing }
}
const marker = L.marker([poi.lat, poi.lng], icon ? { icon } : undefined).addTo(ctx.map)
if (poi.label || props.canEditPois) marker.bindTooltip(poi.label || poi.id, { permanent: false, className: POI_TOOLTIP_CLASS })
if (props.canEditPois) {
marker.on('contextmenu', (e) => {
e.originalEvent?.preventDefault()
e.originalEvent?.stopPropagation()
const pt = ctx.map.latLngToContainerPoint(e.latlng)
contextMenu.value = { type: 'poi', poi, x: pt.x, y: pt.y }
})
}
return { ...acc, [poi.id]: marker }
}, {})
poiMarkersRef.value = next
}
function updateLiveMarkers() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const sessions = (props.liveSessions || []).filter(
s => typeof s?.lat === 'number' && typeof s?.lng === 'number' && s?.id,
)
const byId = Object.fromEntries(sessions.map(s => [s.id, s]))
const prev = liveMarkersRef.value
const icon = getLiveSessionIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = sessions.reduce((acc, session) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span class="text-kestrel-accent">● Live</span>' : ''}</div>`
const existing = prev[session.id]
if (existing) {
existing.setLatLng([session.lat, session.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [session.id]: existing }
}
const marker = L.marker([session.lat, session.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
.on('click', () => emit('selectLive', session))
return { ...acc, [session.id]: marker }
}, {})
liveMarkersRef.value = next
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function closeContextMenu() {
contextMenu.value = { ...CONTEXT_MENU_EMPTY }
}
function openAddPoiModal(latlng) {
closeContextMenu()
poiModalMode.value = 'add'
addPoiLatlng.value = { lat: latlng.lat, lng: latlng.lng }
editPoi.value = null
deletePoi.value = null
poiForm.value = { label: '', iconType: 'pin' }
showPoiModal.value = true
}
function openEditPoiModal(poi) {
closeContextMenu()
poiModalMode.value = 'edit'
editPoi.value = poi
addPoiLatlng.value = null
deletePoi.value = null
poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' }
showPoiModal.value = true
}
function openDeletePoiModal(poi) {
closeContextMenu()
poiModalMode.value = 'delete'
deletePoi.value = poi
addPoiLatlng.value = null
editPoi.value = null
showPoiModal.value = true
}
function closePoiModal() {
showPoiModal.value = false
poiModalMode.value = 'add'
addPoiLatlng.value = null
editPoi.value = null
deletePoi.value = null
}
async function doPoiFetch(fn) {
try {
await fn()
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
}
async function onPoiSubmit(payload) {
const { label, iconType } = payload
const body = { label: (label ?? '').trim(), iconType: iconType || 'pin' }
if (poiModalMode.value === 'add') {
const latlng = addPoiLatlng.value
if (!latlng) return
await doPoiFetch(() => $fetch('/api/pois', { method: 'POST', body: { ...body, lat: latlng.lat, lng: latlng.lng } }))
return
}
if (poiModalMode.value === 'edit' && editPoi.value) {
await doPoiFetch(() => $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body }))
}
}
async function confirmDeletePoi() {
const poi = deletePoi.value
if (!poi?.id) return
await doPoiFetch(() => $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' }))
}
function destroyMap() {
markersRef.value.forEach((m) => {
if (m) m.remove()
})
markersRef.value = []
Object.values(poiMarkersRef.value).forEach(m => m?.remove())
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
const ctx = mapContext.value
if (ctx) {
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
if (ctx.map) ctx.map.remove()
mapContext.value = null
}
}
function initMapWithLocation() {
if (!navigator?.geolocation) {
createMap(DEFAULT_VIEW)
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords
createMap([latitude, longitude])
},
() => {
createMap(DEFAULT_VIEW)
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
)
}
onMounted(async () => {
if (!import.meta.client || typeof document === 'undefined') return
const [leaflet, offline] = await Promise.all([
import('leaflet'),
import('leaflet.offline'),
])
const L = leaflet.default
if (L.Icon?.Default?.mergeOptions) {
L.Icon.Default.mergeOptions({
iconUrl: `${MARKER_ICON_PATH}marker-icon.png`,
iconRetinaUrl: `${MARKER_ICON_PATH}marker-icon-2x.png`,
shadowUrl: `${MARKER_ICON_PATH}marker-shadow.png`,
})
}
leafletRef.value = { L, offlineApi: offline }
initMapWithLocation()
document.addEventListener('click', onDocumentClick)
})
function onDocumentClick(e) {
if (contextMenu.value.type && contextMenuRef.value && !contextMenuRef.value.contains(e.target)) closeContextMenu()
}
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
destroyMap()
})
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
</script>