Polygon and Line Geometry Editor

Open in CodeSandbox

The react-geometry-editor.tsx file contains the code for the GeometryEditor component of the geometry editor, which receives geometry as input in geojson format (LineString and Polygon are supported).

You can insert your GeoJSON geometry in the editor and click Apply GeoJSON to edit the geometry on the map.

<!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://js.api.mappable.world/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, MMapsReactifyContext} 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 @mappable-world/mappable-reactify
          const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
          const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
          const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControl, MMapControlButton} =
              reactify.module(mappable);

          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">
                          <MMapsReactifyContext.Provider value={reactify}>
                              <MMap
                                  behaviors={BEHAVIORS}
                                  location={reactify.useDefault(LOCATION)}
                                  showScaleInCopyrights={true}
                                  ref={(x) => (map = x)}
                              >
                                  {/* Add a map scheme layer */}
                                  <MMapDefaultFeaturesLayer />
                                  <MMapDefaultSchemeLayer />

                                  <MMapControls position="top">
                                      <MMapControl transparent>
                                          <div className="area">Area: {area}</div>
                                      </MMapControl>
                                  </MMapControls>

                                  <MMapControls position="top left">
                                      <MMapControl 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>
                                      </MMapControl>
                                  </MMapControls>

                                  <GeometryEditor
                                      editMode={editMode}
                                      geometry={geometry}
                                      onChange={onChangeGeometry}
                                      maxVertex={MAX_VERTEX_COUNT}
                                      minVertex={MIN_VERTEX_COUNT}
                                  />
                              </MMap>
                          </MMapsReactifyContext.Provider>
                      </div>
                      <CodeArea geometry={geometry} onChangeValue={onEditorChangeValue} />
                  </div>
              );
          }

          ReactDOM.render(
              <React.StrictMode>
                  <App />
              </React.StrictMode>,
              document.getElementById('app')
          );
      }
    </script>

    <!-- prettier-ignore -->
    <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>
.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;
}
import {LngLat} from '@mappable-world/mappable-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]];
};
.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 type {
  DomEventHandler,
  LineStringGeometry,
  LngLat,
  PolygonGeometry,
  MMapMarkerEventHandler,
  MMapMarkerProps
} from '@mappable-world/mappable-types';
import type {Reactify} from '@mappable-world/mappable-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 MMapsReactifyContext = 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(MMapsReactifyContext);
  const MMapFeature = reactify.entity(mappable.MMapFeature);
  const MMapListener = reactify.entity(mappable.MMapListener);

  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<MMapMarkerProps['onClick']>(
    (_, {coordinates}) => {
      const newKey = createNewKey();
      keyOrder.current.splice(previewSegmentIndex + 1, 0, newKey);
      keyCoordinates.current[newKey] = coordinates;
      updatePoint();
    },
    [isMaxVertex, previewSegmentIndex]
  );

  const onDragMovePreviewPoint = useCallback<MMapMarkerEventHandler>(
    (coordinates) => {
      const key = keyOrder.current[previewSegmentIndex + 1];
      keyCoordinates.current[key] = coordinates;
      setPreviewCoordinates(coordinates);
      updatePoint();
    },
    [previewSegmentIndex]
  );

  const onDragStartPreviewPoint = useCallback<MMapMarkerEventHandler>(
    (coordinates) => {
      const newKey = createNewKey();
      keyOrder.current.splice(previewSegmentIndex + 1, 0, newKey);
      keyCoordinates.current[newKey] = coordinates;

      setPreviewPointDrag(true);
      updatePoint();
    },
    [previewSegmentIndex]
  );

  const onDragEndPreviewPoint = useCallback<MMapMarkerEventHandler>((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 && <MMapListener onClick={onMapClick} onMouseMove={onMapMouseMove} />}
      <MMapFeature
        id={LINE_FEATURE_ID}
        zIndex={Z_INDEX_LINE}
        geometry={lineGeometry}
        style={DEFAULT_FEATURE_LINE_STYLE}
      />
      {closed && (
        <MMapFeature
          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: MMapMarkerProps, ref) => {
  const reactify = useContext(MMapsReactifyContext);
  const MMapMarker = reactify.entity(mappable.MMapMarker);

  return (
    <MMapMarker {...props} zIndex={Z_INDEX_MARKERS} ref={ref}>
      <div className="geometry-editor-marker"></div>
    </MMapMarker>
  );
});

const GeometryEditorPreviewMarker = forwardRef((props: MMapMarkerProps, ref) => {
  const reactify = useContext(MMapsReactifyContext);
  const MMapMarker = reactify.entity(mappable.MMapMarker);

  return (
    <MMapMarker {...props} zIndex={Z_INDEX_PREVIEW_MARKERS} id={PREVIEW_MARKER_FEATURE_ID} ref={ref}>
      <div className="geometry-editor-marker preview"></div>
    </MMapMarker>
  );
});
:root {
  --editor-point-color: #1921ff;
  --interact-action-color: #122db2;
}
import type {BehaviorType, DrawingStyle, MMapLocationRequest} from '@mappable-world/mappable-types';
import {GeometryEditorGeometry} from './react-geometry-editor';

export const LOCATION: MMapLocationRequest = {
  center: [55.2744, 25.1972], // starting position [lng, lat]
  zoom: 12 // starting zoom
};

export const BEHAVIORS: BehaviorType[] = ['drag', 'scrollZoom', 'pinchZoom'];

export const INITIAL_GEOMETRY = turf.polygon([
  [
    [55.302408, 25.189178],
    [55.298089, 25.176713],
    [55.276003, 25.172635],
    [55.258304, 25.16725],
    [55.247538, 25.177221],
    [55.242686, 25.187418],
    [55.249911, 25.20896],
    [55.268689, 25.216328],
    [55.302408, 25.189178]
  ]
]).geometry as GeometryEditorGeometry;

export const DEFAULT_FEATURE_LINE_STYLE: DrawingStyle = {
  stroke: [{width: 4, color: '#747FFC', opacity: 0.8}],
  cursor: 'pointer',
  simplificationRate: 0
};

export const DEFAULT_FEATURE_POLYGON_STYLE: DrawingStyle = {
  fill: '#747FFC',
  fillOpacity: 0.14,
  stroke: [{width: 4, color: '#747FFC', opacity: 0.8}],
  cursor: 'grab',
  simplificationRate: 0
};