Files
MeshChatX/meshchatx/src/frontend/components/map/MapPage.vue
2026-01-05 11:47:35 -06:00

3132 lines
136 KiB
Vue

<template>
<div class="flex flex-col h-full w-full bg-white dark:bg-zinc-950 overflow-hidden">
<!-- header -->
<div
class="flex items-center px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur z-10 relative"
>
<div class="flex items-center space-x-2">
<v-icon icon="mdi-map" class="text-blue-500 dark:text-blue-400" size="24"></v-icon>
<h1 class="text-xl font-black text-gray-900 dark:text-white">{{ $t("map.title") }}</h1>
</div>
<div class="ml-auto flex items-center space-x-2">
<!-- export tool toggle -->
<button
v-if="!offlineEnabled"
ref="exportButton"
class="p-2 rounded-lg transition-colors"
:class="
isExportMode
? 'bg-blue-500 text-white shadow-sm'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
:title="$t('map.export_area')"
@click="toggleExportMode"
>
<MaterialDesignIcon icon-name="crop-free" class="size-5" />
</button>
<!-- offline/online toggle -->
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
<button
:class="
!offlineEnabled
? 'bg-white dark:bg-zinc-700 shadow-sm text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-zinc-400'
"
class="px-3 py-1 text-sm font-medium rounded-md transition-all"
@click="toggleOffline(false)"
>
{{ $t("map.online_mode") }}
</button>
<button
:class="
offlineEnabled
? 'bg-white dark:bg-zinc-700 shadow-sm text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-zinc-400'
"
class="px-3 py-1 text-sm font-medium rounded-md transition-all"
:disabled="!hasOfflineMap"
@click="toggleOffline(true)"
>
{{ $t("map.offline_mode") }}
</button>
</div>
<!-- upload button (desktop only) -->
<button
class="hidden sm:flex items-center space-x-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-sm transition-colors text-sm font-medium"
@click="$refs.fileInput.click()"
>
<MaterialDesignIcon icon-name="upload" class="size-4" />
<span>{{ $t("map.upload_mbtiles") }}</span>
</button>
<input ref="fileInput" type="file" accept=".mbtiles" class="hidden" @change="onFileSelected" />
<!-- settings button -->
<button
class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
@click="isSettingsOpen = !isSettingsOpen"
>
<MaterialDesignIcon icon-name="cog" class="size-5" />
</button>
</div>
</div>
<!-- map container -->
<div class="relative flex-1 min-h-0">
<!-- drawing toolbar -->
<div
class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu w-max max-w-[98vw]"
>
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-0.5 sm:p-1 gap-0 sm:gap-0.5 border-0"
>
<button
v-for="tool in drawingTools"
:key="tool.type"
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400',
]"
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
>
<v-icon :icon="'mdi-' + tool.icon" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400',
]"
:title="$t('map.tool_measure')"
@click="toggleMeasure"
>
<v-icon icon="mdi-ruler" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@click="clearDrawings"
>
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
v-if="selectedFeature"
class="p-1.5 sm:p-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 transition-all hover:scale-110 active:scale-90"
title="Edit note"
@click="startEditingNote(selectedFeature)"
>
<v-icon icon="mdi-note-edit-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
v-if="selectedFeature && !selectedFeature.get('telemetry')"
class="p-1.5 sm:p-2 rounded-xl bg-red-100 dark:bg-red-900/30 text-red-600 transition-all hover:scale-110 active:scale-90 animate-pulse"
title="Delete selected item"
@click="deleteSelectedFeature"
>
<v-icon icon="mdi-selection-remove" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.save_drawing')"
@click="showSaveDrawingModal = true"
>
<v-icon icon="mdi-content-save-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.load_drawing')"
@click="openLoadDrawingModal"
>
<v-icon icon="mdi-folder-open-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
class="p-1.5 sm:p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.go_to_my_location')"
@click="goToMyLocation"
>
<v-icon icon="mdi-crosshairs-gps" size="18" class="sm:!size-5"></v-icon>
</button>
</div>
</div>
<!-- search bar -->
<div
v-if="!offlineEnabled"
ref="searchContainer"
class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-30"
>
<div class="relative">
<div class="flex items-center bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 ring-0">
<input
v-model="searchQuery"
type="text"
class="flex-1 px-4 py-2.5 bg-transparent text-gray-900 dark:text-zinc-100 placeholder-gray-400 focus:outline-none focus:ring-0 border-0 text-sm"
:placeholder="$t('map.search_placeholder')"
@input="onSearchInput"
@keydown.enter="performSearch"
@focus="isSearchFocused = true"
/>
<button
v-if="searchQuery"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
@click="clearSearch"
>
<v-icon icon="mdi-close" size="18"></v-icon>
</button>
<button
class="p-2 mr-1 text-blue-500 hover:text-blue-600 disabled:text-gray-300 transition-colors"
:disabled="!searchQuery || isSearching"
@click="performSearch"
>
<v-icon
:icon="isSearching ? 'mdi-loading' : 'mdi-magnify'"
:class="{ 'animate-spin': isSearching }"
size="20"
></v-icon>
</button>
</div>
<!-- search results dropdown -->
<div
v-if="isSearchFocused && (searchResults.length > 0 || searchError)"
class="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 overflow-y-auto z-40 max-h-64"
>
<div v-if="searchError" class="p-4 text-sm text-red-500 flex items-center gap-2">
<v-icon icon="mdi-alert-circle" size="16"></v-icon>
{{ searchError }}
</div>
<button
v-for="(result, index) in searchResults"
:key="index"
class="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-zinc-800/50 border-b border-gray-100/50 dark:border-zinc-800/50 last:border-b-0 transition-all"
@click="selectSearchResult(result)"
>
<div class="font-bold text-gray-900 dark:text-zinc-100 text-sm">
{{ result.display_name }}
</div>
<div
class="text-[10px] text-gray-400 dark:text-zinc-500 mt-0.5 font-bold uppercase tracking-wider"
>
{{ result.type }}
</div>
</button>
</div>
</div>
</div>
<div ref="mapContainer" class="absolute inset-0" :class="{ 'cursor-crosshair': isExportMode }"></div>
<!-- note hover tooltip -->
<div
v-if="
hoveredFeature &&
(hoveredFeature.get('note') ||
(hoveredFeature.get('telemetry') && hoveredFeature.get('telemetry').note)) &&
!editingFeature
"
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
:style="{
left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + 'px',
top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + 'px',
}"
>
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
<MaterialDesignIcon icon-name="note-text" class="size-4" />
<span>{{
hoveredFeature.get("telemetry") ? hoveredFeature.get("peer")?.display_name || "Peer" : "Note"
}}</span>
</div>
<div class="whitespace-pre-wrap break-words">
{{ hoveredFeature.get("note") || hoveredFeature.get("telemetry")?.note }}
</div>
</div>
<!-- inline note editor (overlay) -->
<div ref="noteOverlayElement" class="absolute z-40">
<div
v-if="editingFeature && !isMobileScreen"
class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-700 p-4 w-64 transform -translate-x-1/2 -translate-y-full mb-6"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-1">
<MaterialDesignIcon icon-name="note-edit" class="size-4 text-amber-500" />
Edit Note
</span>
<button
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeNoteEditor"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
</div>
<textarea
v-model="noteText"
class="w-full h-24 p-2 text-sm bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none text-gray-900 dark:text-zinc-100"
placeholder="Type your note here..."
></textarea>
<div class="flex justify-between mt-3">
<button
class="px-3 py-1.5 text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors flex items-center gap-1"
@click="deleteNote"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
Delete
</button>
<button
class="px-3 py-1.5 text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 rounded-lg shadow-sm transition-colors"
@click="saveNote"
>
Save
</button>
</div>
</div>
</div>
<!-- context menu -->
<div
v-if="showContextMenu"
class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
>
<div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
</div>
<div class="flex flex-col">
<button
v-if="contextMenuFeature"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextSelectFeature"
>
<MaterialDesignIcon icon-name="cursor-default" class="size-4" />
<span>Select / Move</span>
</button>
<button
v-if="contextMenuFeature"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextAddNote"
>
<MaterialDesignIcon icon-name="note-edit" class="size-4" />
<span>Add / Edit Note</span>
</button>
<button
v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
@click="contextDeleteFeature"
>
<MaterialDesignIcon icon-name="delete" class="size-4" />
<span>Delete</span>
</button>
<button
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextCopyCoords"
>
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
<span>Copy coords</span>
</button>
<button
v-if="!contextMenuFeature"
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
@click="contextClearMap"
>
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
<span>Clear drawings</span>
</button>
</div>
</div>
<!-- loading skeleton for map -->
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
<div v-for="i in 16" :key="i" class="bg-slate-300 dark:bg-zinc-700 rounded-lg"></div>
</div>
</div>
<!-- telemetry marker overlay -->
<div
v-if="selectedMarker"
class="absolute bottom-4 left-4 right-4 sm:left-4 sm:right-auto sm:w-80 z-20 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden text-gray-900 dark:text-zinc-100"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="size-8 rounded-full flex items-center justify-center border-2"
:style="{
color: selectedMarker.peer?.lxmf_user_icon?.foreground_colour || '#3b82f6',
borderColor: selectedMarker.peer?.lxmf_user_icon?.foreground_colour || '#3b82f6',
backgroundColor: selectedMarker.peer?.lxmf_user_icon?.background_colour || '#ffffff',
}"
>
<v-icon
:icon="'mdi-' + (selectedMarker.peer?.lxmf_user_icon?.icon_name || 'account')"
size="18"
></v-icon>
</div>
<div>
<h3 class="font-bold text-gray-900 dark:text-zinc-100 truncate w-40">
{{
selectedMarker.peer?.display_name ||
selectedMarker.telemetry.destination_hash.substring(0, 8)
}}
</h3>
<div class="text-[10px] font-mono text-gray-500 uppercase tracking-tighter">
{{ selectedMarker.telemetry.destination_hash }}
</div>
</div>
</div>
<button
class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300"
@click="selectedMarker = null"
>
<v-icon icon="mdi-close" size="20"></v-icon>
</button>
</div>
<div class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
Latitude
</div>
<div class="font-mono">
{{ selectedMarker.telemetry.telemetry.location.latitude.toFixed(6) }}
</div>
</div>
<div>
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
Longitude
</div>
<div class="font-mono">
{{ selectedMarker.telemetry.telemetry.location.longitude.toFixed(6) }}
</div>
</div>
<div>
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
Altitude
</div>
<div>{{ selectedMarker.telemetry.telemetry.location.altitude.toFixed(1) }}m</div>
</div>
<div>
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">Speed</div>
<div>{{ selectedMarker.telemetry.telemetry.location.speed.toFixed(1) }}km/h</div>
</div>
</div>
<div
v-if="selectedMarker.telemetry.physical_link"
class="pt-2 border-t border-gray-100 dark:border-zinc-800"
>
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Signal</div>
<div class="flex gap-4 text-xs font-mono">
<span>RSSI: {{ selectedMarker.telemetry.physical_link.rssi }}</span>
<span>SNR: {{ selectedMarker.telemetry.physical_link.snr }}</span>
<span>Q: {{ selectedMarker.telemetry.physical_link.q }}%</span>
</div>
</div>
<div class="pt-2 text-[10px] text-gray-400 flex items-center gap-1">
<v-icon icon="mdi-clock-outline" size="12"></v-icon>
Updated: {{ formatTimestamp(selectedMarker.telemetry.timestamp) }}
</div>
<button
class="w-full py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-bold transition-colors text-sm flex items-center justify-center gap-2"
@click="openChat(selectedMarker.telemetry.destination_hash)"
>
<v-icon icon="mdi-message-text" size="16"></v-icon>
Open Chat
</button>
</div>
</div>
<!-- export instructions overlay -->
<div
v-if="isExportMode && !selectedBbox"
class="absolute top-4 left-1/2 -translate-x-1/2 z-20 px-4 py-2 bg-blue-600 text-white rounded-full shadow-lg font-medium text-sm animate-bounce"
>
{{ $t("map.export_instructions") }}
</div>
<!-- export configuration overlay -->
<div
v-if="isExportMode && selectedBbox"
class="absolute top-4 left-1/2 -translate-x-1/2 z-20 w-80 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden text-gray-900 dark:text-zinc-100"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-zinc-100">{{ $t("map.export_area") }}</h3>
<button class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300" @click="cancelExport">
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-4 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{
$t("map.min_zoom")
}}</label>
<input
v-model.number="exportMinZoom"
type="number"
min="0"
max="20"
class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{
$t("map.max_zoom")
}}</label>
<input
v-model.number="exportMaxZoom"
type="number"
min="0"
max="20"
class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
/>
</div>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 dark:text-zinc-400">{{ $t("map.tile_count") }}:</span>
<span class="font-bold text-blue-600">{{ estimatedTiles }}</span>
</div>
<div class="flex gap-2">
<button
:disabled="isExporting"
class="flex-1 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-zinc-700 dark:hover:bg-zinc-600 disabled:bg-gray-100 dark:disabled:bg-zinc-800 text-gray-900 dark:text-zinc-100 rounded-lg font-bold transition-colors"
@click="cancelExport"
>
{{ $t("common.cancel") }}
</button>
<button
:disabled="isExporting"
class="flex-1 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white rounded-lg font-bold transition-colors shadow-md"
@click="startExport"
>
{{ $t("map.start_export") }}
</button>
</div>
</div>
</div>
<!-- export progress overlay -->
<div
v-if="exportStatus"
class="absolute bottom-4 right-4 z-20 w-72 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 p-4 space-y-3 animate-in slide-in-from-bottom-4"
>
<div class="flex justify-between items-center">
<span class="font-bold text-sm text-gray-900 dark:text-zinc-100">{{
exportStatus.status === "completed" ? $t("map.download_ready") : $t("map.exporting")
}}</span>
<button
v-if="exportStatus.status === 'completed' || exportStatus.status === 'failed'"
class="text-gray-400"
@click="exportStatus = null"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
<button
v-else
class="text-xs font-bold text-red-500 hover:text-red-600 uppercase tracking-tighter"
@click="cancelActiveExport"
>
{{ $t("common.cancel") }}
</button>
</div>
<div v-if="exportStatus.status !== 'completed' && exportStatus.status !== 'failed'">
<div class="w-full h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full bg-blue-500 transition-all duration-300"
:style="{ width: exportStatus.progress + '%' }"
></div>
</div>
<div class="flex justify-between text-[10px] text-gray-500 mt-1 uppercase font-bold tracking-wider">
<span>{{ exportStatus.current }} / {{ exportStatus.total }} tiles</span>
<span>{{ exportStatus.progress }}%</span>
</div>
</div>
<div v-if="exportStatus.status === 'completed'">
<a
:href="`/api/v1/map/export/${exportId}/download`"
class="flex items-center justify-center space-x-2 w-full py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-bold transition-colors shadow-md"
>
<MaterialDesignIcon icon-name="download" class="size-4" />
<span>{{ $t("map.download_now") }}</span>
</a>
</div>
<div
v-if="exportStatus.status === 'failed'"
class="text-xs text-red-500 bg-red-50 dark:bg-red-950/20 p-2 rounded-lg"
>
{{ exportStatus.error }}
</div>
</div>
<!-- loading overlay -->
<div
v-if="isUploading"
class="absolute inset-0 z-20 flex items-center justify-center bg-white/50 dark:bg-black/50 backdrop-blur-sm"
>
<div class="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-xl flex flex-col items-center space-y-4">
<div
class="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"
></div>
<p class="text-gray-900 dark:text-zinc-100 font-medium">{{ $t("map.uploading") }}</p>
</div>
</div>
<!-- no map warning -->
<div
v-if="offlineEnabled && !hasOfflineMap"
class="absolute inset-0 z-20 flex items-center justify-center p-4"
>
<div
class="max-w-md bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-xl border border-amber-200 dark:border-amber-900/50 flex flex-col items-center text-center space-y-4"
>
<MaterialDesignIcon icon-name="alert-circle" class="size-12 text-amber-500" />
<p class="text-gray-900 dark:text-zinc-100 font-medium">{{ $t("map.no_map_loaded") }}</p>
<button
class="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg transition-colors font-medium"
@click="$refs.fileInput.click()"
>
{{ $t("map.upload_mbtiles") }}
</button>
</div>
</div>
<!-- map info overlay -->
<div class="absolute bottom-4 left-4 z-10 space-y-2 pointer-events-none">
<div
v-if="metadata && metadata.name && !metadata.name.startsWith('Map Export')"
class="bg-white/80 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 p-2 rounded-lg text-xs text-gray-600 dark:text-zinc-400 pointer-events-auto shadow-sm"
>
<div class="font-semibold text-gray-900 dark:text-zinc-100 mb-1">
{{ metadata.name }}
</div>
<div
v-if="metadata.attribution"
class="max-w-xs overflow-hidden text-ellipsis whitespace-nowrap"
:title="metadata.attribution"
>
{{ metadata.attribution }}
</div>
</div>
<!-- Lat/Lon Box -->
<div
class="bg-white/80 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 p-2 rounded-lg text-[10px] font-mono text-gray-600 dark:text-zinc-400 pointer-events-auto shadow-sm flex flex-col space-y-0.5"
>
<div class="flex justify-between space-x-4">
<span class="opacity-50 uppercase tracking-tighter">Lat</span>
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[1].toFixed(6) }}</span>
</div>
<div class="flex justify-between space-x-4">
<span class="opacity-50 uppercase tracking-tighter">Lon</span>
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[0].toFixed(6) }}</span>
</div>
</div>
</div>
<!-- controls overlay -->
<div
v-if="isSettingsOpen"
class="absolute top-14 right-4 z-20 w-64 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-zinc-100">{{ $t("app.settings") }}</h3>
<button
class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300"
@click="isSettingsOpen = false"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-4 space-y-4">
<div>
<button
class="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-900 dark:text-zinc-100 rounded-lg transition-colors text-sm font-medium"
@click="setAsDefaultView"
>
<MaterialDesignIcon icon-name="pin" class="size-4" />
<span>{{ $t("map.set_as_default") }}</span>
</button>
</div>
<div v-if="!offlineEnabled" class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Preset Servers</label>
<div class="grid grid-cols-1 gap-2 mb-4">
<button
class="px-3 py-2 text-xs font-semibold rounded-lg transition-all border"
:class="
tileServerUrl.includes('openstreetmap.org')
? 'bg-blue-500 border-blue-600 text-white shadow-sm'
: 'bg-white dark:bg-zinc-800 border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700'
"
@click="setTileServer('osm')"
>
{{ $t("map.tile_server_openstreetmap") }}
</button>
</div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{
$t("map.tile_server_url")
}}</label>
<input
v-model="tileServerUrl"
type="text"
class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
:placeholder="$t('map.tile_server_url_placeholder')"
@blur="saveTileServerUrl"
/>
<p class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
{{ $t("map.tile_server_url_hint") }}
</p>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{
$t("map.nominatim_api_url")
}}</label>
<input
v-model="nominatimApiUrl"
type="text"
class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
:placeholder="$t('map.nominatim_api_url_placeholder')"
@blur="saveNominatimApiUrl"
/>
<p class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
{{ $t("map.nominatim_api_url_hint") }}
</p>
</div>
</div>
<div class="flex items-center justify-between py-2 border-t border-gray-100 dark:border-zinc-800">
<span class="text-sm text-gray-700 dark:text-zinc-300">{{ $t("map.caching_enabled") }}</span>
<Toggle :model-value="cachingEnabled" @update:model-value="toggleCaching" />
</div>
<div class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1"
>MBTiles Storage Directory</label
>
<input
v-model="mbtilesDir"
type="text"
class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
placeholder="Default storage directory"
@blur="saveMBTilesDir"
/>
</div>
<div v-if="mbtilesList.length > 0" class="space-y-2">
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Available Maps</label>
<div class="max-h-48 overflow-y-auto space-y-1">
<div
v-for="file in mbtilesList"
:key="file.name"
class="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800"
>
<div class="flex flex-col min-w-0 flex-1 mr-2">
<span
class="text-xs font-medium text-gray-900 dark:text-zinc-100 truncate"
:title="file.name"
>{{ file.name }}</span
>
<span class="text-[10px] text-gray-500"
>{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span
>
</div>
<div class="flex items-center space-x-1">
<button
v-if="!file.is_active"
class="p-1 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Set as active"
@click="setActiveMBTiles(file.name)"
>
<MaterialDesignIcon icon-name="check" class="size-4" />
</button>
<div v-else class="p-1 text-emerald-500" title="Active">
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
</div>
<button
class="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Delete"
@click="deleteMBTiles(file.name)"
>
<MaterialDesignIcon icon-name="delete" class="size-4" />
</button>
</div>
</div>
</div>
</div>
</div>
<button
class="w-full px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-950/20 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded-lg transition-colors text-xs font-bold uppercase tracking-wider"
@click="clearCache"
>
{{ $t("map.clear_cache") }}
</button>
<div
class="text-xs text-gray-500 dark:text-zinc-500 space-y-1 pt-2 border-t border-gray-100 dark:border-zinc-800"
>
<div class="flex justify-between">
<span>{{ $t("map.zoom") }}:</span>
<span class="font-mono">{{ currentZoom.toFixed(1) }}</span>
</div>
<div class="flex justify-between">
<span>Lat:</span>
<span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
</div>
<div class="flex justify-between">
<span>Lon:</span>
<span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
</div>
</div>
</div>
</div>
<!-- onboarding tooltip -->
<div
v-if="showOnboardingTooltip"
class="fixed inset-0 z-[100] pointer-events-none"
@click="dismissOnboardingTooltip"
>
<div class="absolute inset-0 bg-black/50 pointer-events-auto"></div>
<div
ref="tooltipElement"
class="absolute bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 p-4 pointer-events-auto max-w-xs sm:max-w-sm"
:style="tooltipStyle"
>
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold text-gray-900 dark:text-zinc-100 text-sm">
{{ $t("map.onboarding_title") }}
</h3>
<button
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="dismissOnboardingTooltip"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
</div>
<p class="text-sm text-gray-600 dark:text-zinc-400 mb-3">
{{ $t("map.onboarding_text") }}
</p>
<button
class="w-full px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
@click="dismissOnboardingTooltip"
>
{{ $t("map.onboarding_got_it") }}
</button>
</div>
<svg
v-if="arrowPath && !isMobileScreen"
ref="arrowElement"
class="absolute pointer-events-none"
:style="arrowStyle"
:width="arrowSvgWidth"
:height="arrowSvgHeight"
:viewBox="`0 0 ${arrowSvgWidth} ${arrowSvgHeight}`"
>
<path :d="arrowPath" stroke="#3b82f6" stroke-width="3" fill="none" marker-end="url(#arrowhead)" />
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#3b82f6" />
</marker>
</defs>
</svg>
</div>
<!-- floating upload button (mobile only) -->
<button
class="sm:hidden fixed bottom-4 right-4 z-30 p-4 bg-blue-500 hover:bg-blue-600 text-white rounded-full shadow-lg transition-colors"
:title="$t('map.upload_mbtiles')"
@click="$refs.fileInput.click()"
>
<MaterialDesignIcon icon-name="upload" class="size-6" />
</button>
</div>
<!-- save drawing modal -->
<div
v-if="showSaveDrawingModal"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
>
<div
class="bg-white dark:bg-zinc-900 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<MaterialDesignIcon icon-name="content-save-outline" class="size-6 text-blue-500" />
{{ $t("map.save_drawing_title") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $t("map.save_drawing_desc") }}</p>
<div class="mt-6">
<label
class="block text-xs font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-widest mb-2"
>
{{ $t("map.drawing_name") }}
</label>
<input
ref="newDrawingNameInput"
v-model="newDrawingName"
type="text"
class="w-full px-4 py-3 bg-gray-50 dark:bg-zinc-800 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500 transition-all dark:text-white"
:placeholder="$t('map.drawing_name_placeholder')"
@keyup.enter="saveDrawing"
/>
</div>
<div class="mt-8 flex gap-3">
<button
type="button"
class="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
@click="showSaveDrawingModal = false"
>
{{ $t("common.close") }}
</button>
<button
type="button"
class="flex-1 px-4 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-semibold shadow-lg shadow-blue-500/25 hover:bg-blue-500 transition active:scale-95 disabled:opacity-50"
:disabled="!newDrawingName.trim()"
@click="saveDrawing"
>
{{ $t("common.save") }}
</button>
</div>
</div>
</div>
</div>
<!-- load drawing modal -->
<div
v-if="showLoadDrawingModal"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
>
<div
class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<MaterialDesignIcon icon-name="folder-open-outline" class="size-6 text-blue-500" />
{{ $t("map.load_drawing_title") }}
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showLoadDrawingModal = false">
<MaterialDesignIcon icon-name="close" class="size-6" />
</button>
</div>
<div v-if="isLoadingDrawings" class="py-12 flex flex-col items-center justify-center">
<MaterialDesignIcon icon-name="loading" class="size-10 animate-spin text-blue-500 mb-4" />
<span class="text-sm font-medium text-gray-500">{{ $t("map.loading_drawings") }}</span>
</div>
<div
v-else-if="savedDrawings.length === 0"
class="py-12 flex flex-col items-center justify-center text-center"
>
<div
class="size-16 bg-gray-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4"
>
<MaterialDesignIcon icon-name="folder-outline" class="size-8 text-gray-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ $t("map.no_drawings") }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $t("map.no_drawings_desc") }}</p>
</div>
<div v-else class="max-h-[400px] overflow-y-auto space-y-2 pr-2">
<div
v-for="drawing in savedDrawings"
:key="drawing.id"
class="group p-4 bg-gray-50 dark:bg-zinc-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-2xl border border-transparent hover:border-blue-200 dark:hover:border-blue-800 transition-all cursor-pointer flex items-center justify-between"
@click="loadDrawing(drawing)"
>
<div class="flex-1 min-w-0 mr-4">
<div class="font-bold text-gray-900 dark:text-white truncate">{{ drawing.name }}</div>
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-0.5">
{{ $t("map.saved_on") }} {{ new Date(drawing.updated_at).toLocaleString() }}
</div>
</div>
<button
class="p-2 text-gray-400 hover:text-red-500 transition-colors"
:title="$t('common.delete')"
@click.stop="deleteDrawing(drawing)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
</button>
</div>
</div>
<div class="mt-8 flex justify-end">
<button
type="button"
class="px-6 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
@click="showLoadDrawingModal = false"
>
{{ $t("common.close") }}
</button>
</div>
</div>
</div>
</div>
<!-- mobile note modal -->
<transition name="fade">
<div
v-if="showNoteModal"
class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="closeNoteEditor"
>
<div
class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden animate-slide-up sm:animate-fade-in"
>
<div class="p-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<MaterialDesignIcon icon-name="note-edit" class="size-5 text-amber-500" />
Edit Note
</h3>
<button
class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
@click="closeNoteEditor"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-4">
<textarea
v-model="noteText"
class="w-full h-40 p-4 text-base bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none text-gray-900 dark:text-zinc-100"
placeholder="Type your note here..."
autofocus
></textarea>
</div>
<div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
<button
class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
@click="deleteNote"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
Delete
</button>
<button
class="flex-[2] px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
@click="saveNote"
>
Save Note
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import "ol/ol.css";
import Map from "ol/Map";
import View from "ol/View";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
import DragBox from "ol/interaction/DragBox";
import Draw from "ol/interaction/Draw";
import Modify from "ol/interaction/Modify";
import Snap from "ol/interaction/Snap";
import Select from "ol/interaction/Select";
import Translate from "ol/interaction/Translate";
import { getArea, getLength } from "ol/sphere";
import { LineString, Polygon, Circle } from "ol/geom";
import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
import Toggle from "../forms/Toggle.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
export default {
name: "MapPage",
components: {
MaterialDesignIcon,
Toggle,
},
data() {
return {
map: null,
offlineEnabled: false,
hasOfflineMap: false,
metadata: null,
isUploading: false,
isSettingsOpen: false,
currentCenter: [0, 0],
currentZoom: 2,
cursorCoords: null,
config: null,
peers: {},
// telemetry
telemetryList: [],
markerSource: null,
markerLayer: null,
selectedMarker: null,
// caching
cachingEnabled: true,
// tile server
tileServerUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
// search
searchQuery: "",
searchResults: [],
isSearching: false,
isSearchFocused: false,
searchError: null,
nominatimApiUrl: "https://nominatim.openstreetmap.org",
searchTimeout: null,
// export mode
isExportMode: false,
dragBox: null,
selectedBbox: null,
exportMinZoom: 0,
exportMaxZoom: 10,
isExporting: false,
exportId: null,
exportStatus: null,
exportInterval: null,
// onboarding
showOnboardingTooltip: false,
tooltipStyle: {},
arrowStyle: {},
arrowPath: null,
arrowSvgWidth: 200,
arrowSvgHeight: 200,
isMobileScreen: false,
// MBTiles management
mbtilesList: [],
mbtilesDir: "",
isMapLoaded: false,
// drawing tools
draw: null,
modify: null,
snap: null,
drawSource: null,
drawLayer: null,
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
isDrawing: false,
drawingTools: [
{ type: "Select", icon: "cursor-default" },
{ type: "Point", icon: "map-marker-plus" },
{ type: "LineString", icon: "vector-line" },
{ type: "Polygon", icon: "vector-polygon" },
{ type: "Circle", icon: "circle-outline" },
{ type: "Export", icon: "crop-free" },
],
// measurement
isMeasuring: false,
sketch: null,
helpTooltipElement: null,
helpTooltip: null,
measureTooltipElement: null,
measureTooltip: null,
measurementOverlays: [],
// drawing storage
savedDrawings: [],
// note editing
editingFeature: null,
noteText: "",
hoveredFeature: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
newDrawingName: "",
isLoadingDrawings: false,
showLoadDrawingModal: false,
styleCache: {},
selectedFeature: null,
select: null,
translate: null,
// context menu
showContextMenu: false,
contextMenuPos: { x: 0, y: 0 },
contextMenuFeature: null,
contextMenuCoord: null,
};
},
computed: {
estimatedTiles() {
if (!this.selectedBbox) return 0;
const [minLon, minLat, maxLon, maxLat] = this.selectedBbox;
let total = 0;
for (let z = this.exportMinZoom; z <= this.exportMaxZoom; z++) {
const x1 = this.lonToTile(minLon, z);
const x2 = this.lonToTile(maxLon, z);
const y1 = this.latToTile(maxLat, z);
const y2 = this.latToTile(minLat, z);
total += (Math.abs(x2 - x1) + 1) * (Math.abs(y2 - y1) + 1);
}
return total;
},
displayCoords() {
return this.cursorCoords || this.currentCenter;
},
},
watch: {
showSaveDrawingModal(val) {
if (val) {
this.$nextTick(() => {
this.$refs.newDrawingNameInput?.focus();
});
}
},
},
async mounted() {
await this.getConfig();
// Load persisted map state
try {
const savedState = await TileCache.getMapState("last_view");
if (savedState) {
this.currentCenter = savedState.center || [0, 0];
this.currentZoom = savedState.zoom || 2;
if (savedState.offlineEnabled !== undefined) this.offlineEnabled = savedState.offlineEnabled;
if (savedState.tileServerUrl) this.tileServerUrl = savedState.tileServerUrl;
if (savedState.telemetry) this.telemetryList = savedState.telemetry;
// Temporarily store drawings to restore after map/source init
this._persistedDrawings = savedState.drawings;
}
} catch (e) {
console.warn("Failed to load map state from cache", e);
}
this.initMap();
if (this.telemetryList.length > 0) {
this.updateMarkers();
}
// Restore drawings if any
if (this._persistedDrawings && this.drawSource) {
try {
const format = new GeoJSON();
const features = format.readFeatures(this._persistedDrawings, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
console.log("Restoring persisted drawings, count:", features.length);
this.drawSource.addFeatures(features);
this.rebuildMeasurementOverlays();
} catch (e) {
console.error("Failed to restore persisted drawings", e);
}
delete this._persistedDrawings;
}
await this.checkOfflineMap();
await this.loadMBTilesList();
await this.fetchPeers();
await this.fetchTelemetryMarkers();
// Listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
// Check for query params to center map (overrides saved state)
if (this.$route.query.lat && this.$route.query.lon) {
const lat = parseFloat(this.$route.query.lat);
const lon = parseFloat(this.$route.query.lon);
const zoom = parseInt(this.$route.query.zoom || 15);
if (!isNaN(lat) && !isNaN(lon)) {
this.map.getView().setCenter(fromLonLat([lon, lat]));
this.map.getView().setZoom(zoom);
}
}
// Listen for moveend to update coordinates in UI and save state
if (this.map) {
this.map.on("moveend", () => {
const view = this.map.getView();
this.currentCenter = toLonLat(view.getCenter());
this.currentZoom = view.getZoom();
this.saveMapState();
});
}
// Check if onboarding tooltip should be shown
this.checkOnboardingTooltip();
// Add click outside handler for search
document.addEventListener("click", this.handleClickOutside);
// Check screen size for mobile
this.checkScreenSize();
window.addEventListener("resize", this.checkScreenSize);
// Update info every few seconds
this.reloadInterval = setInterval(() => {
this.fetchTelemetryMarkers();
}, 30000);
},
beforeUnmount() {
if (this.map && this.map.getViewport()) {
this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu);
}
document.removeEventListener("click", this.handleGlobalClick);
if (this._saveStateTimer) {
clearTimeout(this._saveStateTimer);
this._saveStateTimer = null;
}
if (this._pendingSaveResolvers && this._pendingSaveResolvers.length > 0) {
const pending = this._pendingSaveResolvers.slice();
this._pendingSaveResolvers = [];
this.saveMapStateImmediate().then(() => pending.forEach((p) => p.resolve()));
}
if (this.reloadInterval) clearInterval(this.reloadInterval);
if (this.exportInterval) clearInterval(this.exportInterval);
if (this.searchTimeout) clearTimeout(this.searchTimeout);
document.removeEventListener("click", this.handleClickOutside);
window.removeEventListener("resize", this.checkScreenSize);
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
saveMapState() {
if (!this._pendingSaveResolvers) {
this._pendingSaveResolvers = [];
}
return new Promise((resolve, reject) => {
this._pendingSaveResolvers.push({ resolve, reject });
if (this._saveStateTimer) clearTimeout(this._saveStateTimer);
this._saveStateTimer = setTimeout(async () => {
const pending = this._pendingSaveResolvers.slice();
this._pendingSaveResolvers = [];
this._saveStateTimer = null;
try {
await this.saveMapStateImmediate();
pending.forEach((p) => p.resolve());
} catch (e) {
pending.forEach((p) => p.reject(e));
}
}, 150);
});
},
async saveMapStateImmediate() {
try {
let drawings = null;
if (this.drawSource) {
const format = new GeoJSON();
const features = this.serializeFeatures(this.drawSource.getFeatures());
drawings = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
}
const state = JSON.parse(
JSON.stringify({
center: this.currentCenter,
zoom: this.currentZoom,
offlineEnabled: this.offlineEnabled,
tileServerUrl: this.tileServerUrl,
drawings: drawings,
telemetry: this.telemetryList,
})
);
await TileCache.setMapState("last_view", state);
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
} catch (e) {
console.error("Failed to save map state", e);
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
this.offlineEnabled = this.config.map_offline_enabled;
this.cachingEnabled =
this.config.map_tile_cache_enabled !== undefined ? this.config.map_tile_cache_enabled : true;
this.mbtilesDir = this.config.map_mbtiles_dir || "";
if (this.config.map_tile_server_url) {
this.tileServerUrl = this.config.map_tile_server_url;
}
if (this.config.map_nominatim_api_url) {
this.nominatimApiUrl = this.config.map_nominatim_api_url;
}
} catch (e) {
console.error("Failed to load config", e);
}
},
async loadMBTilesList() {
try {
const response = await window.axios.get("/api/v1/map/mbtiles");
this.mbtilesList = response.data;
} catch (e) {
console.error("Failed to load MBTiles list", e);
}
},
async setActiveMBTiles(filename) {
try {
await window.axios.post("/api/v1/map/mbtiles/active", { filename });
await this.checkOfflineMap();
await this.loadMBTilesList();
ToastUtils.success("Map source updated");
} catch {
ToastUtils.error("Failed to set active map");
}
},
async deleteMBTiles(filename) {
if (!confirm(`Are you sure you want to delete ${filename}?`)) return;
try {
await window.axios.delete(`/api/v1/map/mbtiles/${filename}`);
await this.loadMBTilesList();
if (this.metadata && this.metadata.path && this.metadata.path.endsWith(filename)) {
await this.checkOfflineMap();
}
ToastUtils.success("File deleted");
} catch {
ToastUtils.error("Failed to delete file");
}
},
async saveMBTilesDir() {
try {
await window.axios.patch("/api/v1/config", {
map_mbtiles_dir: this.mbtilesDir,
});
ToastUtils.success("Storage directory saved");
this.loadMBTilesList();
} catch {
ToastUtils.error("Failed to save directory");
}
},
initMap() {
// Patch canvas getContext to address performance warning
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attributes) {
if (type === "2d") {
attributes = attributes || {};
attributes.willReadFrequently = true;
}
return originalGetContext.call(this, type, attributes);
};
const defaultLat = parseFloat(this.config?.map_default_lat || 0);
const defaultLon = parseFloat(this.config?.map_default_lon || 0);
const defaultZoom = parseInt(this.config?.map_default_zoom || 2);
// Use saved state if available, otherwise use defaults
const startCenter =
this.currentCenter[0] !== 0 || this.currentCenter[1] !== 0
? fromLonLat(this.currentCenter)
: fromLonLat([defaultLon, defaultLat]);
const startZoom = this.currentZoom !== 2 ? this.currentZoom : defaultZoom;
this.map = new Map({
target: this.$refs.mapContainer,
layers: [
new TileLayer({
source: this.getTileSource(),
}),
],
view: new View({
center: startCenter,
zoom: startZoom,
}),
controls: defaultControls({
attribution: false,
rotate: false,
}),
});
// setup drawing layer
this.drawSource = new VectorSource();
this.drawLayer = new VectorLayer({
source: this.drawSource,
style: (feature) => {
const type = feature.get("type");
const geometry = feature.getGeometry();
const geomType = geometry ? geometry.getType() : null;
if (type === "note" || geomType === "Point") {
const isNote = type === "note";
return this.createMarkerStyle({
iconColor: isNote ? "#f59e0b" : "#3b82f6",
bgColor: "#ffffff",
label: isNote && feature.get("note") ? "Note" : "",
isStale: false,
iconPath: isNote
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
: null,
});
}
return new Style({
fill: new Fill({
color: "rgba(59, 130, 246, 0.2)",
}),
stroke: new Stroke({
color: "#3b82f6",
width: 3,
}),
image: new CircleStyle({
radius: 7,
fill: new Fill({
color: "#3b82f6",
}),
}),
});
},
zIndex: 50,
});
this.map.addLayer(this.drawLayer);
this.attachDrawPersistence();
this.noteOverlay = new Overlay({
element: this.$refs.noteOverlayElement,
autoPan: {
animation: {
duration: 250,
},
},
});
this.map.addOverlay(this.noteOverlay);
this.modify = new Modify({ source: this.drawSource });
this.modify.on("modifystart", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.clearMeasurementOverlay(f));
});
this.modify.on("modifyend", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
this.saveMapState();
});
this.map.addInteraction(this.modify);
this.select = new Select({
layers: [this.drawLayer],
hitTolerance: 15, // High tolerance for touch/offgrid
style: null, // Keep original feature style
});
this.select.on("select", (e) => {
this.selectedFeature = e.selected[0] || null;
});
this.map.addInteraction(this.select);
this.translate = new Translate({
features: this.select.getFeatures(),
layers: [this.drawLayer], // Only move drawing layer items, not telemetry
});
this.translate.on("translateend", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
this.saveMapState();
});
this.map.addInteraction(this.translate);
// Default to Select tool
this.drawType = "Select";
this.select.setActive(true);
this.translate.setActive(true);
this.modify.setActive(true);
this.snap = new Snap({ source: this.drawSource });
this.map.addInteraction(this.snap);
// Right-click context menu
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
source: this.markerSource,
style: (feature) => {
const t = feature.get("telemetry");
const peer = feature.get("peer");
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
// Calculate staleness
const now = Date.now();
const updatedAt = t.updated_at
? new Date(t.updated_at).getTime()
: t.timestamp
? t.timestamp * 1000
: now;
const isStale = now - updatedAt > 10 * 60 * 1000;
let iconColor = "#2563eb";
let bgColor = "#ffffff";
if (peer?.lxmf_user_icon) {
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
}
return this.createMarkerStyle({
iconColor,
bgColor,
label: displayName,
isStale,
});
},
zIndex: 100,
});
this.map.addLayer(this.markerLayer);
this.map.on("pointermove", this.handleMapPointerMove);
this.map.on("click", (evt) => {
this.handleMapClick(evt);
this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
if (feature && feature.get("telemetry")) {
this.onMarkerClick(feature);
} else {
this.selectedMarker = null;
}
// Deselect drawing if clicking empty space
if (!feature && this.select) {
this.select.getFeatures().clear();
this.selectedFeature = null;
}
});
this.currentCenter = [defaultLon, defaultLat];
this.currentZoom = defaultZoom;
// Setup dragBox for export
this.dragBox = new DragBox({
condition: () => this.isExportMode,
});
this.dragBox.on("boxend", () => {
const extent = this.dragBox.getGeometry().getExtent();
const min = toLonLat([extent[0], extent[1]]);
const max = toLonLat([extent[2], extent[3]]);
this.selectedBbox = [min[0], min[1], max[0], max[1]];
this.exportMinZoom = Math.floor(this.map.getView().getZoom());
this.exportMaxZoom = Math.min(this.exportMinZoom + 3, 18);
});
this.map.addInteraction(this.dragBox);
this.isMapLoaded = true;
// Close context menu when clicking elsewhere
document.addEventListener("click", this.handleGlobalClick);
},
isLocalUrl(url) {
if (!url) return false;
try {
const urlObj = new URL(url, window.location.origin);
return (
urlObj.hostname === "localhost" ||
urlObj.hostname === "127.0.0.1" ||
urlObj.hostname === "::1" ||
urlObj.hostname.startsWith("192.168.") ||
urlObj.hostname.startsWith("10.") ||
urlObj.hostname.startsWith("172.") ||
urlObj.hostname.endsWith(".local") ||
url.startsWith("/")
);
} catch {
return url.startsWith("/") || url.startsWith("./") || !url.startsWith("http");
}
},
isDefaultOnlineUrl(url, type) {
if (!url) return false;
if (type === "tile") {
return url.includes("tile.openstreetmap.org") || url.includes("openstreetmap.org");
} else if (type === "nominatim") {
return url.includes("nominatim.openstreetmap.org") || url.includes("openstreetmap.org");
}
return false;
},
async checkApiConnection(url) {
if (!url || this.isLocalUrl(url)) {
return true;
}
try {
let testUrl = url.endsWith("/") ? url.slice(0, -1) : url;
if (testUrl.includes("{z}") || testUrl.includes("{x}") || testUrl.includes("{y}")) {
testUrl = testUrl.replace("{z}", "0").replace("{x}", "0").replace("{y}", "0");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(testUrl, {
method: "HEAD",
signal: controller.signal,
headers: {
"User-Agent": "ReticulumMeshChatX/1.0",
},
});
clearTimeout(timeoutId);
return response.ok || response.status === 405 || response.status === 404;
} catch {
return false;
}
},
getTileSource() {
const isOffline = this.offlineEnabled;
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl, "tile");
let tileUrl;
if (isOffline) {
if (isCustomLocal || (!isDefaultOnline && customTileUrl !== defaultTileUrl)) {
tileUrl = customTileUrl;
} else {
tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
}
} else {
tileUrl = customTileUrl;
}
const source = new XYZ({
url: tileUrl,
crossOrigin: "anonymous",
});
const originalTileLoadFunction = source.getTileLoadFunction();
if (isOffline) {
source.setTileLoadFunction(async (tile, src) => {
try {
const response = await fetch(src);
if (!response.ok) {
if (response.status === 404) {
tile.setState(3);
return;
}
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
tile.getImage().src = url;
// Cleanup to prevent memory leaks
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch {
tile.setState(3);
}
});
} else {
source.setTileLoadFunction(async (tile, src) => {
if (!this.cachingEnabled) {
originalTileLoadFunction(tile, src);
return;
}
try {
const cached = await TileCache.getTile(src);
if (cached) {
const url = URL.createObjectURL(cached);
tile.getImage().src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);
return;
}
const response = await fetch(src);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
tile.getImage().src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);
// Background cache write to avoid blocking UI
TileCache.setTile(src, blob).catch(() => {});
} catch {
originalTileLoadFunction(tile, src);
}
});
}
return source;
},
async checkOfflineMap() {
try {
const response = await window.axios.get("/api/v1/map/offline");
if (response.data && response.data.loaded !== false && Object.keys(response.data).length > 0) {
this.metadata = response.data;
this.hasOfflineMap = true;
if (this.offlineEnabled) {
this.updateMapSource();
}
} else {
this.hasOfflineMap = false;
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
this.updateMapSource();
}
}
} catch {
this.hasOfflineMap = false;
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
this.updateMapSource();
}
}
},
updateMapSource() {
if (!this.map) return;
const layers = this.map.getLayers();
// Find and replace the tile layer (first layer usually)
// or just clear and re-add everything correctly
layers.clear();
// 1. Tile layer
layers.push(
new TileLayer({
source: this.getTileSource(),
})
);
// 2. Draw layer
if (this.drawLayer) {
layers.push(this.drawLayer);
}
// 3. Marker layer
if (this.markerLayer) {
layers.push(this.markerLayer);
}
},
async toggleOffline(enabled) {
if (enabled && !this.hasOfflineMap) {
ToastUtils.error(this.$t("map.no_map_loaded"));
return;
}
if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl, "tile");
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl;
if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) {
const isAccessible = await this.checkApiConnection(this.tileServerUrl);
if (!isAccessible) {
ToastUtils.error(this.$t("map.custom_tile_server_unavailable"));
return;
}
}
if (hasCustomNominatim && !isCustomNominatimLocal && !isDefaultNominatimOnline) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);
if (!isAccessible) {
ToastUtils.error(this.$t("map.custom_nominatim_unavailable"));
return;
}
}
}
this.offlineEnabled = enabled;
if (enabled) {
this.isExportMode = false;
this.clearSearch();
}
this.updateMapSource();
await this.saveMapState();
// Persist setting
try {
await window.axios.patch("/api/v1/config", {
map_offline_enabled: enabled,
});
} catch (e) {
console.error("Failed to save offline setting", e);
}
},
async toggleCaching(enabled) {
this.cachingEnabled = enabled;
try {
await window.axios.patch("/api/v1/config", {
map_tile_cache_enabled: enabled,
});
} catch (e) {
console.error("Failed to save caching setting", e);
}
},
toggleExportMode() {
this.isExportMode = !this.isExportMode;
if (!this.isExportMode) {
this.selectedBbox = null;
}
},
cancelExport() {
this.selectedBbox = null;
this.isExportMode = false;
},
async cancelActiveExport() {
if (!this.exportId) {
this.exportStatus = null;
return;
}
try {
await window.axios.delete(`/api/v1/map/export/${this.exportId}`);
this.exportStatus = null;
this.exportId = null;
ToastUtils.success("Export cancelled");
} catch {
ToastUtils.error("Failed to cancel export");
}
},
async startExport() {
if (!this.selectedBbox) return;
this.isExporting = true;
try {
const response = await window.axios.post("/api/v1/map/export", {
bbox: this.selectedBbox,
min_zoom: this.exportMinZoom,
max_zoom: this.exportMaxZoom,
name: `Map Export ${new Date().toLocaleString()}`,
});
this.exportId = response.data.export_id;
this.isExportMode = false;
this.selectedBbox = null;
this.pollExportStatus();
} catch {
ToastUtils.error("Failed to start export");
this.isExporting = false;
}
},
pollExportStatus() {
if (this.exportInterval) clearInterval(this.exportInterval);
this.exportInterval = setInterval(async () => {
try {
const response = await window.axios.get(`/api/v1/map/export/${this.exportId}`);
this.exportStatus = response.data;
if (this.exportStatus.status === "completed" || this.exportStatus.status === "failed") {
clearInterval(this.exportInterval);
this.isExporting = false;
}
} catch {
clearInterval(this.exportInterval);
this.isExporting = false;
}
}, 2000);
},
lonToTile(lon, zoom) {
return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
},
latToTile(lat, zoom) {
return Math.floor(
((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) *
Math.pow(2, zoom)
);
},
async onFileSelected(event) {
const file = event.target.files[0];
if (!file) return;
if (!file.name.endsWith(".mbtiles")) {
ToastUtils.error("Please select an .mbtiles file");
return;
}
this.isUploading = true;
const formData = new FormData();
formData.append("file", file);
try {
const response = await window.axios.post("/api/v1/map/offline", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
this.metadata = response.data.metadata;
this.hasOfflineMap = true;
this.offlineEnabled = true;
await this.loadMBTilesList();
await this.checkOfflineMap();
this.updateMapSource();
ToastUtils.success(this.$t("map.upload_success"));
// If the map has bounds, we might want to fit to them
if (this.metadata.bounds) {
const bounds = this.metadata.bounds.split(",").map(parseFloat);
if (bounds.length === 4) {
const extent = [...fromLonLat([bounds[0], bounds[1]]), ...fromLonLat([bounds[2], bounds[3]])];
this.map.getView().fit(extent, { padding: [20, 20, 20, 20] });
}
}
} catch (e) {
const error = e.response?.data?.error || e.message;
ToastUtils.error(this.$t("map.upload_failed") + ": " + error);
} finally {
this.isUploading = false;
event.target.value = ""; // Reset input
}
},
async setAsDefaultView() {
if (!this.map) return;
const view = this.map.getView();
const center = toLonLat(view.getCenter());
const zoom = Math.round(view.getZoom());
try {
await window.axios.patch("/api/v1/config", {
map_default_lat: center[1],
map_default_lon: center[0],
map_default_zoom: zoom,
});
ToastUtils.success("Default view saved");
} catch {
ToastUtils.error("Failed to save default view");
}
},
async clearCache() {
try {
await TileCache.clear();
ToastUtils.success(this.$t("map.cache_cleared"));
} catch {
ToastUtils.error("Failed to clear cache");
}
},
async saveTileServerUrl() {
try {
await window.axios.patch("/api/v1/config", {
map_tile_server_url: this.tileServerUrl,
});
this.updateMapSource();
ToastUtils.success(this.$t("map.tile_server_saved"));
await this.saveMapState();
} catch {
ToastUtils.error("Failed to save tile server URL");
}
},
setTileServer(type) {
if (type === "osm") {
this.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
}
this.saveTileServerUrl();
},
async saveNominatimApiUrl() {
try {
await window.axios.patch("/api/v1/config", {
map_nominatim_api_url: this.nominatimApiUrl,
});
ToastUtils.success(this.$t("map.nominatim_api_saved"));
} catch {
ToastUtils.error("Failed to save Nominatim API URL");
}
},
checkOnboardingTooltip() {
const hasSeenOnboarding = localStorage.getItem("map_onboarding_seen");
if (!hasSeenOnboarding && !this.offlineEnabled) {
this.$nextTick(() => {
this.showOnboardingTooltip = true;
this.positionOnboardingTooltip();
});
}
},
positionOnboardingTooltip() {
this.$nextTick(() => {
if (!this.$refs.exportButton || !this.$refs.tooltipElement) return;
const exportButton = this.$refs.exportButton;
const tooltip = this.$refs.tooltipElement;
const buttonRect = exportButton.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const isMobile = window.innerWidth < 640;
let tooltipLeft, tooltipTop;
let tooltipAboveButton = false;
if (isMobile) {
tooltipLeft = window.innerWidth / 2 - tooltipRect.width / 2;
tooltipTop = buttonRect.top - tooltipRect.height - 20;
tooltipAboveButton = true;
if (tooltipTop < 10) {
tooltipTop = buttonRect.bottom + 20;
tooltipAboveButton = false;
}
} else {
tooltipLeft = buttonRect.left - tooltipRect.width - 20;
tooltipTop = buttonRect.top + buttonRect.height / 2 - tooltipRect.height / 2;
}
if (tooltipTop < 10) tooltipTop = 10;
if (tooltipLeft < 10) tooltipLeft = 10;
if (tooltipLeft + tooltipRect.width > window.innerWidth - 10) {
tooltipLeft = window.innerWidth - tooltipRect.width - 10;
}
this.tooltipStyle = {
left: `${tooltipLeft}px`,
top: `${tooltipTop}px`,
};
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
const tooltipCenterX = tooltipLeft + tooltipRect.width / 2;
const tooltipCenterY = tooltipTop + tooltipRect.height / 2;
const arrowStartX = isMobile ? tooltipCenterX : tooltipLeft + tooltipRect.width;
const arrowStartY = isMobile
? tooltipAboveButton
? tooltipTop + tooltipRect.height
: tooltipTop
: tooltipCenterY;
const arrowEndX = buttonRect.left + buttonRect.width * 0.25;
const arrowEndY = buttonCenterY;
const minX = Math.min(arrowStartX, arrowEndX) - 20;
const maxX = Math.max(arrowStartX, arrowEndX) + 20;
const minY = Math.min(arrowStartY, arrowEndY) - 20;
const maxY = Math.max(arrowStartY, arrowEndY) + 20;
this.arrowSvgWidth = maxX - minX;
this.arrowSvgHeight = maxY - minY;
const adjustedStartX = arrowStartX - minX;
const adjustedStartY = arrowStartY - minY;
const adjustedEndX = arrowEndX - minX;
const adjustedEndY = arrowEndY - minY;
const controlX1 = adjustedStartX + (adjustedEndX - adjustedStartX) * 0.5;
const controlY1 = adjustedStartY + (adjustedEndY - adjustedStartY) * 0.3;
const controlX2 = adjustedStartX + (adjustedEndX - adjustedStartX) * 0.7;
const controlY2 = adjustedStartY + (adjustedEndY - adjustedStartY) * 0.7;
this.arrowPath = `M ${adjustedStartX} ${adjustedStartY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${adjustedEndX} ${adjustedEndY}`;
this.arrowStyle = {
left: `${minX}px`,
top: `${minY}px`,
};
});
},
dismissOnboardingTooltip() {
this.showOnboardingTooltip = false;
localStorage.setItem("map_onboarding_seen", "true");
},
onSearchInput() {
this.searchError = null;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
},
async performSearch() {
if (!this.searchQuery || this.isSearching) return;
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
if (this.offlineEnabled) {
if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);
if (!isAccessible) {
this.searchError = this.$t("map.search_offline_error");
return;
}
} else {
this.searchError = this.$t("map.search_offline_error");
return;
}
}
this.isSearching = true;
this.searchError = null;
this.searchResults = [];
try {
const apiUrl = this.nominatimApiUrl.endsWith("/")
? this.nominatimApiUrl.slice(0, -1)
: this.nominatimApiUrl;
const url = `${apiUrl}/search?format=json&q=${encodeURIComponent(this.searchQuery)}&limit=10&addressdetails=1`;
const response = await fetch(url, {
headers: {
"User-Agent": "ReticulumMeshChatX/1.0",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (Array.isArray(data) && data.length > 0) {
this.searchResults = data.map((item) => ({
display_name: item.display_name,
lat: parseFloat(item.lat),
lon: parseFloat(item.lon),
type: item.type || item.class || "",
boundingbox: item.boundingbox,
}));
} else {
this.searchError = this.$t("map.search_no_results");
}
} catch (e) {
console.error("Search error:", e);
if (e.message.includes("Failed to fetch") || e.message.includes("NetworkError")) {
this.searchError = this.$t("map.search_connection_error");
} else {
this.searchError = this.$t("map.search_error") + ": " + e.message;
}
} finally {
this.isSearching = false;
}
},
selectSearchResult(result) {
if (!this.map) return;
const view = this.map.getView();
const center = fromLonLat([result.lon, result.lat]);
if (result.boundingbox && result.boundingbox.length === 4) {
const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(parseFloat);
const extent = [...fromLonLat([minLon, minLat]), ...fromLonLat([maxLon, maxLat])];
view.fit(extent, {
padding: [50, 50, 50, 50],
duration: 500,
});
} else {
view.animate({
center: center,
zoom: Math.max(view.getZoom(), 15),
duration: 500,
});
}
this.clearSearch();
},
clearSearch() {
this.searchQuery = "";
this.searchResults = [];
this.searchError = null;
this.isSearchFocused = false;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
},
handleClickOutside(event) {
if (this.$refs.searchContainer && !this.$refs.searchContainer.contains(event.target)) {
this.isSearchFocused = false;
}
},
checkScreenSize() {
this.isMobileScreen = window.innerWidth < 640;
},
async fetchPeers() {
if (!window.axios) return;
try {
const response = await window.axios.get("/api/v1/lxmf/conversations");
const peers = {};
for (const conv of response.data.conversations) {
peers[conv.destination_hash] = conv;
}
this.peers = peers;
} catch (e) {
console.error("Failed to fetch peers", e);
}
},
attachDrawPersistence() {
if (!this.drawSource) return;
const persist = () => this.saveMapState();
this.drawSource.on("addfeature", persist);
this.drawSource.on("removefeature", persist);
this.drawSource.on("changefeature", persist);
this.drawSource.on("clear", persist);
},
deleteSelectedFeature() {
if (this.selectedFeature && this.drawSource) {
this.clearMeasurementOverlay(this.selectedFeature);
this.drawSource.removeFeature(this.selectedFeature);
if (this.select) this.select.getFeatures().clear();
this.selectedFeature = null;
this.saveMapState();
}
},
// Drawing methods
toggleDraw(type) {
if (!this.map) return;
if (this.drawType === type && !this.isDrawing) {
this.stopDrawing();
return;
}
this.stopDrawing();
this.isMeasuring = false;
this.drawType = type;
if (type === "Select") {
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
return;
}
// Disable selection/translation while drawing
if (this.select) this.select.setActive(false);
if (this.translate) this.translate.setActive(false);
if (this.modify) this.modify.setActive(false);
this.draw = new Draw({
source: this.drawSource,
type: type,
});
this.draw.on("drawstart", (evt) => {
this.isDrawing = true;
this.sketch = evt.feature;
// For LineString, Polygon, and Circle, show measure tooltip while drawing
if (type === "LineString" || type === "Polygon" || type === "Circle") {
this.createMeasureTooltip();
this._drawListener = this.sketch.getGeometry().on("change", (e) => {
const geom = e.target;
let output;
let tooltipCoord;
if (geom instanceof Polygon) {
output = this.formatArea(geom);
tooltipCoord = geom.getInteriorPoint().getCoordinates();
} else if (geom instanceof LineString) {
output = this.formatLength(geom);
tooltipCoord = geom.getLastCoordinate();
} else if (geom instanceof Circle) {
const radius = geom.getRadius();
const center = geom.getCenter();
// Calculate radius distance in projection (sphere-aware)
const edge = [center[0] + radius, center[1]];
const line = new LineString([center, edge]);
output = `Radius: ${this.formatLength(line)}`;
tooltipCoord = edge;
}
if (output) {
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(tooltipCoord);
}
});
}
});
this.draw.on("drawend", (evt) => {
this.isDrawing = false;
const feature = evt.feature;
feature.set("type", "draw"); // Tag as custom drawing for styling
// Clean up sketch listener and tooltips unless it was the Measure tool
if (this._drawListener) {
unByKey(this._drawListener);
this._drawListener = null;
}
this.sketch = null;
// Finalize measurement overlay for the drawn feature
this.finalizeMeasurementOverlay(feature);
this.cleanupMeasureTooltip();
// Re-enable select/translate/modify after drawing
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
this.drawType = "Select";
setTimeout(() => this.saveMapState(), 100);
});
this.map.addInteraction(this.draw);
},
startEditingNote(feature) {
this.editingFeature = feature;
const telemetry = feature.get("telemetry");
this.noteText = telemetry ? telemetry.note || "" : feature.get("note") || "";
if (this.isMobileScreen) {
this.showNoteModal = true;
} else {
this.updateNoteOverlay();
}
},
updateNoteOverlay() {
if (!this.editingFeature || !this.map) return;
const geometry = this.editingFeature.getGeometry();
let coord;
if (geometry instanceof Point) {
coord = geometry.getCoordinates();
} else if (geometry instanceof LineString) {
coord = geometry.getCoordinateAt(0.5); // Middle of line
} else if (geometry instanceof Polygon) {
coord = geometry.getInteriorPoint().getCoordinates();
} else if (geometry instanceof Circle) {
coord = geometry.getCenter();
} else {
coord = this.map.getView().getCenter();
}
this.noteOverlay.setPosition(coord);
},
saveNote() {
if (this.editingFeature) {
const telemetry = this.editingFeature.get("telemetry");
if (telemetry) {
telemetry.note = this.noteText;
} else {
this.editingFeature.set("note", this.noteText);
}
this.saveMapState();
}
this.closeNoteEditor();
},
cancelNote() {
// If it's a new note (no text and just added), we might want to remove it
// but for now just close
this.closeNoteEditor();
},
closeNoteEditor() {
this.editingFeature = null;
this.noteText = "";
this.showNoteModal = false;
if (this.noteOverlay) {
this.noteOverlay.setPosition(undefined);
}
},
deleteNote() {
if (this.editingFeature) {
this.drawSource.removeFeature(this.editingFeature);
this.saveMapState();
}
this.closeNoteEditor();
},
// Measurement helpers
cleanupMeasureTooltip() {
if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
}
if (this.measureTooltip) {
this.map.removeOverlay(this.measureTooltip);
}
this.measureTooltipElement = null;
this.measureTooltip = null;
},
getMeasurementForGeometry(geom) {
if (geom instanceof Polygon) {
return {
text: this.formatArea(geom),
coord: geom.getInteriorPoint().getCoordinates(),
};
}
if (geom instanceof LineString) {
return {
text: this.formatLength(geom),
coord: geom.getLastCoordinate(),
};
}
if (geom instanceof Circle) {
const center = geom.getCenter();
const edge = [center[0] + geom.getRadius(), center[1]];
const line = new LineString([center, edge]);
return {
text: `Radius: ${this.formatLength(line)}`,
coord: edge,
};
}
return null;
},
clearMeasurementOverlay(feature) {
const overlay = feature.get("_measureOverlay");
if (overlay) {
this.map.removeOverlay(overlay);
feature.unset("_measureOverlay", true);
}
},
finalizeMeasurementOverlay(feature) {
if (!this.map) return;
this.clearMeasurementOverlay(feature);
const geom = feature.getGeometry();
const measurement = this.getMeasurementForGeometry(geom);
if (!measurement) return;
const el = document.createElement("div");
el.className = "ol-tooltip ol-tooltip-static";
el.innerHTML = measurement.text;
const overlay = new Overlay({
element: el,
offset: [0, -7],
positioning: "bottom-center",
});
overlay.set("isMeasureTooltip", true);
this.map.addOverlay(overlay);
overlay.setPosition(measurement.coord);
feature.set("_measureOverlay", overlay);
},
rebuildMeasurementOverlays() {
if (!this.drawSource || !this.map) return;
// Remove all existing measure overlays
const overlays = this.map.getOverlays().getArray();
for (let i = overlays.length - 1; i >= 0; i--) {
const ov = overlays[i];
if (ov.get && ov.get("isMeasureTooltip")) {
this.map.removeOverlay(ov);
}
}
// Rebuild for all features
this.drawSource.getFeatures().forEach((f) => {
f.unset("_measureOverlay", true);
this.finalizeMeasurementOverlay(f);
});
},
serializeFeatures(features) {
return features.map((f) => {
const clone = f.clone();
clone.unset("_measureOverlay", true); // avoid circular refs
const geom = clone.getGeometry();
if (geom instanceof Circle) {
clone.setGeometry(fromCircle(geom, 128));
}
return clone;
});
},
// Context menu handlers
onContextMenu(evt) {
if (!this.map) return;
evt.preventDefault();
const pixel = this.map.getEventPixel(evt);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
this.contextMenuFeature = feature || null;
this.contextMenuCoord = toLonLat(this.map.getCoordinateFromPixel(pixel));
this.contextMenuPos = { x: evt.clientX, y: evt.clientY };
if (feature && this.select) {
this.select.getFeatures().clear();
this.select.getFeatures().push(feature);
this.selectedFeature = feature;
}
this.showContextMenu = true;
},
closeContextMenu() {
this.showContextMenu = false;
},
contextSelectFeature() {
if (!this.contextMenuFeature || !this.select || !this.translate) {
this.closeContextMenu();
return;
}
this.select.setActive(true);
this.translate.setActive(true);
this.modify?.setActive(true);
this.select.getFeatures().clear();
this.select.getFeatures().push(this.contextMenuFeature);
this.selectedFeature = this.contextMenuFeature;
this.drawType = "Select";
this.closeContextMenu();
},
contextDeleteFeature() {
if (this.contextMenuFeature && !this.contextMenuFeature.get("telemetry")) {
this.drawSource.removeFeature(this.contextMenuFeature);
this.saveMapState();
}
this.closeContextMenu();
},
contextAddNote() {
if (this.contextMenuFeature) {
this.startEditingNote(this.contextMenuFeature);
}
this.closeContextMenu();
},
async contextCopyCoords() {
if (!this.contextMenuCoord) {
this.closeContextMenu();
return;
}
const [lon, lat] = this.contextMenuCoord;
const text = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ToastUtils.success("Copied coordinates");
} else {
ToastUtils.success(text);
}
} catch (e) {
console.error("Copy failed", e);
ToastUtils.warning(text);
}
this.closeContextMenu();
},
contextClearMap() {
this.clearDrawings();
this.closeContextMenu();
},
// Clear all overlays on escape/context close
handleGlobalClick() {
if (this.showContextMenu) {
this.closeContextMenu();
}
},
handleMapPointerMove(evt) {
if (!this.map) return;
const lonLat = toLonLat(evt.coordinate);
this.cursorCoords = [lonLat[0], lonLat[1]];
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
if (feature) {
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
if (hasNote) {
this.hoveredFeature = feature;
} else {
this.hoveredFeature = null;
}
this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
this.map.getTargetElement().style.cursor = "";
}
},
handleMapClick(evt) {
if (this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
layerFilter: (l) => l === this.drawLayer,
});
if (feature && feature.get("type") === "note") {
this.startEditingNote(feature);
} else {
this.closeNoteEditor();
}
},
stopDrawing() {
if (this.draw) {
this.map.removeInteraction(this.draw);
this.draw = null;
}
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
this.drawType = null;
this.isDrawing = false;
this.stopMeasuring();
},
clearDrawings() {
if (confirm("Clear all drawings from the map?")) {
this.drawSource.clear();
// clear tooltips if any
const overlays = this.map.getOverlays().getArray();
for (let i = overlays.length - 1; i >= 0; i--) {
const overlay = overlays[i];
if (overlay.get("isMeasureTooltip")) {
this.map.removeOverlay(overlay);
}
}
this.saveMapState();
}
},
// Measurement methods
toggleMeasure() {
if (!this.map) return;
if (this.isMeasuring) {
this.stopMeasuring();
this.drawType = null;
return;
}
this.stopDrawing();
this.isMeasuring = true;
this.drawType = "LineString";
this.createMeasureTooltip();
this.createHelpTooltip();
this.draw = new Draw({
source: this.drawSource,
type: "LineString",
style: new Style({
fill: new Fill({
color: "rgba(255, 255, 255, 0.2)",
}),
stroke: new Stroke({
color: "rgba(0, 0, 0, 0.5)",
lineDash: [10, 10],
width: 2,
}),
image: new CircleStyle({
radius: 5,
stroke: new Stroke({
color: "rgba(0, 0, 0, 0.7)",
}),
fill: new Fill({
color: "rgba(255, 255, 255, 0.2)",
}),
}),
}),
});
this.map.addInteraction(this.draw);
let listener;
this.draw.on("drawstart", (evt) => {
this.sketch = evt.feature;
let tooltipCoord = evt.coordinate;
listener = this.sketch.getGeometry().on("change", (evt) => {
const geom = evt.target;
let output;
if (geom instanceof Polygon) {
output = this.formatArea(geom);
tooltipCoord = geom.getInteriorPoint().getCoordinates();
} else if (geom instanceof LineString) {
output = this.formatLength(geom);
tooltipCoord = geom.getLastCoordinate();
}
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(tooltipCoord);
});
});
this.draw.on("drawend", () => {
this.measureTooltipElement.className = "ol-tooltip ol-tooltip-static";
this.measureTooltip.setOffset([0, -7]);
this.sketch = null;
this.measureTooltipElement = null;
this.createMeasureTooltip();
unByKey(listener);
});
this.map.on("pointermove", this.pointerMoveHandler);
},
stopMeasuring() {
this.isMeasuring = false;
if (this.draw && this.map) {
this.map.removeInteraction(this.draw);
this.draw = null;
}
if (this.map) {
this.map.un("pointermove", this.pointerMoveHandler);
}
if (this.helpTooltip && this.map) {
this.map.removeOverlay(this.helpTooltip);
this.helpTooltip = null;
}
this.sketch = null;
},
pointerMoveHandler(evt) {
if (evt.dragging) return;
let helpMsg = "Click to start drawing";
if (this.sketch) {
helpMsg = "Click to continue drawing, double-click to finish";
}
this.helpTooltipElement.innerHTML = helpMsg;
this.helpTooltip.setPosition(evt.coordinate);
this.helpTooltipElement.classList.remove("hidden");
},
formatLength(line) {
const length = getLength(line);
let output;
let imperialOutput;
// Metric
if (length > 100) {
output = Math.round((length / 1000) * 100) / 100 + " km";
} else {
output = Math.round(length * 100) / 100 + " m";
}
// Imperial
const feet = length * 3.28084;
if (feet > 5280) {
const miles = length * 0.000621371;
imperialOutput = Math.round(miles * 100) / 100 + " mi";
} else {
imperialOutput = Math.round(feet * 100) / 100 + " ft";
}
return `${output}<br/><span class="text-[10px] opacity-80">${imperialOutput}</span>`;
},
formatArea(polygon) {
const area = getArea(polygon);
let output;
let imperialOutput;
// Metric
if (area > 10000) {
output = Math.round((area / 1000000) * 100) / 100 + " km²";
} else {
output = Math.round(area * 100) / 100 + " m²";
}
// Imperial
const sqFeet = area * 10.7639;
if (sqFeet > 27878400) {
// > 1 sq mile
const sqMiles = area * 0.000000386102;
imperialOutput = Math.round(sqMiles * 100) / 100 + " mi²";
} else {
imperialOutput = Math.round(sqFeet * 100) / 100 + " ft²";
}
return `${output}<br/><span class="text-[10px] opacity-80">${imperialOutput}</span>`;
},
createHelpTooltip() {
if (!this.map) return;
if (this.helpTooltipElement && this.helpTooltipElement.parentNode) {
this.helpTooltipElement.parentNode.removeChild(this.helpTooltipElement);
}
this.helpTooltipElement = document.createElement("div");
this.helpTooltipElement.className = "ol-tooltip hidden";
this.helpTooltip = new Overlay({
element: this.helpTooltipElement,
offset: [15, 0],
positioning: "center-left",
});
this.map.addOverlay(this.helpTooltip);
},
createMeasureTooltip() {
if (!this.map) return;
this.measureTooltipElement = document.createElement("div");
this.measureTooltipElement.className = "ol-tooltip ol-tooltip-measure";
this.measureTooltip = new Overlay({
element: this.measureTooltipElement,
offset: [0, -15],
positioning: "bottom-center",
stopEvent: false,
insertFirst: false,
});
this.measureTooltip.set("isMeasureTooltip", true);
this.map.addOverlay(this.measureTooltip);
},
// Drawing storage methods
async openLoadDrawingModal() {
this.showLoadDrawingModal = true;
this.isLoadingDrawings = true;
try {
const response = await window.axios.get("/api/v1/map/drawings");
this.savedDrawings = response.data.drawings;
} catch {
ToastUtils.error("Failed to load drawings");
} finally {
this.isLoadingDrawings = false;
}
},
async saveDrawing() {
if (!this.newDrawingName.trim()) return;
if (!this.drawSource) {
ToastUtils.error("Map not initialized");
return;
}
const format = new GeoJSON();
const features = this.serializeFeatures(this.drawSource.getFeatures());
const json = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
try {
await window.axios.post("/api/v1/map/drawings", {
name: this.newDrawingName,
data: json,
});
ToastUtils.success("Drawing saved");
this.showSaveDrawingModal = false;
this.newDrawingName = "";
} catch {
ToastUtils.error("Failed to save drawing");
}
},
async loadDrawing(drawing) {
const format = new GeoJSON();
const features = format.readFeatures(drawing.data, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
this.drawSource.clear();
this.drawSource.addFeatures(features);
await this.saveMapState();
this.showLoadDrawingModal = false;
ToastUtils.success(`Loaded "${drawing.name}"`);
},
async deleteDrawing(drawing) {
if (!confirm(`Delete drawing "${drawing.name}"?`)) return;
try {
await window.axios.delete(`/api/v1/map/drawings/${drawing.id}`);
this.savedDrawings = this.savedDrawings.filter((d) => d.id !== drawing.id);
ToastUtils.success("Deleted");
} catch {
ToastUtils.error("Failed to delete");
}
},
goToMyLocation() {
// Priority 1: Use telemetry data if available for our own hash
if (this.config && this.config.identity_hash) {
const myTelemetry = this.telemetryList.find((t) => t.destination_hash === this.config.identity_hash);
if (myTelemetry && myTelemetry.telemetry?.location) {
const loc = myTelemetry.telemetry.location;
this.map.getView().animate({
center: fromLonLat([loc.longitude, loc.latitude]),
zoom: 15,
duration: 1000,
});
return;
}
}
// Priority 2: Use browser geolocation if online or available
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
this.map.getView().animate({
center: fromLonLat([pos.coords.longitude, pos.coords.latitude]),
zoom: 15,
duration: 1000,
});
},
(err) => {
console.error("Geolocation failed", err);
ToastUtils.warning("Could not determine your location");
}
);
} else {
ToastUtils.warning("Geolocation is not supported by your browser");
}
},
async fetchTelemetryMarkers() {
if (!window.axios) return;
try {
const response = await window.axios.get("/api/v1/telemetry/peers");
this.telemetryList = response.data.telemetry;
this.updateMarkers();
} catch (e) {
console.error("Failed to fetch telemetry", e);
}
},
updateMarkers() {
if (!this.markerSource) return;
this.markerSource.clear();
for (const t of this.telemetryList) {
const loc = t.telemetry?.location;
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
const feature = new Feature({
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
telemetry: t,
peer: this.peers[t.destination_hash],
});
this.markerSource.addFeature(feature);
}
},
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
const markerFill = isStale ? "#d1d5db" : bgColor;
const markerStroke = isStale ? "#9ca3af" : iconColor;
const path =
iconPath ||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
const style = new Style({
image: new Icon({
src: src,
anchor: [0.5, 1],
scale: 1.6, // Reduced from 2.5
imgSize: [24, 24],
}),
text: new Text({
text: label,
offsetY: -45, // Adjusted from -60
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
this.styleCache[cacheKey] = style;
return style;
},
onMarkerClick(feature) {
this.selectedMarker = {
telemetry: feature.get("telemetry"),
peer: feature.get("peer"),
};
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
if (json.type === "lxmf.telemetry") {
// Find and update or add to telemetryList
const index = this.telemetryList.findIndex((t) => t.destination_hash === json.destination_hash);
const entry = {
destination_hash: json.destination_hash,
timestamp: json.timestamp,
telemetry: json.telemetry,
updated_at: new Date().toISOString(),
};
if (index !== -1) {
this.telemetryList.splice(index, 1, entry);
} else {
this.telemetryList.push(entry);
}
this.updateMarkers();
}
},
formatTimestamp(ts) {
return new Date(ts * 1000).toLocaleString();
},
openChat(hash) {
this.$router.push({
name: "messages",
params: { destinationHash: hash },
});
},
},
};
</script>
<style scoped>
/* Ensure map takes full space */
:deep(.ol-viewport) {
border-radius: inherit;
}
.cursor-crosshair {
cursor: crosshair !important;
}
:deep(.ol-tooltip) {
position: relative;
background: rgba(0, 0, 0, 0.7);
border-radius: 4px;
color: white;
padding: 4px 8px;
opacity: 0.7;
font-size: 12px;
cursor: default;
user-select: none;
text-align: center;
line-height: 1.2;
}
:deep(.ol-tooltip-measure) {
opacity: 1;
font-weight: bold;
}
:deep(.ol-tooltip-static) {
background-color: #3b82f6;
color: white;
border: 1px solid white;
}
:deep(.ol-tooltip-measure:before),
:deep(.ol-tooltip-static:before) {
border-top: 6px solid rgba(0, 0, 0, 0.7);
border-right: 6px solid transparent;
border-left: 6px solid transparent;
content: "";
position: absolute;
bottom: -6px;
margin-left: -7px;
left: 50%;
}
:deep(.ol-tooltip-static:before) {
border-top-color: #3b82f6;
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>