Source: control/cad.js

import { RegularShape, Style, Fill, Stroke, Circle } from 'ol/style';
import { Point, LineString, Polygon, MultiPoint } from 'ol/geom';
import { fromExtent } from 'ol/geom/Polygon';
import Feature from 'ol/Feature';
import Vector from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { Pointer, Snap } from 'ol/interaction';
import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay';
import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser';
import Control from './control';
import cadSVG from '../../img/cad.svg';
import SnapEvent, { SnapEventType } from '../event/snap-event';

const parser = new OL3Parser();
parser.inject(Point, LineString, Polygon, MultiPoint);

/**
 * Control with snapping functionality for geometry alignment.
 * @extends {ole.Control}
 * @alias ole.CadControl
 */
class CadControl extends Control {
  /**
   * @param {Object} [options] Tool options.
   * @param {Function} [options.filter] Returns an array containing the features
   *   to include for CAD (takes the source as a single argument).
   * @param {Number} [options.snapTolerance] Snap tolerance in pixel
   *   for snap lines. Default is 10.
   * @param {Boolean} [options.showSnapLines] Whether to show
   *   snap lines (default is true).
   * @param {Boolean} [options.showSnapPoints] Whether to show
   *  snap points around the closest feature.
   * @param {Number} [options.snapPointDist] Distance of the
   *   snap points (default is 30).
   * @param {Boolean} [options.useMapUnits] Whether to use map units
   *   as measurement for point snapping. Default is false (pixel are used).
   * @param {ol.style.Style.StyleLike} [options.snapStyle] Style used for the snap layer.
   * @param {ol.style.Style.StyleLike} [options.linesStyle] Style used for the lines layer.
   */
  constructor(options) {
    super({
      title: 'CAD control',
      className: 'ole-control-cad',
      image: cadSVG,
      showSnapPoints: true,
      showSnapLines: false,
      snapPointDist: 10,
      ...options,
    });

    /**
     * Interaction for handling move events.
     * @type {ol.interaction.Pointer}
     * @private
     */
    this.pointerInteraction = new Pointer({
      handleMoveEvent: this.onMove.bind(this),
    });

    /**
     * Layer for drawing snapping geometries.
     * @type {ol.layer.Vector}
     * @private
     */
    this.snapLayer = new Vector({
      source: new VectorSource(),
      style: options.snapStyle || [
        new Style({
          image: new RegularShape({
            fill: new Fill({
              color: '#E8841F',
            }),
            stroke: new Stroke({
              width: 1,
              color: '#618496',
            }),
            points: 4,
            radius: 5,
            radius2: 0,
            angle: Math.PI / 4,
          }),
          stroke: new Stroke({
            width: 1,
            lineDash: [5, 10],
            color: '#618496',
          }),
        }),
      ],
    });

    /**
     * Layer for colored lines indicating
     * intesection point between snapping lines.
     * @type {ol.layer.Vector}
     * @private
     */
    this.linesLayer = new Vector({
      source: new VectorSource(),
      style: options.linesStyle || [
        new Style({
          image: new Circle({
            fill: new Fill({
              color: '#E8841F',
            }),
            stroke: new Stroke({
              width: 1,
              color: '#618496',
            }),
            radius: 5,
          }),
          stroke: new Stroke({
            width: 1,
            lineDash: [5, 10],
            color: '#FF530D',
          }),
        }),
      ],
    });

    /**
     * Snap tolerance in pixel.
     * @type {Number}
     * @private
     */
    this.snapTolerance =
      options.snapTolerance === undefined ? 10 : options.snapTolerance;

    /**
     * Filter the features to snap with.
     * @type {Function}
     * @private
     */
    this.filter = options.filter || null;

    /**
     * Interaction for snapping
     * @type {ol.interaction.Snap}
     * @private
     */
    this.snapInteraction = new Snap({
      pixelTolerance: this.snapTolerance,
      source: this.snapLayer.getSource(),
    });

    this.standalone = false;
  }

