174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
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>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|