nidus-sync/ts/components/MapLocator.vue
Eli Ribble 3867737fcc
Track camera changes during map load
This is necessary so that we can frame the map at any time in client
code, like with the user's location data, and still end up with the
correct location.
2026-04-27 19:44:25 +00:00

562 lines
12 KiB
Vue

<style scoped lang="scss">
@import url("https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
.map-wrapper {
position: relative;
width: 100%;
height: 100%;
border-radius: 10px;
overflow: hidden;
}
.map {
width: 100%;
height: 100%;
transition: filter 0.2s ease;
}
.map-inactive {
filter: brightness(0.95);
}
.map-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
cursor: pointer;
transition: background 0.2s ease;
}
.map-overlay:hover {
background: rgba(255, 255, 255, 0.65);
}
.overlay-content {
text-align: center;
color: #0d6efd;
font-size: 1.1rem;
font-weight: 500;
user-select: none;
pointer-events: none;
animation: pulse 2s ease-in-out infinite;
}
.overlay-content i {
display: block;
font-size: 3rem;
margin-bottom: 0.5rem;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
.map-status-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
border: none;
color: #000;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
transition: all 0.2s;
}
.map-status-btn.locked {
background: $warning;
}
.map-status-btn.unlocked {
background: $primary;
}
.map-status-btn:hover {
background: #ffb300;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
transform: translateY(-1px);
}
.map-status-btn:active {
transform: translateY(0);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.overlay-content {
font-size: 1rem;
}
.overlay-content i {
font-size: 2.5rem;
}
.map-status-btn {
padding: 0.5rem;
font-size: 1rem;
}
}
/* Ensure map fills container on all devices */
:deep(.maplibregl-map) {
width: 100%;
height: 100%;
}
:deep(.maplibregl-canvas) {
width: 100%;
height: 100%;
}
</style>
<template>
<div class="map-wrapper" ref="mapWrapper">
<!-- Map container -->
<div ref="mapContainer" class="map-container"></div>
<!-- Lock/unlock indicator button -->
<button
v-if="mapInteractive"
type="button"
class="btn btn-sm map-status-btn unlocked"
@click="deactivateMap"
title="Lock map to enable page scrolling"
>
<i class="bi bi-unlock-fill"></i>
<span class="d-none d-md-inline ms-1">Map Active</span>
</button>
<button
v-if="!mapInteractive"
type="button"
class="btn btn-sm map-status-btn locked"
@click="activateMap"
title="Unlock map to enable map pan/zoom"
>
<i class="bi bi-lock-fill"></i>
<span class="d-none d-md-inline ms-1">Map Locked</span>
</button>
</div>
</template>
<script setup lang="ts">
import maplibregl from "maplibre-gl";
import {
onMounted,
onUnmounted,
ref,
type Ref,
shallowRef,
useTemplateRef,
watch,
} from "vue";
import { boundsMarkers, boundsDefault } from "@/map/util";
import type { MapClickEvent, Marker } from "@/types";
import type { Location } from "@/type/api";
import type { Camera, MoveEndEventInternal } from "@/type/map";
// Emits interface
interface Emits {
(e: "update:modelValue", value: Camera): void;
(e: "click", event: MapClickEvent): void;
(e: "marker-drag-end", location: Location): void;
}
// Props
interface Props {
initialCamera?: Camera;
modelValue: Camera;
markers?: Marker[];
useSatellite?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
markers: () => [],
useSatellite: false,
});
const emit = defineEmits<Emits>();
// Refs
const clickTimeout = ref<number | null>(null);
const isLoaded = ref<boolean>(false);
const map: Ref<maplibregl.Map | null> = shallowRef(null);
const mapContainer = ref<HTMLDivElement | null>(null);
const mapInteractive = ref(false);
const mapMarkers: Ref<Map<string, maplibregl.Marker>> = shallowRef<
Map<string, maplibregl.Marker>
>(new Map());
const mapWrapper = useTemplateRef("mapWrapper");
function activateMap() {
mapInteractive.value = true;
console.log("activated map");
if (!map.value) {
return;
}
map.value.scrollZoom.enable();
map.value.dragPan.enable();
map.value.touchZoomRotate.enable();
map.value.doubleClickZoom.enable();
}
// Deactivate map interaction
function deactivateMap() {
mapInteractive.value = false;
if (!map.value) {
return;
}
map.value.scrollZoom.disable();
map.value.dragPan.disable();
map.value.touchZoomRotate.disable();
map.value.doubleClickZoom.disable();
}
// Initialize map
function initializeMap() {
if (!mapContainer.value) return;
let style = "https://tiles.stadiamaps.com/styles/alidade_smooth.json";
if (props.useSatellite) {
style = "https://tiles.stadiamaps.com/styles/alidade_satellite.json";
}
let map_options: maplibregl.MapOptions = {
container: mapContainer.value,
style: style,
// Disable interactions by default
doubleClickZoom: false,
dragPan: false,
scrollZoom: false,
touchZoomRotate: false,
};
if (props.markers.length > 0) {
if (props.markers.length == 1) {
const m = props.markers[0];
map_options.center = [m.location.longitude, m.location.latitude];
map_options.zoom = 15;
console.log(
"initial map fitting single marker:",
m,
"location:",
m.location,
"zoom:",
15,
);
} else {
const bounds = boundsMarkers(props.markers);
console.log(
"initial map fitting initial markers:",
props.markers,
"bounds:",
bounds,
);
map_options.bounds = bounds;
}
} else if (
props.initialCamera &&
(props.initialCamera.location.latitude ||
props.initialCamera.location.longitude)
) {
console.log("initial map jump to initial camera", props.initialCamera);
map_options.center = [
props.initialCamera.location.longitude,
props.initialCamera.location.latitude,
];
map_options.zoom = props.initialCamera.zoom;
} else if (
props.modelValue.location.latitude != 0 ||
props.modelValue.location.longitude != 0
) {
console.log("initial map jump to initial model", props.modelValue);
map_options.center = [
props.modelValue.location.longitude,
props.modelValue.location.latitude,
];
map_options.zoom = props.modelValue.zoom;
} else {
const bounds = boundsDefault();
console.log("initial map fitting default bounds", bounds);
map_options.bounds = bounds;
}
const _map = new maplibregl.Map(map_options);
_map.addControl(new maplibregl.NavigationControl(), "top-left");
map.value = _map;
_map.on("click", (e: maplibregl.MapLayerMouseEvent) => {
e.preventDefault();
if (!mapInteractive.value) {
activateMap();
return;
}
// Use timeout to distinguish between click and drag
if (clickTimeout.value) {
clearTimeout(clickTimeout.value);
}
clickTimeout.value = window.setTimeout(() => {
emit("click", {
location: {
latitude: e.lngLat.lat,
longitude: e.lngLat.lng,
},
map: _map,
point: e.point,
});
}, 100);
});
_map.on("load", () => {
// It's possible at this point that the client changed the camera while the map
// was loading. If that's the case we need to handle that change now.
console.log("map load complete");
isLoaded.value = true;
// Delay this by a tick so that other load handlers fire first
// This allows updates to the camera model that happened during the load to fire
// and jump the camera to a new location before doing this update.
setTimeout(() => {
updateModel(_map);
}, 1);
});
_map.on("moveend", (evt: MoveEndEventInternal) => {
if (_map && !evt.isInternalUpdate) {
updateModel(_map);
}
});
_map.on("zoomend", (evt: MoveEndEventInternal) => {
if (_map && !evt.isInternalUpdate) {
updateModel(_map);
}
});
// Listen for clicks outside the map
}
function updateModel(_map: maplibregl.Map) {
const center = _map.getCenter();
const newCamera: Camera = {
location: {
latitude: center.lat,
longitude: center.lng,
},
zoom: _map.getZoom(),
};
emit("update:modelValue", newCamera);
}
// Update markers on the map
const updateMarkers = () => {
if (!map.value) return;
// Remove markers that no longer exist
const currentMarkerIds = new Set(props.markers.map((m) => m.id));
for (const [id, marker] of mapMarkers.value) {
if (!currentMarkerIds.has(id)) {
marker.remove();
mapMarkers.value.delete(id);
}
}
// Add or update markers
props.markers.forEach((markerData) => {
let marker = mapMarkers.value.get(markerData.id);
if (marker) {
// Update existing marker
marker.setLngLat([
markerData.location.longitude,
markerData.location.latitude,
]);
marker.setDraggable(markerData.draggable ?? false);
} else {
// Create new marker
const el = document.createElement("div");
el.className = "custom-marker";
el.style.backgroundColor = markerData.color ?? "#FF0000";
el.style.width = "25px";
el.style.height = "25px";
el.style.borderRadius = "50%";
el.style.border = "3px solid white";
el.style.boxShadow = "0 2px 4px rgba(0,0,0,0.3)";
el.style.cursor = markerData.draggable ? "move" : "pointer";
marker = new maplibregl.Marker({
element: el,
draggable: markerData.draggable ?? false,
})
.setLngLat([
markerData.location.longitude,
markerData.location.latitude,
])
.addTo(map.value!);
// Handle marker drag end
if (markerData.draggable) {
marker.on("dragend", () => {
const lngLat = marker!.getLngLat();
const location: Location = {
latitude: lngLat.lat,
longitude: lngLat.lng,
};
emit("marker-drag-end", location);
});
}
mapMarkers.value.set(markerData.id, marker);
}
});
frameMarkers();
};
// Frame all markers in view
function frameMarkers() {
if (!map.value || props.markers.length === 0 || !isLoaded.value) return;
if (props.markers.length === 1) {
// Single marker: pan to it
// If we are zoomed way out we are likely in the default state antd therefore should zoom in a bunch
// for the framing.
const zoom = props.modelValue.zoom > 1 ? props.modelValue.zoom : 15;
console.log(
"framing single marker. location:",
props.markers[0].location,
"model zoom: ",
props.modelValue.zoom,
"calculated zoom: ",
zoom,
);
// Defer this until the map is loaded or we'll drop updates
if (map.value) {
if (isLoaded.value) {
panToLocation(props.markers[0].location, zoom);
} else {
map.value.on("load", () => {
panToLocation(props.markers[0].location, zoom);
});
}
} else {
console.error("Can't frame markers before the map is created");
}
} else {
// Multiple markers: fit bounds
if (map.value) {
console.log("framing multiple markers", isLoaded.value, props.markers);
if (isLoaded.value) {
panToMarkers(props.markers);
} else {
map.value.on("load", () => {
panToMarkers(props.markers);
});
}
} else {
console.error("Can't frame multiple markers before the map is created");
}
}
}
function panToMarkers(markers: Marker[]) {
setTimeout(() => {
if (!map.value) return;
const bounds = boundsMarkers(markers);
map.value.fitBounds(
bounds,
{ padding: 100, duration: 1000 },
{ isInternalUpdate: true },
);
console.log("fitting map to bounds", bounds);
}, 1);
}
function panToLocation(location: Location, zoom: number) {
if (!map.value) return;
map.value.panTo(
{
lat: props.markers[0].location.latitude,
lng: props.markers[0].location.longitude,
},
{ duration: 1000, zoom: zoom },
{ isInternalUpdate: true },
);
}
// Watch for modelValue changes to pan to location
watch(
() => props.modelValue,
(newCamera) => {
if (map.value) {
if (isLoaded.value) {
console.log("panning based on model change", newCamera);
map.value.panTo(
{
lat: newCamera.location.latitude,
lng: newCamera.location.longitude,
},
{ duration: 1000, zoom: newCamera.zoom },
{ isInternalUpdate: true },
);
} else {
console.log("delaying jump until loaded", newCamera);
map.value.once("load", () => {
if (!map.value) return;
map.value.jumpTo(
{
center: {
lat: newCamera.location.latitude,
lng: newCamera.location.longitude,
},
zoom: newCamera.zoom,
},
{ isInternalUpdate: true },
);
});
}
}
},
{ deep: true },
);
// Watch for markers changes
watch(
() => props.markers,
() => {
updateMarkers();
},
{ deep: true },
);
// Lifecycle hooks
onMounted(() => {
setTimeout(() => {
initializeMap();
updateMarkers();
}, 0);
});
onUnmounted(() => {
if (clickTimeout.value) {
clearTimeout(clickTimeout.value);
}
// Remove all markers
mapMarkers.value.forEach((marker) => marker.remove());
mapMarkers.value.clear();
// Remove map
if (map.value) {
map.value.remove();
map.value = null;
}
});
</script>