feat: add map-editor plugin with interactive OSM map and markers
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 19s

Implement Phase 1 of custom Kirby plugin for editing interactive maps:
- OpenStreetMap base layer with MapLibre GL JS
- Click to add markers, drag to reposition
- Marker list sidebar with selection and deletion
- Auto-save with debounce (YAML format)
- Add marker button creates marker at current map center
- Max 50 markers per map (configurable)
- Clean UI with marker counter

Blueprint updated to use new map-editor field type instead of placeholder.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-28 15:43:23 +01:00
parent 7e42c4baec
commit dc84ff63a2
11 changed files with 5560 additions and 5 deletions

View file

@ -13,12 +13,12 @@ columns:
text: text:
label: Présentation de la carte label: Présentation de la carte
type: writer type: writer
map: mapdata:
label: Carte label: Carte
type: info type: map-editor
text: | defaultCenter: [43.836699, 4.360054]
Ici le plugin pour la carte et les marqueurs defaultZoom: 13
Avoir la possibilité de changer le fond de carte en image maxMarkers: 50
sidebar: sidebar:
width: 1/3 width: 1/3
sections: sections:

View file

@ -0,0 +1,86 @@
# Map Editor Plugin for Kirby CMS
Interactive map editor plugin for Kirby CMS using MapLibre GL JS. Create print-ready maps with draggable markers and rich content.
## Phase 1 - Complete ✓
Basic map functionality with OSM tiles and draggable markers.
### Features
- Interactive OpenStreetMap base layer
- Add markers by clicking on the map
- Drag & drop markers to reposition
- Delete markers with confirmation
- Marker list sidebar
- Real-time YAML data storage
- Zoom controls
- Maximum 50 markers per map (configurable)
### Installation
1. Plugin is located in `/public/site/plugins/map-editor/`
2. Dependencies are already installed via `npm install`
3. Plugin is built and ready to use
### Usage in Blueprints
```yaml
mapdata:
label: Carte
type: map-editor
defaultCenter: [43.836699, 4.360054] # [latitude, longitude]
defaultZoom: 13
maxMarkers: 50
```
### Data Format
The plugin stores data in YAML format in the page content file:
```yaml
Mapdata:
background:
type: osm
center:
lat: 43.836699
lon: 4.360054
zoom: 13
markers:
- id: marker_1234567890_abc
position:
lat: 43.836699
lon: 4.360054
icon:
type: default
title: ""
content: []
```
### Development
Build the plugin during development:
```bash
cd /public/site/plugins/map-editor
npm run dev # Watch mode
npm run build # Production build
```
### Next Phases (Planned)
- **Phase 2**: Rich marker content (titles, custom icons, text + images)
- **Phase 3**: Geocoding search with Nominatim API
- **Phase 4**: Static map rendering for PDF export
## Technical Details
- **MapLibre GL JS**: Open-source map rendering engine
- **OSM Tiles**: Free OpenStreetMap tiles
- **Kirbyup**: Build tool for Kirby panel plugins
- **Vue 2**: Kirby panel uses Vue 2 (not Vue 3)
## Browser Support
- Modern browsers with ES6+ support
- WebGL required for MapLibre

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<circle cx="20" cy="16" r="14" fill="#e74c3c" stroke="white" stroke-width="3"/>
<circle cx="20" cy="16" r="6" fill="white"/>
<path d="M 20 30 L 15 20 L 25 20 Z" fill="#e74c3c" stroke="white" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,26 @@
<?php
/**
* Map Editor Plugin for Kirby CMS
*
* Interactive map editor with MapLibre GL JS for creating
* print-ready maps with markers and rich content.
*/
Kirby::plugin('geoproject/map-editor', [
'fields' => [
'map-editor' => [
'props' => [
'defaultCenter' => function ($defaultCenter = [43.836699, 4.360054]) {
return $defaultCenter;
},
'defaultZoom' => function ($defaultZoom = 13) {
return $defaultZoom;
},
'maxMarkers' => function ($maxMarkers = 50) {
return $maxMarkers;
}
]
]
]
]);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
{
"name": "map-editor",
"version": "1.0.0",
"description": "Interactive map editor plugin for Kirby CMS",
"type": "module",
"scripts": {
"dev": "npx -y kirbyup src/index.js --watch",
"build": "npx -y kirbyup src/index.js"
},
"dependencies": {
"maplibre-gl": "^3.6.0",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"kirbyup": "^3.3.0"
}
}

View file

