geoproject-app/public/site/plugins/map-editor/src/components/map/MapPreview.vue
isUnknown b19635f324 feat: add custom marker icons with configurable size
- Add markerIcon files field to marker.yml for custom JPG/PNG/SVG icons
- Add markerIconSize range field (20-500px, default 40px) with unit display
- Layout icon fields side-by-side (50/50 width) in marker blueprint
- Add markerIconUrl prop in index.php to auto-detect uploaded icon
- Add markerIconSize prop in index.php to read size from page data
- Update MapPreview.vue to display custom images instead of default pins
- Set icon dimensions dynamically based on markerIconSize value
- Icon size updates on save/reload (reactive implementation deferred)
- Remove custom tiles background functionality (not needed)

Note: Custom icons show uploaded image, may have white background on
transparent PNGs depending on image processing. Size is non-reactive
and requires save + reload to update in preview.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 16:14:33 +01:00

479 lines
11 KiB
Vue

<template>
<div class="map-preview">
<div ref="mapContainer" class="map-container"></div>
<div v-if="loading" class="map-loading">
<div class="spinner"></div>
<span>Loading map...</span>
</div>
</div>
</template>
<script>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
export default {
props: {
center: {
type: Object,
required: true
},
zoom: {
type: Number,
required: true
},
markers: {
type: Array,
default: () => []
},
selectedMarkerId: {
type: String,
default: null
}
},
setup(props, { emit }) {
// State
const mapContainer = ref(null);
const map = ref(null);
const loading = ref(true);
const markerElements = ref(new Map());
const isDragging = ref(false);
// Lifecycle
onMounted(async () => {
await nextTick();
initMap();
});
onBeforeUnmount(() => {
if (map.value) {
map.value.remove();
map.value = null;
}
markerElements.value.clear();
});
// Watchers - only for updates FROM parent, not TO parent
watch(() => props.center, (newCenter) => {
if (map.value && map.value.loaded() && !isDragging.value) {
const currentCenter = map.value.getCenter();
// Only update if significantly different to avoid render loops
if (Math.abs(currentCenter.lat - newCenter.lat) > 0.00001 ||
Math.abs(currentCenter.lng - newCenter.lon) > 0.00001) {
map.value.setCenter([newCenter.lon, newCenter.lat]);
}
}
}, { deep: true });
watch(() => props.zoom, (newZoom) => {
if (map.value && map.value.loaded()) {
const currentZoom = map.value.getZoom();
// Only update if significantly different
if (Math.abs(currentZoom - newZoom) > 0.01) {
map.value.setZoom(newZoom);
}
}
});
watch(() => props.markers, () => {
updateMarkers();
}, { deep: true });
watch(() => props.selectedMarkerId, (newId) => {
updateMarkerSelection(newId);
});
// Methods
function initMap() {
if (!mapContainer.value) {
console.error("Map container not found");
return;
}
loading.value = true;
try {
map.value = new maplibregl.Map({
container: mapContainer.value,
style: {
version: 8,
sources: {
osm: {
type: "raster",
tiles: [
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png"
],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}
},
layers: [
{
id: "osm",
type: "raster",
source: "osm",
minzoom: 0,
maxzoom: 19
}
]
},
center: [props.center.lon, props.center.lat],
zoom: props.zoom
});
map.value.on("load", () => {
loading.value = false;
updateMarkers();
});
// Handle map clicks to add markers
map.value.on("click", (e) => {
// Check if click was on a marker element
const target = e.originalEvent.target;
const isMarkerClick = target.closest('.custom-marker');
if (!isMarkerClick) {
const clickPos = {
lat: e.lngLat.lat,
lng: e.lngLat.lng
};
emit("map-click", clickPos);
}
});
} catch (error) {
console.error("Error initializing map:", error);
loading.value = false;
}
}
function updateMarkers() {
if (!map.value || !map.value.loaded()) {
return;
}
// Remove existing markers
markerElements.value.forEach(({ marker }) => {
if (marker) {
marker.remove();
}
});
markerElements.value.clear();
// Add markers
if (props.markers && Array.isArray(props.markers)) {
props.markers.forEach((markerData, index) => {
addMarkerToMap(markerData, index);
});
}
}
function addMarkerToMap(markerData, index) {
if (!map.value || !markerData || !markerData.position) {
return;
}
// Create marker element (outer wrapper for MapLibre positioning)
const el = document.createElement("div");
el.className = "custom-marker";
// Check if custom icon is provided
if (markerData.iconUrl) {
// Use custom image
el.classList.add("custom-icon");
const img = document.createElement("img");
img.src = markerData.iconUrl;
img.className = "marker-icon-image";
// Set size from marker data or default to 40px
const size = markerData.iconSize || 40;
img.style.width = `${size}px`;
img.style.height = `${size}px`;
if (props.selectedMarkerId === markerData.id) {
img.classList.add("selected");
}
el.appendChild(img);
} else {
// Use default pin marker
// Create inner wrapper for visual transforms (isolates from MapLibre transforms)
const inner = document.createElement("div");
inner.className = "marker-inner";
if (props.selectedMarkerId === markerData.id) {
inner.classList.add("selected");
}
// Add marker number
const numberEl = document.createElement("div");
numberEl.className = "marker-number";
numberEl.textContent = index + 1;
inner.appendChild(numberEl);
el.appendChild(inner);
}
try {
const coords = [markerData.position.lon, markerData.position.lat];
// Create MapLibre marker
// Anchor at bottom-center (where the pin tip is)
const marker = new maplibregl.Marker({
element: el,
draggable: true,
anchor: 'bottom'
})
.setLngLat(coords)
.addTo(map.value);
// Handle marker drag
marker.on("dragstart", () => {
isDragging.value = true;
});
marker.on("dragend", () => {
const lngLat = marker.getLngLat();
emit("marker-moved", {
markerId: markerData.id,
position: {
lat: lngLat.lat,
lng: lngLat.lng
}
});
setTimeout(() => {
isDragging.value = false;
}, 100);
});
// Handle marker click
el.addEventListener("click", (e) => {
e.stopPropagation();
emit("marker-click", markerData.id);
});
// Handle marker double-click
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
emit("marker-dblclick", markerData.id);
});
// Store marker reference
markerElements.value.set(markerData.id, {
marker,
element: el
});
} catch (error) {
console.error("Error adding marker to map:", error);
}
}
function updateMarkerSelection(selectedId) {
markerElements.value.forEach(({ element }, markerId) => {
if (element) {
// Handle default pin marker
const inner = element.querySelector('.marker-inner');
if (inner) {
if (markerId === selectedId) {
inner.classList.add("selected");
} else {
inner.classList.remove("selected");
}
}
// Handle custom icon marker
const img = element.querySelector('.marker-icon-image');
if (img) {
if (markerId === selectedId) {
img.classList.add("selected");
} else {
img.classList.remove("selected");
}
}
}
});
}
function getCurrentCenter() {
if (map.value && map.value.loaded()) {
const center = map.value.getCenter();
return {
lat: center.lat,
lon: center.lng
};
}
return {
lat: props.center.lat,
lon: props.center.lon
};
}
function centerOnPosition(lat, lon) {
if (map.value && map.value.loaded()) {
map.value.flyTo({
center: [lon, lat],
zoom: map.value.getZoom(),
duration: 1000
});
}
}
return {
mapContainer,
loading,
getCurrentCenter,
centerOnPosition
};
}
};
</script>
<style>
.map-preview {
position: relative;
width: 100%;
height: 100%;
}
.map-container {
width: 100%;
height: 100%;
}
.map-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: rgba(255, 255, 255, 0.9);
z-index: 1000;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Custom marker outer wrapper - NO transforms here, MapLibre handles positioning */
.custom-marker {
/* MapLibre will position this element via transform: translate3d() */
cursor: grab;
}
.custom-marker:active {
cursor: grabbing;
}
/* Inner wrapper for visual styling - transforms are isolated here */
.marker-inner {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.marker-inner::before {
content: "";
position: absolute;
width: 40px;
height: 40px;
background: #e74c3c;
border: 3px solid white;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.marker-inner:hover::before {
background: #c0392b;
transform: rotate(-45deg) scale(1.1);
}
.marker-inner.selected::before {
background: #3498db;
border-color: #2980b9;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.6);
}
.marker-number {
position: relative;
z-index: 1;
color: white;
font-weight: 700;
font-size: 14px;
line-height: 1;
/* transform removed - was causing positioning issues */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* Custom icon marker */
.custom-marker.custom-icon {
cursor: grab;
}
.custom-marker.custom-icon:active {
cursor: grabbing;
}
.marker-icon-image {
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
transition: all 0.2s;
}
.marker-icon-image:hover {
transform: scale(1.1);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.marker-icon-image.selected {
filter: drop-shadow(0 4px 12px rgba(52, 152, 219, 0.8));
}
/* MapLibre controls styling */
.maplibregl-ctrl-group {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.maplibregl-ctrl-group button {
width: 30px;
height: 30px;
}
.maplibregl-ctrl-attrib {
font-size: 11px;
background: rgba(255, 255, 255, 0.8);
}
.maplibregl-canvas-container {
cursor: crosshair;
}
.maplibregl-canvas-container.maplibregl-interactive {
cursor: crosshair;
}
</style>