  /**
   * @inheritdoc
   */
  getDialogTemplate() {
    const distLabel = this.properties.useMapUnits ? 'map units' : 'px';

    return `
      <div>
        <input
          id="aux-cb"
          type="radio"
          name="radioBtn"
          ${this.properties.showSnapLines ? 'checked' : ''}
        >
        <label>Show snap lines</label>
      </div>
      <div>
        <input
          id="dist-cb"
          type="radio"
          name="radioBtn"
          ${this.properties.showSnapPoints ? 'checked' : ''}
        >
        <label>Show snap points. Distance (${distLabel}):</label>
        <input type="text" id="width-input"
          value="${this.properties.snapPointDist}">
      </div>
    `;
  }

  /**
   * @inheritdoc
   */
  setMap(map) {
    super.setMap(map);

    // Ensure that the snap interaction is at the last position
    // as it must be the first to handle the  pointermove event.
    this.map.getInteractions().on(
      'add',
      ((e) => {
        const pos = e.target.getArray().indexOf(this.snapInteraction);

        if (
          this.snapInteraction.getActive() &&
          pos > -1 &&
          pos !== e.target.getLength() - 1
        ) {
          this.deactivate(true);
          this.activate(true);
        }
        // eslint-disable-next-line no-extra-bind
      }).bind(this),
    );
  }

  /**
   * Handle move event.
   * @private
   * @param {ol.MapBrowserEvent} evt Move event.
   */
  onMove(evt) {
    const features = this.getClosestFeatures(evt.coordinate, 5);

    this.linesLayer.getSource().clear();
    this.snapLayer.getSource().clear();

    this.pointerInteraction.dispatchEvent(
      new SnapEvent(SnapEventType.SNAP, features.length ? features : null, evt),
    );

    if (this.properties.showSnapLines) {
      this.drawSnapLines(features, evt.coordinate);
    }

    if (this.properties.showSnapPoints && features.length) {
      this.drawSnapPoints(evt.coordinate, features[0]);
    }
  }

  /**
   * Returns a list of the {num} closest features
   * to a given coordinate.
   * @private
   * @param {ol.Coordinate} coordinate Coordinate.
   * @param {Number} numFeatures Number of features to search.
   * @returns {Array.<ol.Feature>} List of closest features.
   */
  getClosestFeatures(coordinate, numFeatures) {
    const num = numFeatures || 1;
    const ext = [-Infinity, -Infinity, Infinity, Infinity];
    const featureDict = {};

    const pushSnapFeatures = (f) => {
      const cCoord = f.getGeometry().getClosestPoint(coordinate);
      const dx = cCoord[0] - coordinate[0];
      const dy = cCoord[1] - coordinate[1];
      const dist = dx * dx + dy * dy;
      featureDict[dist] = f;
    };

    this.source.forEachFeatureInExtent(ext, (f) => {
      if (!this.filter || (this.filter && this.filter(f))) {
        pushSnapFeatures(f);
      }
    });

    const dists = Object.keys(featureDict);
    let features = [];
    const count = Math.min(dists.length, num);

    dists.sort((a, b) => a - b);

    for (let i = 0; i < count; i += 1) {
      features.push(featureDict[dists[i]]);
    }

    const drawFeature = this.editor.getDrawFeature();
    if (drawFeature) {
      // Include all but the last vertex (at mouse position) to prevent snapping on mouse cursor node
      const currentDrawFeature = drawFeature.clone();
      currentDrawFeature
        .getGeometry()
        .setCoordinates(
          drawFeature.getGeometry().getCoordinates().slice(0, -1),
        );
      features = [currentDrawFeature, ...features];
    }

    const editFeature = this.editor.getEditFeature();
    if (editFeature && this.editor.modifyStartCoordinate) {
      /* Include all nodes of the edit feature except the node being modified */
      // First exclude the edit feature from snap detection
      if (features.indexOf(editFeature) > -1) {
        features.splice(features.indexOf(editFeature), 1);
      }

      // Convert to MultiPoint and get the node coordinate closest to mouse cursor
      const isPolygon = editFeature.getGeometry() instanceof Polygon;
      const snapGeom = new MultiPoint(
        isPolygon
          ? editFeature.getGeometry().getCoordinates()[0]
          : editFeature.getGeometry().getCoordinates(),
      );
      const editNodeCoordinate = snapGeom.getClosestPoint(
        this.editor.modifyStartCoordinate,
      );

      // Exclude the node being modified
      snapGeom.setCoordinates(
        snapGeom.getCoordinates().filter((coord) => {
          return coord.toString() !== editNodeCoordinate.toString();
        }),
      );

      // Clone editFeature and apply adjusted snap geometry
      const snapEditFeature = editFeature.clone();
      snapEditFeature
        .getGeometry()
        .setCoordinates(
          isPolygon ? [snapGeom.getCoordinates()] : snapGeom.getCoordinates(),
        );
      features = [snapEditFeature, ...features];
    }

    return features;
  }

