Files
kestrelos/app/components/KestrelMap.vue
Madison Grubb fbb38c5dd7
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
improve db
2026-02-12 13:28:36 -05:00

779 lines
27 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 [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.2)]"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<template v-if="contextMenu.type === 'map'">
<button
type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
@click="openAddPoiModal(contextMenu.latlng)"
>
Add POI here
</button>
</template>
<template v-else-if="contextMenu.type === 'poi'">
<button
type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border"
@click="openEditPoiModal(contextMenu.poi)"
>
Edit
</button>
<button
type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-kestrel-border"
@click="openDeletePoiModal(contextMenu.poi)"
>
Delete
</button>
</template>
</div>
<!-- POI modal (Add / Edit) -->
<Teleport to="body">
<Transition name="modal">
<div
v-if="showPoiModal"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
@keydown.escape="closePoiModal"
>
<button
type="button"
class="absolute inset-0 bg-black/60 transition-opacity"
aria-label="Close"
@click="closePoiModal"
/>
<!-- Add / Edit form -->
<div
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
ref="poiModalRef"
class="relative w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
>
<h2
id="poi-modal-title"
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
{{ poiModalMode === 'edit' ? 'Edit POI' : 'Add POI' }}
</h2>
<form
class="space-y-4"
@submit.prevent="submitPoiModal"
>
<div>
<label
for="add-poi-label"
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Label (optional)
</label>
<input
id="add-poi-label"
v-model="poiForm.label"
type="text"
placeholder="e.g. Rally point"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent"
autocomplete="off"
>
</div>
<div>
<label
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Icon type
</label>
<div
:ref="el => iconDropdownOpen && (iconDropdownRef.value = el)"
class="relative inline-block w-full"
>
<button
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
:aria-expanded="iconDropdownOpen"
aria-haspopup="listbox"
:aria-label="`Icon type: ${poiForm.iconType}`"
@click="iconDropdownOpen = !iconDropdownOpen"
>
<span class="flex items-center gap-2 capitalize">
<Icon
:name="POI_ICONIFY_IDS[poiForm.iconType]"
class="size-4 shrink-0"
/>
{{ poiForm.iconType }}
</span>
<span
class="text-kestrel-muted transition-transform"
:class="iconDropdownOpen && 'rotate-180'"
>
</span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="iconDropdownOpen"
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
role="listbox"
>
<button
v-for="opt in POI_ICON_TYPES"
:key="opt"
type="button"
role="option"
:aria-selected="poiForm.iconType === opt"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
:class="poiForm.iconType === opt
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border'"
@click="poiForm.iconType = opt; iconDropdownOpen = false"
>
<Icon
:name="POI_ICONIFY_IDS[opt]"
class="size-4 shrink-0"
/>
{{ opt }}
</button>
</div>
</Transition>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
@click="closePoiModal"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
>
{{ poiModalMode === 'edit' ? 'Save changes' : 'Add POI' }}
</button>
</div>
</form>
</div>
<!-- Delete confirmation -->
<div
v-if="poiModalMode === 'delete'"
ref="poiModalRef"
class="relative w-full max-w-sm rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
>
<h2
id="delete-poi-title"
class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
Delete POI?
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
{{ deletePoi?.label ? `${deletePoi.label}” will be removed.` : 'This POI will be removed.' }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
@click="closePoiModal"
>
Cancel
</button>
<button
type="button"
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
@click="confirmDeletePoi"
>
Delete
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</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 poiModalRef = ref(null)
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 iconDropdownOpen = ref(false)
const iconDropdownRef = ref(null)
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_ICON_TYPES = ['pin', 'flag', 'waypoint']
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
/** Tabler icon names (Nuxt Icon / Iconify) modern technical aesthetic. */
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
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
}
function getPoiIcon(L, poi) {
const type = poi.icon_type === 'pin' || poi.icon_type === 'flag' || poi.icon_type === 'waypoint' ? 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'
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 style="color:#22c9c9">● 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' }
iconDropdownOpen.value = false
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' }
iconDropdownOpen.value = false
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'
iconDropdownOpen.value = false
addPoiLatlng.value = null
editPoi.value = null
deletePoi.value = null
}
function onPoiModalDocumentClick(e) {
if (!showPoiModal.value) return
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) {
iconDropdownOpen.value = false
}
}
async function submitPoiModal() {
if (poiModalMode.value === 'add') {
const latlng = addPoiLatlng.value
if (!latlng) return
const { label, iconType } = poiForm.value
try {
await $fetch('/api/pois', { method: 'POST', body: { lat: latlng.lat, lng: latlng.lng, label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
return
}
if (poiModalMode.value === 'edit' && editPoi.value) {
const { label, iconType } = poiForm.value
try {
await $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body: { label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
}
}
async function confirmDeletePoi() {
const poi = deletePoi.value
if (!poi?.id) return
try {
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
}
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)
document.addEventListener('click', onPoiModalDocumentClick)
})
function onDocumentClick(e) {
if (contextMenu.value.type && contextMenuRef.value && !contextMenuRef.value.contains(e.target)) closeContextMenu()
}
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
document.removeEventListener('click', onPoiModalDocumentClick)
destroyMap()
})
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: transform 0.2s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.96);
}
/* Unrendered/loading tiles show black instead of white when panning */
.kestrel-map-container {
background: #000 !important;
}
:deep(.leaflet-tile-pane),
:deep(.leaflet-map-pane),
:deep(.leaflet-tile-container) {
background: #000 !important;
}
:deep(img.leaflet-tile) {
background: #000 !important;
/* Override Leaflets plus-lighter so unloaded/empty tiles dont flash white */
mix-blend-mode: normal;
}
/* Leaflet injects divIcon HTML into the map; :deep() so these styles apply to that content */
:deep(.poi-div-icon) {
background: none;
border: none;
}
:deep(.poi-icon-svg) {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Dark-themed tooltip for POI labels (Leaflet creates these in the map container) */
:deep(.kestrel-poi-tooltip) {
background: #1e293b;
border: 1px solid rgba(34, 201, 201, 0.35);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:deep(.kestrel-poi-tooltip::before),
:deep(.kestrel-poi-tooltip::after) {
border-top-color: #1e293b;
border-bottom-color: #1e293b;
border-left-color: #1e293b;
border-right-color: #1e293b;
}
/* Live session popup (content injected by Leaflet) */
:deep(.kestrel-live-popup-wrap .leaflet-popup-content) {
margin: 8px 12px;
min-width: 200px;
}
:deep(.kestrel-live-popup) {
color: #e2e8f0;
font-size: 12px;
}
:deep(.kestrel-live-popup img) {
display: block;
max-height: 160px;
width: auto;
border-radius: 4px;
background: #0f172a;
}
:deep(.live-session-icon) {
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Map controls dark theme with cyan glow (zoom, locate, save/clear tiles) */
:deep(.leaflet-control-zoom),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar) {
border: 1px solid rgba(34, 201, 201, 0.35) !important;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 12px -2px rgba(34, 201, 201, 0.15);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
}
:deep(.leaflet-control-zoom a),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar a) {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
background: #0d1424 !important;
color: #b8c9e0 !important;
border: none !important;
border-radius: 0 !important;
font-size: 18px !important;
font-weight: 600;
text-decoration: none !important;
transition: background 0.15s, color 0.15s, box-shadow 0.15s, text-shadow 0.15s;
}
:deep(.leaflet-control-zoom a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
:deep(.leaflet-control-zoom a:hover),
:deep(.leaflet-control-locate:hover),
:deep(.savetiles.leaflet-bar a:hover) {
background: #111a2e !important;
color: #22c9c9 !important;
box-shadow: 0 0 16px -2px rgba(34, 201, 201, 0.25);
text-shadow: 0 0 8px rgba(34, 201, 201, 0.35);
}
:deep(.leaflet-control-locate) {
display: flex !important;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
}
:deep(.leaflet-control-locate svg) {
color: currentColor;
}
/* Save/Clear tiles text buttons */
:deep(.savetiles.leaflet-bar) {
display: flex;
flex-direction: column;
}
:deep(.savetiles.leaflet-bar a) {
width: auto !important;
min-width: 5.5em;
height: auto !important;
line-height: 1.25 !important;
padding: 6px 10px !important;
white-space: nowrap;
text-align: center;
font-size: 11px !important;
font-weight: 500;
letter-spacing: 0.02em;
}
:deep(.savetiles.leaflet-bar a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
</style>