All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #4
440 lines
15 KiB
Vue
440 lines
15 KiB
Vue
<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 = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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>
|