  getRotatedExtent(geometry) {
    const coordinates =
      geometry instanceof Polygon
        ? geometry.getCoordinates()[0]
        : geometry.getCoordinates();
    const xMin = coordinates.reduce((finalMin, coord) => {
      const pixelCurrent = this.map.getPixelFromCoordinate(coord);
      const pixelFinal = this.map.getPixelFromCoordinate(
        finalMin || coordinates[0],
      );
      return pixelCurrent[0] <= pixelFinal[0] ? coord : finalMin;
    });
    const xMax = coordinates.reduce((finalMax, coord) => {
      const pixelCurrent = this.map.getPixelFromCoordinate(coord);
      const pixelFinal = this.map.getPixelFromCoordinate(
        finalMax || coordinates[0],
      );
      return pixelCurrent[0] >= pixelFinal[0] ? coord : finalMax;
    });
    const yMin = coordinates.reduce((finalMin, coord) => {
      const pixelCurrent = this.map.getPixelFromCoordinate(coord);
      const pixelFinal = this.map.getPixelFromCoordinate(
        finalMin || coordinates[0],
      );
      return pixelCurrent[1] <= pixelFinal[1] ? coord : finalMin;
    });
    const yMax = coordinates.reduce((finalMax, coord) => {
      const pixelCurrent = this.map.getPixelFromCoordinate(coord);
      const pixelFinal = this.map.getPixelFromCoordinate(
        finalMax || coordinates[0],
      );
      return pixelCurrent[1] >= pixelFinal[1] ? coord : finalMax;
    });
    const minVertLine = new LineString([
      [xMin[0], -20037508.342789],
      [xMin[0], 20037508.342789],
    ]);
    minVertLine.rotate(this.map.getView().getRotation(), xMin);
    const maxVertLine = new LineString([
      [xMax[0], -20037508.342789],
      [xMax[0], 20037508.342789],
    ]);
    maxVertLine.rotate(this.map.getView().getRotation(), xMax);
    const minHoriLine = new LineString([
      [-20037508.342789, yMin[1]],
      [20037508.342789, yMin[1]],
    ]);
    minHoriLine.rotate(this.map.getView().getRotation(), yMin);
    const maxHoriLine = new LineString([
      [-20037508.342789, yMax[1]],
      [20037508.342789, yMax[1]],
    ]);
    maxHoriLine.rotate(this.map.getView().getRotation(), yMax);
    const intersectTopLeft = OverlayOp.intersection(
      parser.read(minVertLine),
      parser.read(minHoriLine),
    );
    const intersectBottomLeft = OverlayOp.intersection(
      parser.read(minVertLine),
      parser.read(maxHoriLine),
    );
    const intersectTopRight = OverlayOp.intersection(
      parser.read(maxVertLine),
      parser.read(minHoriLine),
    );
    const intersectBottomRight = OverlayOp.intersection(
      parser.read(maxVertLine),
      parser.read(maxHoriLine),
    );
    // this.linesLayer
    //   .getSource()
    //   .addFeatures([
    //     new Feature(
    //       new Point([
    //         intersectTopLeft.getCoordinate().x,
    //         intersectTopLeft.getCoordinate().y,
    //       ]),
    //     ),
    //     new Feature(
    //       new Point([
    //         intersectBottomLeft.getCoordinate().x,
    //         intersectBottomLeft.getCoordinate().y,
    //       ]),
    //     ),
    //     new Feature(
    //       new Point([
    //         intersectTopRight.getCoordinate().x,
    //         intersectTopRight.getCoordinate().y,
    //       ]),
    //     ),
    //     new Feature(
    //       new Point([
    //         intersectBottomRight.getCoordinate().x,
    //         intersectBottomRight.getCoordinate().y,
    //       ]),
    //     ),
    //     new Feature(minVertLine),
    //     new Feature(maxVertLine),
    //     new Feature(minHoriLine),
    //     new Feature(maxHoriLine),
    //   ]);
    return [
      [intersectTopLeft.getCoordinate().x, intersectTopLeft.getCoordinate().y],
      [
        intersectBottomLeft.getCoordinate().x,
        intersectBottomLeft.getCoordinate().y,
      ],
      [
        intersectTopRight.getCoordinate().x,
        intersectTopRight.getCoordinate().y,
      ],
      [
        intersectBottomRight.getCoordinate().x,
        intersectBottomRight.getCoordinate().y,
      ],
    ];
  }

