aspans_patrol/apps/web/src/map/RouteEditor.tsx
2026-10-22 10:53:02 +05:00

174 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useEffect, useRef } from 'react';
import L from 'leaflet';
import { Circle, GeoJSON, Marker, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import '@geoman-io/leaflet-geoman-free';
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css';
import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint } from '@patrol/shared';
import { PERIMETER_STYLE, ROUTE_STYLE, numberIcon } from './base';
export type EditorCheckpoint = { key: string; name: string; location: GeoJsonPoint; radiusM: number };
export type EditorMode = 'path' | 'points' | 'view';
// Линия маршрута — через geoman: нарисовать, поправить вершины, удалить. Всегда одна линия.
function PathLayer({ value, onChange, mode }: { value: GeoJsonLineString | null; onChange: (v: GeoJsonLineString | null) => void; mode: EditorMode }) {
const map = useMap();
const line = useRef<L.Polyline | null>(null);
const changeRef = useRef(onChange);
changeRef.current = onChange;
useEffect(() => {
const emit = () => {
const l = line.current;
if (!l) return changeRef.current(null);
const geo = l.toGeoJSON().geometry as GeoJSON.LineString;
changeRef.current(geo.coordinates.length >= 2 ? { type: 'LineString', coordinates: geo.coordinates as [number, number][] } : null);
};
const attach = (l: L.Polyline) => {
line.current = l;
l.setStyle(ROUTE_STYLE);
l.on('pm:edit pm:dragend', emit);
};
if (value) attach(L.polyline(value.coordinates.map(([lng, lat]) => [lat, lng] as [number, number])).addTo(map));
map.pm.setLang('ru');
map.pm.setGlobalOptions({ pathOptions: ROUTE_STYLE, snappable: true });
const onCreate = (e: { shape: string; layer: L.Layer }) => {
if (e.shape !== 'Line') return;
line.current?.remove();
attach(e.layer as L.Polyline);
emit();
};
const onRemove = (e: { layer: L.Layer }) => {
if (e.layer === line.current) {
line.current = null;
emit();
}
};
map.on('pm:create', onCreate);
map.on('pm:remove', onRemove);
return () => {
map.off('pm:create', onCreate);
map.off('pm:remove', onRemove);
map.pm.removeControls();
map.pm.disableDraw();
map.pm.disableGlobalEditMode();
line.current?.remove();
line.current = null;
};
// Инициализация один раз на маршрут (key у редактора).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map]);
// Панель geoman видна только в режиме «линия», чтобы клики в режиме «точки» не путались с рисованием.
useEffect(() => {
map.pm.removeControls();
map.pm.disableDraw();
map.pm.disableGlobalEditMode();
if (mode === 'path') {
map.pm.addControls({
position: 'topleft',
drawMarker: false,
drawCircleMarker: false,
drawPolygon: false,
drawRectangle: false,
drawCircle: false,
drawText: false,
drawPolyline: true,
cutPolygon: false,
rotateMode: false,
dragMode: false,
editMode: true,
removalMode: true,
});
}
if (line.current) line.current.options.pmIgnore = mode !== 'path';
}, [map, mode]);
return null;
}
function ClickToAdd({ enabled, onAdd }: { enabled: boolean; onAdd: (p: GeoJsonPoint) => void }) {
const map = useMapEvents({
click(e) {
if (!enabled || map.pm.globalDrawModeEnabled()) return;
onAdd({ type: 'Point', coordinates: [Number(e.latlng.lng.toFixed(7)), Number(e.latlng.lat.toFixed(7))] });
},
});
useEffect(() => {
const el = map.getContainer();
el.style.cursor = enabled ? 'crosshair' : '';
return () => {
el.style.cursor = '';
};
}, [map, enabled]);
return null;
}
export function RouteEditor({
perimeter,
path,
onPathChange,
checkpoints,
onCheckpointsChange,
mode,
selected,
onSelect,
}: {
perimeter: GeoJsonMultiPolygon | null;
path: GeoJsonLineString | null;
onPathChange: (v: GeoJsonLineString | null) => void;
checkpoints: EditorCheckpoint[];
onCheckpointsChange: (v: EditorCheckpoint[]) => void;
mode: EditorMode;
selected: number | null;
onSelect: (i: number | null) => void;
}) {
const add = (location: GeoJsonPoint) => {
const next = [...checkpoints, { key: crypto.randomUUID(), name: `Точка ${checkpoints.length + 1}`, location, radiusM: 30 }];
onCheckpointsChange(next);
onSelect(next.length - 1);
};
const move = (i: number, latlng: L.LatLng) => {
onCheckpointsChange(
checkpoints.map((c, j) => (j === i ? { ...c, location: { type: 'Point', coordinates: [Number(latlng.lng.toFixed(7)), Number(latlng.lat.toFixed(7))] } } : c)),
);
};
return (
<>
{perimeter && <GeoJSON data={perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} pmIgnore interactive={false} />}
<PathLayer value={path} onChange={onPathChange} mode={mode} />
<ClickToAdd enabled={mode === 'points'} onAdd={add} />
{checkpoints.map((c, i) => {
const pos: [number, number] = [c.location.coordinates[1], c.location.coordinates[0]];
return (
<Fragment key={c.key}>
<Circle center={pos} radius={c.radiusM} pathOptions={{ color: '#2563eb', weight: 1, fillOpacity: selected === i ? 0.18 : 0.08 }} pmIgnore interactive={false} />
<Marker
key={mode === 'path' ? 'passive' : 'active'}
interactive={mode !== 'path'}
position={pos}
icon={numberIcon(i + 1, { active: selected === i })}
draggable={mode === 'points'}
pmIgnore
eventHandlers={{
click: (e) => {
L.DomEvent.stopPropagation(e);
onSelect(i);
},
dragend: (e) => move(i, (e.target as L.Marker).getLatLng()),
}}
>
<Tooltip direction="top" offset={[0, -14]}>
{c.name}
</Tooltip>
</Marker>
</Fragment>
);
})}
</>
);
}