leaflet-craft
Version:
Zoopla inspired freehand polygon creation using Leaflet.js.
594 lines (510 loc) • 15.6 kB
JavaScript
import "regenerator-runtime/runtime";
import "./styles/app.css";
import { FeatureGroup, Point } from "leaflet";
import { select } from "d3-selection";
import { line, curveMonotoneX } from "d3-shape";
import Set from "es6-set";
import WeakMap from "es6-weak-map";
import Symbol from "es6-symbol";
import createPolygon from "turf-polygon";
import { compose, head } from "ramda";
import * as turf from "@turf/helpers";
import pointsWithinPolygon from "@turf/points-within-polygon";
import { updateFor } from "./helpers/Layer";
import { createFor, removeFor, clearFor } from "./helpers/Polygon";
import { latLngsToTuple } from "./helpers/Utils";
import {
CREATE,
EDIT,
DELETE,
APPEND,
DELETEMARKERS,
DELETEPOINT,
EDIT_APPEND,
DISTANCE_FLAG,
NONE,
ALL,
modeFor
} from "./helpers/Flags";
import simplifyPolygon from "./helpers/Simplify";
import UndoRedo from "./helpers/UndoRedo";
import PubSub from "./helpers/PubSub";
import {
maintainStackStates,
undoMainStack,
undoStackObject,
redoMainStack,
redoStackObject,
mergedPolygonsMap
} from "./helpers/UndoRedo";
import { customControl } from "./helpers/toolbar";
import { undoRedoControl } from "./helpers/UndoRedoToolbar";
import {undoHandler, redoHandler} from "./helpers/Handlers";
/**
* @constant polygons
* @type {WeakMap}
*/
export const polygons = new WeakMap();
const {publish, subscribe, clear} = PubSub();
export const pubSub = {publish, subscribe};
/**
* @constant defaultOptions
* @type {Object}
*/
export const defaultOptions = {
mode: NONE,
smoothFactor: 0.3,
elbowDistance: 10,
simplifyFactor: 0,
mergePolygons: true,
concavePolygon: true,
maximumPolygons: Infinity,
notifyAfterEditExit: false,
leaveModeAfterCreate: false,
strokeWidth: 2,
undoRedo: true,
showUndoRedoBar: true,
showControlBar: true,
onCreateStart: () => {},
onCreateEnd: () => {},
onEditStart: () => {},
onEditEnd: () => {},
onRemoveStart: () => {},
onRemoveEnd: () => {}
};
/**
* @constant instanceKey
* @type {Symbol}
*/
export const instanceKey = Symbol("freedraw/instance");
/**
* @constant modesKey
* @type {Symbol}
*/
export const modesKey = Symbol("freedraw/modes");
export const distanceFlag = Symbol("freedraw/distanceFlag");
/**
* @constant notifyDeferredKey
* @type {Symbol}
*/
export const notifyDeferredKey = Symbol("freedraw/notify-deferred");
/**
* @constant edgesKey
* @type {Symbol}
*/
export const edgesKey = Symbol("freedraw/edges");
export const rawLatLngKey = Symbol("freedraw/rawLatLngs");
export const polygonID = Symbol("freedraw/polygonID");
export const polygonArea = Symbol("freedraw/polygonArea");
/**
* @constant cancelKey
* @type {Symbol}
*/
const cancelKey = Symbol("freedraw/cancel");
export default class FreeDraw extends FeatureGroup {
/**
* @constructor
* @param {Object} [options = {}]
* @return {void}
*/
constructor(options = defaultOptions) {
super();
this.options = { ...defaultOptions, ...options };
}
toggleUndoRedoBar(show) {
if(show) {
this.undoRedoBar = new undoRedoControl(this.options);
this.map.addControl(this.undoRedoBar);
}
else {
this.map.removeControl(this.undoRedoBar);
}
}
toggleControlBar(show) {
if(show) {
this.controlBar = new customControl(this.options);
this.map.addControl(this.controlBar);
}
else {
this.map.removeControl(this.controlBar);
// this._choice = true;
// this.map.removeRuler()
}
}
/**
* @method onAdd
* @param {Object} map
* @return {void}
*/
onAdd(map) {
// Memorise the map instance.
this.map = map;
// Attach the cancel function and the instance to the map.
map[cancelKey] = () => {};
map[instanceKey] = this;
map[notifyDeferredKey] = () => {};
// Setup the dependency injection for simplifying the polygon.
map.simplifyPolygon = simplifyPolygon;
// Add the item to the map.
polygons.set(map, new Set());
// Set the initial mode.
modeFor(map, 0, this.options);
// Instantiate the SVG layer that sits on top of the map.
const svg = (this.svg = select(map._container)
.append("svg")
.classed("free-draw", true)
.attr("width", "100%")
.attr("height", "100%")
.style("pointer-events", "none")
.style("z-index", "1001")
.style("position", "relative"));
// Set the mouse events.
this.listenForEvents(map, svg, this.options);
if (this.options.undoRedo) {
this.history = UndoRedo(map);
// Set Undo Redo Listeners
this.history.attachListeners();
pubSub.subscribe("Add_Undo_Redo", maintainStackStates);
if(this.options.showUndoRedoBar) {
// map.addControl(new undoRedoControl(this.options));
this.toggleUndoRedoBar(true);
}
}
pubSub.subscribe('create-start', this.options.onCreateStart);
pubSub.subscribe('create-end', this.options.onCreateEnd);
pubSub.subscribe('edit-start', this.options.onEditStart);
pubSub.subscribe('edit-end', this.options.onEditEnd);
pubSub.subscribe('remove-start', this.options.onRemoveStart);
pubSub.subscribe('remove-end', this.options.onRemoveEnd);
if(this.options.showControlBar) {
// map.addControl(new customControl(this.options));
this.toggleControlBar(true);
}
}
/**
* @method onRemove
* @param {Object} map
* @return {void}
*/
onRemove(map) {
// Remove the item from the map.
polygons.delete(map);
// Remove the SVG layer.
this.svg.remove();
// Remove the appendages from the map container.
delete map[cancelKey];
delete map[instanceKey];
delete map.simplifyPolygon;
this.history.removeListeners();
undoMainStack.clear();
redoMainStack.clear();
undoStackObject.clear();
redoStackObject.clear();
mergedPolygonsMap.clear();
// clear events
clear();
}
/**
* @method create
* @param {LatLng[]} latLngs
* @param {Object} [options = { concavePolygon: false }]
* @return {Object}
*/
create(latLngs, options = { concavePolygon: false, simplifyFactor: 0}) {
const created = createFor(this.map, latLngsToTuple(latLngs), {
...this.options,
...options
});
pubSub.publish("create-end");
// updateFor(this.map, "create");
return created;
}
/**
* @method remove
* @param {Object} polygon
* @return {void}
*/
remove(polygon) {
// remove from UndoStack
undoMainStack.pop();
polygon ? removeFor(this.map, polygon) : super.remove();
// updateFor(this.map, "remove");
}
/**
* @method clear
* @return {void}
*/
clear() {
clearFor(this.map);
updateFor(this.map, "clear");
undoMainStack.clear();
redoMainStack.clear();
undoStackObject.clear();
redoStackObject.clear();
mergedPolygonsMap.clear();
}
/**
* @method setMode
* @param {Number} [mode = null]
* @return {Number}
*/
mode(mode = null) {
// Set mode when passed `mode` is numeric, and then yield the current mode.
typeof mode === "number" && modeFor(this.map, mode, this.options);
return this.map[modesKey];
}
toggleMode(mode = null) {
// Set mode when passed `mode` is numeric, and then yield the current mode.
// Update the mode.
this.map[modesKey] = mode;
// Fire the updated mode.
this.map[instanceKey].fire('mode', { mode });
}
/**
* @method size
* @return {Number}
*/
size() {
return polygons.get(this.map).size;
}
/**
* @method all
* @return {Array}
*/
all() {
return polygons.get(this.map);
}
/**
* @method cancel
* @return {void}
*/
cancel() {
this.map[cancelKey]();
}
/**
* @method listenForEvents
* @param {Object} map
* @param {Object} svg
* @param {Object} options
* @return {void}
*/
listenForEvents(map, svg, options) {
/**
* @method mouseDown
* @param {Object} event
* @return {void}
*/
const mouseDown = event => {
if ((map[modesKey] & DELETEMARKERS)) {
const latLngs = new Set();
const lineIterator = this.createPath(
svg,
map.latLngToContainerPoint(event.latlng),
options.strokeWidth
);
const mouseMove = event => {
// Resolve the pixel point to the latitudinal and longitudinal equivalent.
const point = map.mouseEventToContainerPoint(event.originalEvent);
// Push each lat/lng value into the points set.
latLngs.add(map.containerPointToLatLng(point));
// Invoke the generator by passing in the starting point for the path.
lineIterator(new Point(point.x, point.y));
};
// Create the path when the user moves their cursor.
map.on("mousemove touchmove", mouseMove);
const mouseUp = () => {
// Remove the ability to invoke `cancel`.
map[cancelKey] = () => {};
// Stop listening to the events.
map.off("mouseup", mouseUp);
map.off("mousemove", mouseMove);
"body" in document &&
document.body.removeEventListener("mouseleave", mouseUp);
// Clear the SVG canvas.
svg.selectAll("*").remove();
if(map[modesKey] & DELETEMARKERS) {
this.colorMarkersTobeDeleted(latLngs);
} else {
// DISTANCE CALCULATION POLYLINE GENERATION LOGIC:
this.distanceLineTobeCreated(latLngs);
}
};
// Clear up the events when the user releases the mouse.
map.on("mouseup touchend", mouseUp);
"body" in document &&
document.body.addEventListener("mouseleave", mouseUp);
// Setup the function to invoke when `cancel` has been invoked.
map[cancelKey] = () => mouseUp({}, false);
return;
}
if (!(map[modesKey] & CREATE)) {
// Polygons can only be created when the mode includes create.
return;
}
/**
* @constant latLngs
* @type {Set}
*/
const latLngs = new Set();
// Create the line iterator and move it to its first `yield` point, passing in the start point
// from the mouse down event.
const lineIterator = this.createPath(
svg,
map.latLngToContainerPoint(event.latlng),
options.strokeWidth
);
/**
* @method mouseMove
* @param {Object} event
* @return {void}
*/
const mouseMove = event => {
// Resolve the pixel point to the latitudinal and longitudinal equivalent.
const point = map.mouseEventToContainerPoint(event.originalEvent);
// Push each lat/lng value into the points set.
latLngs.add(map.containerPointToLatLng(point));
// Invoke the generator by passing in the starting point for the path.
lineIterator(new Point(point.x, point.y));
};
// Create the path when the user moves their cursor.
map.on("mousemove touchmove", mouseMove);
/**
* @method mouseUp
* @param {Boolean} [create = true]
* @return {Function}
*/
const mouseUp = async (_, create = true) => {
// Remove the ability to invoke `cancel`.
map[cancelKey] = () => {};
// Stop listening to the events.
map.off("mouseup", mouseUp);
map.off("mousemove", mouseMove);
"body" in document &&
document.body.removeEventListener("mouseleave", mouseUp);
// Clear the SVG canvas.
svg.selectAll("*").remove();
if (create) {
// ...And finally if we have any lat/lngs in our set then we can attempt to
// create the polygon.
if(latLngs.size >= 3) {
const response = await pubSub.publish("create-start");
if (response && response.interrupt) {
return;
}
createFor(map, latLngsToTuple(Array.from(latLngs)), options);
// Finally invoke the callback for the polygon regions.
updateFor(map, "create");
pubSub.publish("create-end");
// Exit the `CREATE` mode if the options permit it.
options.leaveModeAfterCreate && this.mode(this.mode() ^ CREATE);
}
}
};
// Clear up the events when the user releases the mouse.
map.on("mouseup touchend", mouseUp);
"body" in document &&
document.body.addEventListener("mouseleave", mouseUp);
// Setup the function to invoke when `cancel` has been invoked.
map[cancelKey] = () => mouseUp({}, false);
};
map.on("mousedown touchstart", mouseDown);
}
distanceLineTobeCreated(latLngs) {
if (!latLngs || latLngs.size < 2) {
return;
}
latLngs = Array.from(latLngs).map(model => [model.lat, model.lng]);
console.log("coordinates", latLngs);
const polyline = L.polyline(latLngs, {color: 'red'}).addTo(this.map);
}
colorMarkersTobeDeleted(latLngs) {
if (!latLngs || latLngs.size < 3) {
return;
}
latLngs = Array.from(latLngs).map(model => [model.lat, model.lng]);
const toTurfPolygon = compose(
createPolygon,
x => [x],
x => [...x, head(x)]
);
const turfPolygon = toTurfPolygon(Array.from(latLngs));
const allPolygons = this.all();
Array.from(allPolygons).map(p => {
const latLngArr = p[rawLatLngKey].map(model => [model[0], model[1]]);
const turfPoints = turf.points(latLngArr);
const containedMarkers = pointsWithinPolygon(turfPoints, turfPolygon);
if (containedMarkers.features.length !== 0) {
const selectedMarkers = [];
containedMarkers.features.map(f =>
selectedMarkers.push(f.geometry.coordinates)
);
const newlatLngArr = latLngArr.filter(ll => {
return !selectedMarkers.some(sm => sm === ll);
});
removeFor(this.map, p);
if (newlatLngArr.length > 2) {
p.setLatLngs(newlatLngArr);
const latLngs = p.getLatLngs()[0];
createFor(this.map, latLngsToTuple([...latLngs, latLngs[0]]), this.options, true, p[polygonID], 0);
} else {
undoMainStack.push(p[polygonID]);
undoStackObject[p[polygonID]].push(null);
}
}
pubSub.publish('edit-end');
return p;
});
}
/**
* @method createPath
* @param {Object} svg
* @param {Point} fromPoint
* @param {Number} strokeWidth
* @return {void}
*/
createPath(svg, fromPoint, strokeWidth) {
let lastPoint = fromPoint;
const lineFunction = line()
.curve(curveMonotoneX)
.x(d => d.x)
.y(d => d.y);
return toPoint => {
const lineData = [lastPoint, toPoint];
lastPoint = toPoint;
// Draw SVG line based on the last movement of the mouse's position.
svg
.append("path")
.classed("leaflet-line", true)
.attr("d", lineFunction(lineData))
.attr("fill", "none")
.attr("stroke", "black")
.attr("stroke-width", strokeWidth);
};
}
}
/**
* @method freeDraw
* @return {Object}
*/
export const freeDraw = options => {
return new FreeDraw(options);
};
export {
CREATE,
EDIT,
DELETE,
APPEND,
EDIT_APPEND,
NONE,
ALL,
DELETEMARKERS,
DELETEPOINT,
DISTANCE_FLAG
} from "./helpers/Flags";
export const clickUndo = (map) => {
undoHandler(map);
}
export const clickRedo = (map) => {
redoHandler(map);
}
export * from './integrations';