All checks were successful
ci/woodpecker/push/push Pipeline was successful
## Added - CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity - Support for TAK stream protocol and traditional XML CoT messages - TLS/SSL support with automatic fallback to plain TCP - Username/password authentication for CoT connections - Real-time device position tracking with TTL-based expiration (90s default) - API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password` - TAK Server section in Settings with QR code for iTAK setup - ATAK password management in Account page for OIDC users - CoT device markers on map showing real-time positions - Comprehensive documentation in `docs/` directory - Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG` - Dependencies: `fast-xml-parser`, `jszip`, `qrcode` ## Changed - Authentication system supports CoT password management for OIDC users - Database schema includes `cot_password_hash` field - Test suite refactored to follow functional design principles ## Removed - Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js` ## Security - XML entity expansion protection in CoT parser - Enhanced input validation and SQL injection prevention - Authentication timeout to prevent hanging connections ## Breaking Changes - Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations. ## Migration Notes - OIDC users must set ATAK password via Account settings before connecting - Docker: expose port 8089 (`-p 8089:8089`) - Kubernetes: update Helm values to expose port 8089 Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #6
508 lines
17 KiB
Vue
508 lines
17 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: () => [],
|
|
},
|
|
cotEntities: {
|
|
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 cotMarkersRef = 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 resizeObserver = ref(null)
|
|
|
|
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],
|
|
})
|
|
}
|
|
|
|
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
|
|
function getCotEntityIcon(L) {
|
|
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
|
|
return L.divIcon({
|
|
className: 'poi-div-icon cot-entity-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()
|
|
updateCotMarkers()
|
|
nextTick(() => map.invalidateSize())
|
|
}
|
|
|
|
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 updateCotMarkers() {
|
|
const ctx = mapContext.value
|
|
const { L } = leafletRef.value || {}
|
|
if (!ctx?.map || !L) return
|
|
|
|
const entities = (props.cotEntities || []).filter(
|
|
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
|
|
)
|
|
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
|
|
const prev = cotMarkersRef.value
|
|
const icon = getCotEntityIcon(L)
|
|
|
|
Object.keys(prev).forEach((id) => {
|
|
if (!byId[id]) prev[id]?.remove()
|
|
})
|
|
|
|
const next = entities.reduce((acc, entity) => {
|
|
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
|
|
const existing = prev[entity.id]
|
|
if (existing) {
|
|
existing.setLatLng([entity.lat, entity.lng])
|
|
existing.setIcon(icon)
|
|
existing.getPopup()?.setContent(content)
|
|
return { ...acc, [entity.id]: existing }
|
|
}
|
|
const marker = L.marker([entity.lat, entity.lng], { icon })
|
|
.addTo(ctx.map)
|
|
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
|
|
return { ...acc, [entity.id]: marker }
|
|
}, {})
|
|
cotMarkersRef.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 = {}
|
|
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
|
|
cotMarkersRef.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)
|
|
|
|
nextTick(() => {
|
|
if (mapRef.value) {
|
|
resizeObserver.value = new ResizeObserver(() => {
|
|
mapContext.value?.map?.invalidateSize()
|
|
})
|
|
resizeObserver.value.observe(mapRef.value)
|
|
}
|
|
})
|
|
})
|
|
|
|
function onDocumentClick(e) {
|
|
if (contextMenu.value.type && contextMenuRef.value && !contextMenuRef.value.contains(e.target)) closeContextMenu()
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', onDocumentClick)
|
|
if (resizeObserver.value && mapRef.value) {
|
|
resizeObserver.value.disconnect()
|
|
resizeObserver.value = null
|
|
}
|
|
destroyMap()
|
|
})
|
|
|
|
watch(() => props.devices, () => updateMarkers(), { deep: true })
|
|
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
|
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
|
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
|
|
</script>
|