Редактор геометрии полигонов и линий
В файле react-geometry-editor.tsx находится код компонента редактора геометрии GeometryEditor, который получает на вход геометрию в формате geojson (поддерживаются геометрии LineString и Polygon).
Вы можете вставить свою геометрию GeoJSON в редактор и нажать кнопку Apply GeoJSON для редактирования геометрии на карте.
react.html
variables.ts
variables.css
react-geometry-editor.tsx
react-geometry-editor.css
common.ts
common.css
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@17/umd/react.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/@turf/turf@7.1.0/turf.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.62.0/lib/codemirror.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.62.0/theme/material.css" />
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.62.0/lib/codemirror.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.62.0/mode/javascript/javascript.js"></script>
<!-- To make the map appear, you must add your apikey -->
<script src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script
data-plugins="transform-modules-umd"
data-presets="typescript"
type="text/babel"
src="../variables.ts"
></script>
<script
data-plugins="transform-modules-umd"
data-presets="typescript"
type="text/babel"
src="./common.ts"
></script>
<script
data-plugins="transform-modules-umd"
data-presets="react, typescript"
type="text/babel"
src="../react-geometry-editor.tsx"
></script>
<script data-plugins="transform-modules-umd" data-presets="react, typescript" type="text/babel">
import type {EditorFromTextArea} from 'codemirror';
import {CODE_MIRROR_OPTIONS, MAX_VERTEX_COUNT, MIN_VERTEX_COUNT} from './common';
import {GeometryEditor, GeometryEditorGeometry, YMapsReactifyContext} from '../react-geometry-editor';
import {INITIAL_GEOMETRY, LOCATION, BEHAVIORS} from '../variables';
window.map = null;
const {useState, useEffect, useCallback, useMemo, useRef} = React;
main();
async function main() {
// For each object in the JS API, there is a React counterpart
// To use the React version of the API, include the module @yandex/ymaps3-reactify
const [ymaps3React] = await Promise.all([ymaps3.import('@yandex/ymaps3-reactify'), ymaps3.ready]);
const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
const {YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapControls, YMapControl, YMapControlButton} =
reactify.module(ymaps3);
const CodeArea = (props: {geometry: GeometryEditorGeometry; onChangeValue: (value: string) => void}) => {
const codeMirrorRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<EditorFromTextArea>(null);
const applyGeoJson = useCallback(() => {
const newValue = editorRef.current.getValue();
props.onChangeValue(newValue);
}, [props.onChangeValue]);
useEffect(() => {
editorRef.current = CodeMirror.fromTextArea(codeMirrorRef.current, CODE_MIRROR_OPTIONS);
editorRef.current.setSize('100%', '100%');
return () => editorRef.current.toTextArea();
}, []);
useEffect(() => {
editorRef.current.setValue(JSON.stringify(props.geometry, null, 2));
}, [props.geometry]);
return (
<div className="geojson-editor">
<textarea ref={codeMirrorRef} />
<button onClick={applyGeoJson}>Apply GeoJSON</button>
</div>
);
};
function App() {
const [geometry, setGeometry] = useState<GeometryEditorGeometry>(INITIAL_GEOMETRY);
const [editMode, setEditMode] = useState(true);
const area = useMemo(() => {
return geometry.type === 'Polygon'
? `${(turf.area(geometry) / 1_000_000).toFixed(2)} km²`
: 'Not valid geometry';
}, [geometry]);
const onChangeGeometry = useCallback((geometry) => {
setGeometry(geometry);
}, []);
const toggleEditMode = useCallback((e) => {
setEditMode(e.target.checked);
}, []);
const onEditorChangeValue = useCallback((value: string) => {
try {
const rawGeometryObject = JSON.parse(value);
let feature;
if (rawGeometryObject.type === 'Polygon') {
feature = turf.polygon(rawGeometryObject.coordinates);
const vertexCount = turf.getCoords(feature)[0].length - 1;
if (vertexCount < MIN_VERTEX_COUNT || vertexCount > MAX_VERTEX_COUNT) {
throw Error(`Polygon must have between ${MIN_VERTEX_COUNT} and ${MAX_VERTEX_COUNT} vertices`);
}
} else if (rawGeometryObject.type === 'LineString') {
feature = turf.lineString(rawGeometryObject.coordinates);
const vertexCount = turf.getCoords(feature).length;
if (vertexCount < MIN_VERTEX_COUNT || vertexCount > MAX_VERTEX_COUNT) {
throw Error(
`LineString must have between ${MIN_VERTEX_COUNT} and ${MAX_VERTEX_COUNT} vertices`
);
}
} else {
throw Error('Not valid geometry. Support only Polygon and LineString');
}
setGeometry(feature.geometry as GeometryEditorGeometry);
} catch (error) {
alert(error.message);
}
}, []);
return (
// Initialize the map and pass initialization parameters
<div className="container">
<div className="map">
<YMapsReactifyContext.Provider value={reactify}>
<YMap
behaviors={BEHAVIORS}
location={reactify.useDefault(LOCATION)}
showScaleInCopyrights={true}
ref={(x) => (map = x)}
>
{/* Add a map scheme layer */}
<YMapDefaultFeaturesLayer />
<YMapDefaultSchemeLayer />
<YMapControls position="top">
<YMapControl transparent>
<div className="area">Area: {area}</div>
</YMapControl>
</YMapControls>
<YMapControls position="top left">
<YMapControl transparent>
<div className="edit-mode-control">
<input
id="edit-mode"
type="checkbox"
onChange={toggleEditMode}
defaultChecked={editMode}
/>
<label className="toggle-control" htmlFor="edit-mode"></label>
<span className="text-label">Edit Mode</span>
</div>
</YMapControl>
</YMapControls>
<GeometryEditor
editMode={editMode}
geometry={geometry}
onChange={onChangeGeometry}
maxVertex={MAX_VERTEX_COUNT}
minVertex={MIN_VERTEX_COUNT}
/>
</YMap>
</YMapsReactifyContext.Provider>
</div>
<CodeArea geometry={geometry} onChangeValue={onEditorChangeValue} />
</div>
);
}
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('app')
);
}
</script>
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="../variables.css" />
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="../react-geometry-editor.css" />
</head>
<body>
<div id="app"></div>
</body>
</html>
import type {BehaviorType, DrawingStyle, YMapLocationRequest} from '@yandex/ymaps3-types';
import {GeometryEditorGeometry} from './react-geometry-editor';
export const LOCATION: YMapLocationRequest = {
center: [37.623082, 55.75254], // starting position [lng, lat]
zoom: 12 // starting zoom
};
export const BEHAVIORS: BehaviorType[] = ['drag', 'scrollZoom', 'pinchZoom'];
export const INITIAL_GEOMETRY = turf.polygon([
[
[37.588108, 55.74035],
[37.590714, 55.75743],
[37.601966, 55.76826],
[37.625483, 55.77002],
[37.642346, 55.76228],
[37.65074, 55.75139],
[37.651494, 55.73807],
[37.614349, 55.73167],
[37.588108, 55.74035]
]
]).geometry as GeometryEditorGeometry;
export const DEFAULT_FEATURE_LINE_STYLE: DrawingStyle = {
stroke: [{width: 4, color: '#74ACFC', opacity: 0.8}],
cursor: 'pointer',
simplificationRate: 0
};
export const DEFAULT_FEATURE_POLYGON_STYLE: DrawingStyle = {
fill: '#74ACFC',
fillOpacity: 0.14,
stroke: [{width: 4, color: '#74ACFC', opacity: 0.8}],
cursor: 'grab',
simplificationRate: 0
};
:root {
--editor-point-color: #196dff;
--interact-action-color: #313133;
}
import type {
DomEventHandler,
LineStringGeometry,
LngLat,
PolygonGeometry,
YMapMarkerEventHandler,
YMapMarkerProps
} from '@yandex/ymaps3-types';
import type {Reactify} from '@yandex/ymaps3-types/reactify';
import {loopLineString} from './common';
import {DEFAULT_FEATURE_LINE_STYLE, DEFAULT_FEATURE_POLYGON_STYLE} from './variables';
const {useContext, useCallback, useEffect, useState, useMemo, useRef, forwardRef} = React;
const Z_INDEX_BASE = 2600;
const Z_INDEX_POLYGON = Z_INDEX_BASE;
const Z_INDEX_LINE = Z_INDEX_BASE + 0.1;
const Z_INDEX_PREVIEW_MARKERS = -1;
const Z_INDEX_MARKERS = Z_INDEX_BASE + 0.3;
const LINE_FEATURE_ID = 'GEOMETRY_EDITOR_LINE_FEATURE';
const PREVIEW_MARKER_FEATURE_ID = 'GEOMETRY_EDITOR_PREVIEW_MARKER_FEATURE';
export const YMapsReactifyContext = React.createContext<Reactify | null>(null);
export type GeometryEditorGeometry = LineStringGeometry | PolygonGeometry;
export type GeometryEditorProps = {
geometry: GeometryEditorGeometry;
onChange?: (geometry: GeometryEditorGeometry) => void | false;
maxVertex?: number;
minVertex?: number;
editMode?: boolean;
};
type GeometryEditorPoint = {
coordinates: LngLat;
key: string;
};
export const GeometryEditor = ({geometry, editMode = false, onChange, minVertex, maxVertex}: GeometryEditorProps) => {
const reactify = useContext(YMapsReactifyContext);
const YMapFeature = reactify.entity(ymaps3.YMapFeature);
const YMapListener = reactify.entity(ymaps3.YMapListener);
const id = useRef(0);
const keyOrder = useRef<string[]>([]);
const keyCoordinates = useRef<Record<string, LngLat>>({});
const previewMarkerRef = useRef();
const createNewKey = () => (id.current++).toString();
const [previewCoordinates, setPreviewCoordinates] = useState<LngLat>();
const [previewSegmentIndex, setPreviewSegmentIndex] = useState<number>();
const [previewPointDrag, setPreviewPointDrag] = useState(false);
const closed = useMemo(() => geometry.type === 'Polygon', [geometry]);
const points = useMemo<GeometryEditorPoint[]>(() => {
const coordinates: LngLat[] =
geometry.type === 'LineString' ? geometry.coordinates : geometry.coordinates[0].slice(0, -1);
keyOrder.current.length = coordinates.length;
return coordinates.map((coordinates, index) => {
let key: string;
if (keyOrder.current[index] !== undefined) {
key = keyOrder.current[index];
} else {
key = createNewKey();
keyOrder.current[index] = key;
}
keyCoordinates.current[key] = coordinates;
return {key, coordinates};
});
}, [geometry, maxVertex, minVertex]);
const isMaxVertex = useMemo(() => maxVertex !== undefined && points.length >= maxVertex, [points, maxVertex]);
const isMinVertex = useMemo(() => minVertex !== undefined && points.length <= minVertex, [points, minVertex]);
const lineGeometry = useMemo<LineStringGeometry>(() => {
const coordinates = points.map((p) => p.coordinates);
return {type: 'LineString', coordinates: closed ? loopLineString(coordinates) : coordinates};
}, [points, closed]);
const polygonGeometry = useMemo<PolygonGeometry>(
() => ({type: 'Polygon', coordinates: [points.map((p) => p.coordinates)]}),
[points]
);
const updatePoint = () => {
const coordinates = keyOrder.current.map((key) => keyCoordinates.current[key]);
const geometry: GeometryEditorGeometry = closed
? (turf.polygon([loopLineString(coordinates)]).geometry as PolygonGeometry)
: (turf.lineString(coordinates).geometry as LineStringGeometry);
onChange?.(geometry);
};
const onMapClick = useCallback<DomEventHandler>(
(object, event) => {
if (object !== undefined && object.entity === previewMarkerRef.current) return;
if (isMaxVertex) return;
const newKey = createNewKey();
keyOrder.current.push(newKey);
keyCoordinates.current[newKey] = event.coordinates;
updatePoint();
},
[isMaxVertex]
);
const onMapMouseMove = useCallback<DomEventHandler>(
(object, event) => {
if (object === undefined) return;
if (object.type === 'feature' && object.entity.id === LINE_FEATURE_ID) {
setPreviewCoordinates(event.coordinates);
}
if (object.type === 'marker' && object.entity === previewMarkerRef.current && !previewPointDrag) {
const pointOnLineCoordinates = turf.nearestPointOnLine(lineGeometry, event.coordinates);
setPreviewCoordinates(pointOnLineCoordinates.geometry.coordinates as LngLat);
setPreviewSegmentIndex(pointOnLineCoordinates.properties.index);
}
},
[lineGeometry, previewPointDrag]
);
const onClickPreviewPoint = useCallback<YMapMarkerProps['onClick']>(
(_, {coordinates}) => {
const newKey = createNewKey();
keyOrder.current.splice(previewSegmentIndex + 1, 0, newKey);
keyCoordinates.current[newKey] = coordinates;
updatePoint();
},
[isMaxVertex, previewSegmentIndex]
);
const onDragMovePreviewPoint = useCallback<YMapMarkerEventHandler>(
(coordinates) => {
const key = keyOrder.current[previewSegmentIndex + 1];
keyCoordinates.current[key] = coordinates;
setPreviewCoordinates(coordinates);
updatePoint();
},
[previewSegmentIndex]
);
const onDragStartPreviewPoint = useCallback<YMapMarkerEventHandler>(
(coordinates) => {
const newKey = createNewKey();
keyOrder.current.splice(previewSegmentIndex + 1, 0, newKey);
keyCoordinates.current[newKey] = coordinates;
setPreviewPointDrag(true);
updatePoint();
},
[previewSegmentIndex]
);
const onDragEndPreviewPoint = useCallback<YMapMarkerEventHandler>((coordinates) => {
setPreviewPointDrag(false);
}, []);
const onDragMovePolygonFeature = useCallback((geometryCoordinates: LngLat[][]) => {
const {geometry} = turf.polygon([loopLineString(geometryCoordinates[0])]);
onChange?.(geometry as GeometryEditorGeometry);
}, []);
const renderPoints = useMemo(
() =>
points.map((point, index) => {
const onDragMove = (coordinates: LngLat) => {
keyCoordinates.current[point.key] = coordinates;
updatePoint();
};
const onDoubleClick = () => {
if (!editMode || isMinVertex) {
return;
}
keyCoordinates.current[point.key] = undefined;
keyOrder.current.splice(index, 1);
updatePoint();
};
return {onDragMove, onDoubleClick, ...point};
}),
[points, isMinVertex, editMode]
);
return (
<>
{editMode && <YMapListener onClick={onMapClick} onMouseMove={onMapMouseMove} />}
<YMapFeature
id={LINE_FEATURE_ID}
zIndex={Z_INDEX_LINE}
geometry={lineGeometry}
style={DEFAULT_FEATURE_LINE_STYLE}
/>
{closed && (
<YMapFeature
zIndex={Z_INDEX_POLYGON}
geometry={polygonGeometry}
style={DEFAULT_FEATURE_POLYGON_STYLE}
draggable={editMode}
onDragMove={onDragMovePolygonFeature}
/>
)}
{editMode && renderPoints.map((point) => <GeometryEditorMarker draggable={editMode} {...point} />)}
{editMode && previewCoordinates && (!isMaxVertex || previewPointDrag) && (
<GeometryEditorPreviewMarker
coordinates={previewCoordinates}
draggable={editMode}
onClick={onClickPreviewPoint}
onDragMove={onDragMovePreviewPoint}
onDragStart={onDragStartPreviewPoint}
onDragEnd={onDragEndPreviewPoint}
ref={previewMarkerRef}
/>
)}
</>
);
};
const GeometryEditorMarker = forwardRef((props: YMapMarkerProps, ref) => {
const reactify = useContext(YMapsReactifyContext);
const YMapMarker = reactify.entity(ymaps3.YMapMarker);
return (
<YMapMarker {...props} zIndex={Z_INDEX_MARKERS} ref={ref}>
<div className="geometry-editor-marker"></div>
</YMapMarker>
);
});
const GeometryEditorPreviewMarker = forwardRef((props: YMapMarkerProps, ref) => {
const reactify = useContext(YMapsReactifyContext);
const YMapMarker = reactify.entity(ymaps3.YMapMarker);
return (
<YMapMarker {...props} zIndex={Z_INDEX_PREVIEW_MARKERS} id={PREVIEW_MARKER_FEATURE_ID} ref={ref}>
<div className="geometry-editor-marker preview"></div>
</YMapMarker>
);
});
.geometry-editor-marker {
position: absolute;
width: 8px;
height: 8px;
cursor: pointer;
border: 1.5px solid var(--editor-point-color);
border-radius: 8px;
background-color: #fff;
transform: translate(-50%, -50%);
}
.geometry-editor-marker:hover {
width: 16px;
height: 16px;
border: none;
border-radius: 8px;
background-color: var(--editor-point-color);
}
.geometry-editor-marker.preview {
cursor: copy;
opacity: 0;
}
.geometry-editor-marker.preview:hover {
opacity: 1;
}
import {LngLat} from '@yandex/ymaps3-types';
import {EditorConfiguration} from 'codemirror';
export const CODE_MIRROR_OPTIONS: EditorConfiguration = {
mode: 'json',
theme: 'material',
lineNumbers: true
};
export const MAX_VERTEX_COUNT = 10;
export const MIN_VERTEX_COUNT = 4;
// helper utils
export const lngLatEqual = (a: LngLat, b: LngLat): boolean => {
const [aLng, aLat] = a;
const [bLng, bLat] = b;
return aLng === bLng && aLat === bLat;
};
export const loopLineString = (coordinates: LngLat[]): LngLat[] => {
return [...coordinates, coordinates[0]];
};
.container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.container .map {
flex-grow: 1;
}
.area {
padding: 8px 12px;
font-size: 14px;
font-style: normal;
line-height: 20px;
color: #fff;
border-radius: 12px;
background-color: #212326;
box-shadow: 0 0 2px 0 rgba(95, 105, 131, 0.08), 0 2px 4px 0 #5f698333;
}
.edit-mode-control {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 16px;
font-size: 16px;
font-style: normal;
line-height: 22px;
user-select: none;
color: #000;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 12px 0 #5f69831a, 0 4px 24px 0 #5f69830a;
gap: 12px;
}
.edit-mode-control input {
display: none;
}
.edit-mode-control input + label.toggle-control {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
cursor: pointer;
border-radius: 10px;
background-color: #5c5e6624;
transition: 0.3s;
}
.edit-mode-control input:checked + label.toggle-control {
background-color: var(--interact-action-color);
}
.edit-mode-control input + label.toggle-control::after {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
content: '';
border-radius: 50%;
background-color: #fff;
box-shadow: 0 0 2px 0 #5f698314, 0 2px 4px 0 #5f698333;
transition: 0.3s;
}
.edit-mode-control input:checked + label.toggle-control::after {
transform: translateX(14px);
}
.geojson-editor {
position: relative;
width: 25%;
}
.geojson-editor button {
position: absolute;
z-index: 1000;
bottom: 8px;
left: calc(50% - (133px / 2));
width: 133px;
padding: 8px 12px;
font-size: 14px;
font-weight: bold;
font-style: normal;
line-height: 16px;
cursor: pointer;
color: #4d4d4d;
border: none;
border-radius: 8px;
outline: none;
background: #fff;
box-shadow: 0 0 2px 0 #5f698314, 0 2px 4px 0 #5f698333;
}
.geojson-editor button:hover {
box-shadow: 0 4px 12px 0 #5f69831a, 0 4px 24px 0 #5f69830a;
}