Редактор геометрии полигонов и линий

В файле react-geometry-editor.tsx находится код компонента редактора геометрии GeometryEditor, который получает на вход геометрию в формате geojson (поддерживаются геометрии LineString и Polygon).

Вы можете вставить свою геометрию GeoJSON в редактор и нажать кнопку Apply GeoJSON для редактирования геометрии на карте.

<!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;
}