@ -0,0 +1,455 @@
<template>
<k-field v-bind="$props" class="k-map-editor-field">
<div class="map-editor-container">
<!-- Main content: Marker list + Map preview -->
<div class="map-content">
<!-- Marker list sidebar -->
<div class="marker-list-sidebar">
<div class="marker-list-header">
<h3>Markers ({{ markers.length }}/{{ maxMarkers }})</h3>
<button
type="button"
class="k-button k-button-icon"
@click="addMarkerAtCenter"
:disabled="markers.length >= maxMarkers"
title="Add marker"
>
<k-icon type="add" />
</button>
</div>
<div class="marker-list-items">
<div
v-for="(marker, index) in markers"
:key="marker.id"
class="marker-item"
:class="{ active: selectedMarkerId === marker.id }"
@click="selectMarker(marker.id)"
>
<div class="marker-item-content">
<span class="marker-number">{{ index + 1 }}</span>
<span class="marker-title">
{{ marker.title || `Marker ${index + 1}` }}
</span>
</div>
<div class="marker-item-actions">
<button
type="button"
class="k-button k-button-small"
@click.stop="deleteMarker(marker.id)"
title="Delete marker"
>
<k-icon type="trash" />
</button>
</div>
</div>
<div v-if="markers.length === 0" class="marker-list-empty">
Click on the map to add markers
</div>
</div>
</div>
<!-- Map preview -->
<div class="map-preview-container">
<MapPreview
v-if="mapReady"
ref="mapPreview"
:center="center"
:zoom="zoom"
:markers="markers"
:selected-marker-id="selectedMarkerId"
@marker-moved="handleMarkerMoved"
@map-click="handleMapClick"
@marker-click="selectMarker"
/>
</div>
</div>
</div>
</k-field>
</template>
<script>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import MapPreview from '../map/MapPreview.vue';
import yaml from 'js-yaml';
export default {
components: {
MapPreview,
},
props: {
value: String,
name: String,
label: String,
help: String,
disabled: Boolean,
defaultCenter: {
type: Array,
default: () => [43.836699, 4.360054],
},
defaultZoom: {
type: Number,
default: 13,
},
maxMarkers: {
type: Number,
default: 50,
},
},
setup(props, { emit }) {
// State
const center = ref({
lat: props.defaultCenter[0],
lon: props.defaultCenter[1],
});
const zoom = ref(props.defaultZoom);
const markers = ref([]);
const selectedMarkerId = ref(null);
const mapReady = ref(false);
const saveTimeout = ref(null);
const mapPreview = ref(null);
// Load data on mount
onMounted(async () => {
loadMapData();
await nextTick();
mapReady.value = true;
});
onBeforeUnmount(() => {
if (saveTimeout.value) {
clearTimeout(saveTimeout.value);
}
});
// Watch only markers for automatic save
watch(
markers,
() => {
debouncedSave();
},
{ deep: true }
);
function loadMapData() {
if (!props.value || props.value.trim() === '') {
return;
}
try {
const data = yaml.load(props.value);
if (data) {
if (data.center) {
center.value = {
lat: data.center.lat,
lon: data.center.lon,
};
}
if (data.zoom !== undefined) {
zoom.value = data.zoom;
}
if (data.markers && Array.isArray(data.markers)) {
markers.value = data.markers;
}
}
} catch (error) {
console.error('Error loading map data:', error);
}
}
function debouncedSave() {
if (saveTimeout.value) {
clearTimeout(saveTimeout.value);
}
saveTimeout.value = setTimeout(() => {
saveMapData();
}, 300);
}
function saveMapData() {
const data = {
background: {
type: 'osm',
},
center: {
lat: center.value.lat,
lon: center.value.lon,
},
zoom: zoom.value,
markers: markers.value,
};
const yamlString = yaml.dump(data, {
indent: 2,
lineWidth: -1,
noRefs: true,
});
emit('input', yamlString);
}
function generateMarkerId() {
return `marker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
function addMarkerAtCenter() {
if (markers.value.length >= props.maxMarkers) {
return;
}
// Get current map center from MapPreview
let currentCenter = { lat: center.value.lat, lon: center.value.lon };
if (mapPreview.value && mapPreview.value.getCurrentCenter) {
currentCenter = mapPreview.value.getCurrentCenter();
}
const newMarker = {
id: generateMarkerId(),
position: {
lat: currentCenter.lat,
lon: currentCenter.lon,
},
icon: {
type: 'default',
},
title: '',
content: [],
};
markers.value = [...markers.value, newMarker];
selectedMarkerId.value = newMarker.id;
}
function handleMapClick(position) {
if (markers.value.length >= props.maxMarkers) {
return;
}
const newMarker = {
id: generateMarkerId(),
position: {
lat: position.lat,
lon: position.lng,
},
icon: {
type: 'default',
},
title: '',
content: [],
};
markers.value = [...markers.value, newMarker];
selectedMarkerId.value = newMarker.id;
}
function deleteMarker(markerId) {
if (!confirm('Are you sure you want to delete this marker?')) {
return;
}
markers.value = markers.value.filter((m) => m.id !== markerId);
if (selectedMarkerId.value === markerId) {
selectedMarkerId.value = null;
}
}
function selectMarker(markerId) {
selectedMarkerId.value = markerId;
}
function handleMarkerMoved({ markerId, position }) {
const markerIndex = markers.value.findIndex((m) => m.id === markerId);
if (markerIndex !== -1) {
const updatedMarkers = [...markers.value];
updatedMarkers[markerIndex] = {
...updatedMarkers[markerIndex],
position: {
lat: position.lat,
lon: position.lng,
},
};
markers.value = updatedMarkers;
}
}
return {
center,
zoom,
markers,
selectedMarkerId,
mapReady,
mapPreview,
addMarkerAtCenter,
handleMapClick,
deleteMarker,
selectMarker,
handleMarkerMoved,
};
},
};
</script>
<style>
.k-map-editor-field {
--marker-list-width: 250px;
}
.map-editor-container {
border: 1px solid var(--color-border);
border-radius: var(--rounded);
overflow: hidden;
background: var(--color-white);
}
.map-content {
display: flex;
height: 600px;
background: var(--color-white);
}
.marker-list-sidebar {
width: var(--marker-list-width);
flex-shrink: 0;
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
background: var(--color-background);
}
.marker-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-white);
}
.marker-list-header h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-text-light);
flex: 1;
}
.marker-list-header .k-button-icon {
width: 2rem;
height: 2rem;
min-width: 2rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-black);
}
.marker-list-header .k-button-icon .k-icon {
color: var(--color-black);
}
.marker-list-header .k-button-icon:hover {
background: var(--color-background);
}
.marker-list-header .k-button-icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.marker-list-items {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.marker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
margin-bottom: 0.5rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--rounded-sm);
cursor: pointer;
transition: all 0.2s;
color: #000;
}
.marker-item:hover {
background: var(--color-background);
border-color: var(--color-focus);
color: #fff;
}
.marker-item.active {
background: var(--color-focus-outline);
border-color: var(--color-focus);
}
.marker-item-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.marker-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--color-gray-400);
color: var(--color-white);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.marker-item.active .marker-number {
background: var(--color-focus);
}
.marker-title {
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.marker-item-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.k-button-small {
padding: 0.25rem 0.5rem;
min-height: auto;
}
.marker-list-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-light);
font-size: 0.875rem;
}
.map-preview-container {
flex: 1;
position: relative;
background: #f0f0f0;
min-width: 0;
}
</style>

View file

@ -0,0 +1,389 @@
<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) {
emit("map-click", {
lat: e.lngLat.lat,
lng: e.lngLat.lng
});
}
});
} 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
const el = document.createElement("div");
el.className = "custom-marker";
if (props.selectedMarkerId === markerData.id) {
el.classList.add("selected");
}
// Add marker number
const numberEl = document.createElement("div");
numberEl.className = "marker-number";
numberEl.textContent = index + 1;
el.appendChild(numberEl);
try {
// Create MapLibre marker
const marker = new maplibregl.Marker({
element: el,
draggable: true
})
.setLngLat([markerData.position.lon, markerData.position.lat])
.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);
});
// 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) {
if (markerId === selectedId) {
element.classList.add("selected");
} else {
element.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
};
}
return {
mapContainer,
loading,
getCurrentCenter
};
}
};
</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 styles */
.custom-marker {
width: 40px;
height: 40px;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.custom-marker:active {
cursor: grabbing;
}
.custom-marker::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;
}
.custom-marker:hover::before {
background: #c0392b;
transform: rotate(-45deg) scale(1.1);
}
.custom-marker.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: translateY(-4px);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* 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>

View file

@ -0,0 +1,7 @@
import MapEditor from "./components/field/MapEditor.vue";
window.panel.plugin("geoproject/map-editor", {
fields: {
"map-editor": MapEditor
}
});