Add CP→CA→PI platform foundation on MapMil monorepo.

Unify parsing workers, analytics API with PostgreSQL, map UI, and PI distribution into centers/ with Docker Compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 11:43:27 +03:00
co-authored by Cursor
commit 9dfe713668
88 changed files with 6587 additions and 0 deletions
@@ -0,0 +1,232 @@
<script setup lang="ts">
import L from "leaflet";
import { onMounted, onUnmounted, ref, watch } from "vue";
import type { MapObject } from "../types/object";
const props = defineProps<{
objects: MapObject[];
selectedId: number | null;
}>();
const emit = defineEmits<{
select: [object: MapObject];
contextmenu: [payload: {
x: number;
y: number;
latitude: number;
longitude: number;
object: MapObject | null;
}];
move: [payload: { object: MapObject; latitude: number; longitude: number }];
}>();
const mapContainer = ref<HTMLElement | null>(null);
let map: L.Map | null = null;
let markersLayer: L.LayerGroup | null = null;
const markerById = new Map<number, L.Marker>();
const defaultIcon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
const selectedIcon = L.icon({
iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png",
iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
function isDraggable(obj: MapObject): boolean {
return obj.id === props.selectedId;
}
function getMarkerIcon(obj: MapObject): L.Icon {
if (obj.id === props.selectedId) return selectedIcon;
return defaultIcon;
}
function bindMarker(marker: L.Marker, obj: MapObject) {
marker.off("click");
marker.off("contextmenu");
marker.off("dragend");
marker.on("click", () => emit("select", obj));
marker.on("contextmenu", (event: L.LeafletMouseEvent) => {
L.DomEvent.stopPropagation(event.originalEvent);
L.DomEvent.preventDefault(event.originalEvent);
emit("contextmenu", {
x: event.originalEvent.clientX,
y: event.originalEvent.clientY,
latitude: obj.latitude,
longitude: obj.longitude,
object: obj,
});
});
const draggable = isDraggable(obj);
if (marker.dragging) {
if (draggable) {
marker.dragging.enable();
} else {
marker.dragging.disable();
}
}
if (draggable) {
marker.on("dragend", () => {
const latlng = marker.getLatLng();
emit("move", {
object: obj,
latitude: latlng.lat,
longitude: latlng.lng,
});
});
}
}
function syncMarkers() {
if (!map || !markersLayer) return;
const currentIds = new Set(props.objects.map((obj) => obj.id));
for (const [id, marker] of markerById) {
if (!currentIds.has(id)) {
markersLayer.removeLayer(marker);
markerById.delete(id);
}
}
for (const obj of props.objects) {
const draggable = isDraggable(obj);
let marker = markerById.get(obj.id);
if (!marker) {
marker = L.marker([obj.latitude, obj.longitude], {
icon: getMarkerIcon(obj),
draggable,
});
marker.addTo(markersLayer);
markerById.set(obj.id, marker);
} else {
const latlng = marker.getLatLng();
const positionChanged =
Math.abs(latlng.lat - obj.latitude) > 1e-8 ||
Math.abs(latlng.lng - obj.longitude) > 1e-8;
if (positionChanged) {
marker.setLatLng([obj.latitude, obj.longitude]);
}
marker.setIcon(getMarkerIcon(obj));
if (marker.dragging) {
if (draggable) {
marker.dragging.enable();
} else {
marker.dragging.disable();
}
}
}
bindMarker(marker, obj);
}
}
function handleMapContextMenu(event: MouseEvent) {
if (!map || !mapContainer.value) return;
event.preventDefault();
const rect = mapContainer.value.getBoundingClientRect();
const point = map.containerPointToLatLng(
L.point(event.clientX - rect.left, event.clientY - rect.top),
);
emit("contextmenu", {
x: event.clientX,
y: event.clientY,
latitude: point.lat,
longitude: point.lng,
object: null,
});
}
onMounted(() => {
if (!mapContainer.value) return;
map = L.map(mapContainer.value).setView([55.7558, 37.6173], 11);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
markersLayer = L.layerGroup().addTo(map);
mapContainer.value.addEventListener("contextmenu", handleMapContextMenu);
syncMarkers();
});
onUnmounted(() => {
mapContainer.value?.removeEventListener("contextmenu", handleMapContextMenu);
map?.remove();
map = null;
markersLayer = null;
markerById.clear();
});
watch(
() => [props.objects, props.selectedId] as const,
() => syncMarkers(),
{ deep: true },
);
</script>
<template>
<div class="map-wrapper">
<div
ref="mapContainer"
class="map"
:class="{ 'has-selection': selectedId !== null }"
/>
</div>
</template>
<style scoped>
.map-wrapper {
position: relative;
z-index: 0;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
isolation: isolate;
}
.map {
width: 100%;
height: 100%;
}
.map-wrapper :deep(.leaflet-container) {
z-index: 0;
}
.map.has-selection :deep(.leaflet-marker-draggable) {
cursor: grab;
}
.map.has-selection :deep(.leaflet-marker-draggable:active) {
cursor: grabbing;
}
</style>