  /**
   * Draws snap lines by building the extent for
   * a pair of features.
   * @private
   * @param {Array.<ol.Feature>} features List of features.
   * @param {ol.Coordinate} coordinate Mouse pointer coordinate.
   */
  drawSnapLines(features, coordinate) {
    let auxCoords = [];

    for (let i = 0; i < features.length; i += 1) {
      const geom = features[i].getGeometry();
      const featureCoord = geom.getCoordinates();

      if (featureCoord.length) {
        if (geom instanceof Point) {
          auxCoords.push(featureCoord);
        } else {
          // filling snapLayer with features vertex
          if (geom instanceof LineString) {
            for (let j = 0; j < featureCoord.length; j += 1) {
              auxCoords.push(featureCoord[j]);
            }
          } else if (geom instanceof Polygon) {
            for (let j = 0; j < featureCoord[0].length; j += 1) {
              auxCoords.push(featureCoord[0][j]);
            }
          }

          // filling auxCoords
          const coords = fromExtent(geom.getExtent()).getCoordinates()[0];
          // const coords = this.getRotatedExtent(geom);
          console.log(this.getRotatedExtent(geom));
          console.log(coords);
          this.getRotatedExtent(geom).forEach((coord) =>
            this.linesLayer
              .getSource()
              .addFeature(new Feature(new Point(coord))),
          );
          auxCoords = auxCoords.concat(coords);
        }
      }
    }

    const px = this.map.getPixelFromCoordinate(coordinate);
    let lineCoords = null;

    for (let i = 0; i < auxCoords.length; i += 1) {
      const tol = this.snapTolerance;
      const auxPx = this.map.getPixelFromCoordinate(auxCoords[i]);
      const drawVLine =
        px[0] > auxPx[0] - this.snapTolerance / 2 &&
        px[0] < auxPx[0] + this.snapTolerance / 2;
      const drawHLine =
        px[1] > auxPx[1] - this.snapTolerance / 2 &&
        px[1] < auxPx[1] + this.snapTolerance / 2;

      if (drawVLine) {
        let newY = px[1];
        newY += px[1] < auxPx[1] ? -tol * 2 : tol * 2;
        const newPt = this.map.getCoordinateFromPixel([auxPx[0], newY]);
        lineCoords = [[auxCoords[i][0], newPt[1]], auxCoords[i]];
      } else if (drawHLine) {
        let newX = px[0];
        newX += px[0] < auxPx[0] ? -tol * 2 : tol * 2;
        const newPt = this.map.getCoordinateFromPixel([newX, auxPx[1]]);
        lineCoords = [[newPt[0], auxCoords[i][1]], auxCoords[i]];
      }

      if (lineCoords) {
        const geom = new LineString(lineCoords);
        const mapRotation = this.map.getView().getRotation();
        if (mapRotation !== 0) {
          geom.rotate(mapRotation, lineCoords[1]);
        }
        this.snapLayer.getSource().addFeature(new Feature(geom));
        // this.linesLayer
        // .getSource()
        // .addFeature(new Feature(new Point(lineCoords[0])));
        // this.linesLayer
        // .getSource()
        // .addFeature(new Feature(new Point(lineCoords[1])));
      }
    }

    let vertArray = null;
    let horiArray = null;
    const snapFeatures = this.snapLayer.getSource().getFeatures();

    if (snapFeatures.length) {
      snapFeatures.forEach((feature) => {
        const featureCoord = feature.getGeometry().getCoordinates();
        const x0 = featureCoord[0][0];
        const x1 = featureCoord[1][0];
        const y0 = featureCoord[0][1];
        const y1 = featureCoord[1][1];

        if (x0 === x1) {
          vertArray = x0;
        }
        if (y0 === y1) {
          horiArray = y0;
        }
      });

      const snapPt = [];

      if (vertArray && horiArray) {
        snapPt.push(vertArray);
        snapPt.push(horiArray);
        this.linesLayer.getSource().addFeatures(snapFeatures);

        this.snapLayer.getSource().clear();
        const snapGeom = new Point(snapPt);
        this.snapLayer.getSource().addFeature(new Feature(snapGeom));
      }
    }
  }

  /**
   * Adds snap points to the snapping layer.
   * @private
   * @param {ol.Coordinate} coordinateMouse cursor coordinate.
   * @param {ol.eaturee} feature Feature to draw the snap points for.
   */
  drawSnapPoints(coordinate, feature) {
    const featCoord = feature.getGeometry().getClosestPoint(coordinate);

    const px = this.map.getPixelFromCoordinate(featCoord);
    let snapCoords = [];

    if (this.properties.useMapUnits) {
      snapCoords = [
        [featCoord[0] - this.properties.snapPointDist, featCoord[1]],
        [featCoord[0] + this.properties.snapPointDist, featCoord[1]],
        [featCoord[0], featCoord[1] - this.properties.snapPointDist],
        [featCoord[0], featCoord[1] + this.properties.snapPointDist],
      ];
    } else {
      const snapPx = [
        [px[0] - this.properties.snapPointDist, px[1]],
        [px[0] + this.properties.snapPointDist, px[1]],
        [px[0], px[1] - this.properties.snapPointDist],
        [px[0], px[1] + this.properties.snapPointDist],
      ];

      for (let j = 0; j < snapPx.length; j += 1) {
        snapCoords.push(this.map.getCoordinateFromPixel(snapPx[j]));
      }
    }

    const snapGeom = new MultiPoint(snapCoords);
    this.snapLayer.getSource().addFeature(new Feature(snapGeom));
  }

  /**
   * @inheritdoc
   */
  activate(silent) {
    super.activate(silent);
    this.snapLayer.setMap(this.map);
    this.linesLayer.setMap(this.map);
    this.map.addInteraction(this.pointerInteraction);
    this.map.addInteraction(this.snapInteraction);

    document.getElementById('aux-cb').addEventListener('change', (evt) => {
      this.setProperties({
        showSnapLines: evt.target.checked,
        showSnapPoints: !evt.target.checked,
      });
    });

    document.getElementById('dist-cb').addEventListener('change', (evt) => {
      this.setProperties({
        showSnapPoints: evt.target.checked,
        showSnapLines: !evt.target.checked,
      });
    });

    document.getElementById('width-input').addEventListener('keyup', (evt) => {
      const snapPointDist = parseFloat(evt.target.value);
      if (!Number.isNaN(snapPointDist)) {
        this.setProperties({ snapPointDist });
      }
    });
  }

  /**
   * @inheritdoc
   */
  deactivate(silent) {
    super.deactivate(silent);
    this.snapLayer.setMap(null);
    this.linesLayer.setMap(null);
    this.map.removeInteraction(this.pointerInteraction);
    this.map.removeInteraction(this.snapInteraction);
  }
}

export default CadControl;