mapbox-gl-draw-snap-mode
Version:
Snapping mode for mapbox-gl-draw
510 lines (425 loc) • 14.3 kB
JavaScript
// Heavily inspired from work of @davidgilbertson on Github and `leaflet-geoman` project.
import MapboxDraw from "@mapbox/mapbox-gl-draw";
const { geojsonTypes } = MapboxDraw.constants;
import bboxPolygon from "@turf/bbox-polygon";
import booleanDisjoint from "@turf/boolean-disjoint";
import { getCoords } from "@turf/invariant";
import distance from "@turf/distance";
import polygonToLine from "@turf/polygon-to-line";
import nearestPointOnLine from "@turf/nearest-point-on-line";
import nearestPointInPointSet from "@turf/nearest-point";
import midpoint from "@turf/midpoint";
import {
featureCollection,
lineString as turfLineString,
point as turfPoint,
} from "@turf/helpers";
export const IDS = {
VERTICAL_GUIDE: "VERTICAL_GUIDE",
HORIZONTAL_GUIDE: "HORIZONTAL_GUIDE",
};
export const addPointToVertices = (
map,
vertices,
coordinates,
forceInclusion
) => {
const { width: w, height: h } = map.getCanvas();
// Just add vertices of features currently visible in viewport
const { x, y } = map.project(coordinates);
const pointIsOnTheScreen = x > 0 && x < w && y > 0 && y < h;
// But do add off-screen points if forced (e.g. for the current feature)
// So features will always snap to their own points
if (pointIsOnTheScreen || forceInclusion) {
vertices.push(coordinates);
}
};
export const createSnapList = (map, draw, currentFeature, getFeatures) => {
// Get all features
let features = [];
if (typeof getFeatures === "function") {
features = getFeatures(map, draw);
}
if (!Array.isArray(features) || features.length === 0) {
features = draw.getAll().features;
}
const snapList = [];
// Get current bbox as polygon
const bboxAsPolygon = (() => {
const canvas = map.getCanvas(),
w = canvas.width,
h = canvas.height,
cUL = map.unproject([0, 0]).toArray(),
cUR = map.unproject([w, 0]).toArray(),
cLR = map.unproject([w, h]).toArray(),
cLL = map.unproject([0, h]).toArray();
return bboxPolygon([cLL, cUR].flat());
})();
const vertices = [];
// Keeps vertices for drawing guides
const addVerticesToVertices = (coordinates, isCurrentFeature = false) => {
if (!Array.isArray(coordinates)) throw Error("Your array is not an array");
if (Array.isArray(coordinates[0])) {
// coordinates is an array of arrays, we must go deeper
coordinates.forEach((coord) => {
addVerticesToVertices(coord);
});
} else {
// If not an array of arrays, only consider arrays with two items
if (coordinates.length === 2) {
addPointToVertices(map, vertices, coordinates, isCurrentFeature);
}
}
};
features.forEach((feature) => {
// For current feature
if (feature.id === currentFeature.id) {
if (currentFeature.type === geojsonTypes.POLYGON) {
// For the current polygon, the last two points are the mouse position and back home
// so we chop those off (else we get vertices showing where the user clicked, even
// if they were just panning the map)
addVerticesToVertices(
feature.geometry.coordinates[0].slice(0, -2),
true
);
}
return;
}
// If this is re-running because a user is moving the map, the features might include
// vertices or the last leg of a polygon
if (
feature.id === IDS.HORIZONTAL_GUIDE ||
feature.id === IDS.VERTICAL_GUIDE
)
return;
addVerticesToVertices(feature.geometry.coordinates);
// If feature is currently on viewport add to snap list
if (!booleanDisjoint(bboxAsPolygon, feature)) {
snapList.push(feature);
}
});
return [snapList, vertices];
};
const getNearbyVertices = (vertices, coords) => {
const verticals = [];
const horizontals = [];
vertices.forEach((vertex) => {
verticals.push(vertex[0]);
horizontals.push(vertex[1]);
});
const nearbyVerticalGuide = verticals.find(
(px) => Math.abs(px - coords.lng) < 0.009
);
const nearbyHorizontalGuide = horizontals.find(
(py) => Math.abs(py - coords.lat) < 0.009
);
return {
verticalPx: nearbyVerticalGuide,
horizontalPx: nearbyHorizontalGuide,
};
};
const calcLayerDistances = (lngLat, layer) => {
// the point P which we want to snap (probably the marker that is dragged)
const P = [lngLat.lng, lngLat.lat];
// is this a marker?
const isMarker = layer.geometry.type === "Point";
// is it a polygon?
const isPolygon = layer.geometry.type === "Polygon";
// is it a multiPolygon?
const isMultiPolygon = layer.geometry.type === "MultiPolygon";
// is it a multiPoint?
const isMultiPoint = layer.geometry.type === "MultiPoint";
let lines = undefined;
// the coords of the layer
const latlngs = getCoords(layer);
if (isMarker) {
const [lng, lat] = latlngs;
// return the info for the marker, no more calculations needed
return {
latlng: { lng, lat },
distance: distance(latlngs, P),
};
}
if (isMultiPoint) {
const np = nearestPointInPointSet(
P,
featureCollection(latlngs.map((x) => turfPoint(x)))
);
const c = np.geometry.coordinates;
return {
latlng: { lng: c[0], lat: c[1] },
distance: np.properties.distanceToPoint,
};
}
if (isPolygon || isMultiPolygon) {
lines = polygonToLine(layer);
} else {
lines = layer;
}
let nearestPoint;
if (isPolygon) {
let lineStrings;
if (lines.geometry.type === "LineString") {
lineStrings = [turfLineString(lines.geometry.coordinates)];
} else {
lineStrings = lines.geometry.coordinates.map((coords) =>
turfLineString(coords)
);
}
const closestFeature = getFeatureWithNearestPoint(lineStrings, P);
lines = closestFeature.feature;
nearestPoint = closestFeature.point;
} else if (isMultiPolygon) {
const lineStrings = lines.features
.map((feat) => {
if (feat.geometry.type === "LineString") {
return [feat.geometry.coordinates];
} else {
return feat.geometry.coordinates;
}
})
.flatMap((coords) => coords)
.map((coords) => turfLineString(coords));
const closestFeature = getFeatureWithNearestPoint(lineStrings, P);
lines = closestFeature.feature;
nearestPoint = closestFeature.point;
} else {
nearestPoint = nearestPointOnLine(lines, P);
}
const [lng, lat] = nearestPoint.geometry.coordinates;
let segmentIndex = nearestPoint.properties.index;
let { coordinates } = lines.geometry;
if (lines.geometry.type === "MultiLineString") {
coordinates =
lines.geometry.coordinates[nearestPoint.properties.multiFeatureIndex];
}
if (segmentIndex + 1 === coordinates.length) segmentIndex--;
return {
latlng: { lng, lat },
segment: coordinates.slice(segmentIndex, segmentIndex + 2),
distance: nearestPoint.properties.dist,
isMarker,
};
};
function getFeatureWithNearestPoint(lineStrings, P) {
const nearestPointsOfEachFeature = lineStrings.map((feat) => ({
feature: feat,
point: nearestPointOnLine(feat, P),
}));
nearestPointsOfEachFeature.sort(
(a, b) => a.point.properties.dist - b.point.properties.dist
);
return {
feature: nearestPointsOfEachFeature[0].feature,
point: nearestPointsOfEachFeature[0].point,
};
}
const calcClosestLayer = (lngLat, layers) => {
let closestLayer = {};
// loop through the layers
layers.forEach((layer, index) => {
// find the closest latlng, segment and the distance of this layer to the dragged marker latlng
const results = calcLayerDistances(lngLat, layer);
// save the info if it doesn't exist or if the distance is smaller than the previous one
if (
closestLayer.distance === undefined ||
results.distance < closestLayer.distance
) {
closestLayer = results;
closestLayer.layer = layer;
}
});
// return the closest layer and it's data
// if there is no closest layer, return undefined
return closestLayer;
};
// minimal distance before marker snaps (in pixels)
const metersPerPixel = function (latitude, zoomLevel) {
const earthCircumference = 40075017;
const latitudeRadians = latitude * (Math.PI / 180);
return (
(earthCircumference * Math.cos(latitudeRadians)) /
Math.pow(2, zoomLevel + 8)
);
};
// we got the point we want to snap to (C), but we need to check if a coord of the polygon
function snapToLineOrPolygon(
closestLayer,
snapOptions,
snapVertexPriorityDistance
) {
// A and B are the points of the closest segment to P (the marker position we want to snap)
const A = closestLayer.segment[0];
const B = closestLayer.segment[1];
// C is the point we would snap to on the segment.
// The closest point on the closest segment of the closest polygon to P. That's right.
const C = [closestLayer.latlng.lng, closestLayer.latlng.lat];
// distances from A to C and B to C to check which one is closer to C
const distanceAC = distance(A, C);
const distanceBC = distance(B, C);
// closest latlng of A and B to C
let closestVertexLatLng = distanceAC < distanceBC ? A : B;
// distance between closestVertexLatLng and C
let shortestDistance = distanceAC < distanceBC ? distanceAC : distanceBC;
// snap to middle (M) of segment if option is enabled
if (snapOptions && snapOptions.snapToMidPoints) {
const M = midpoint(A, B).geometry.coordinates;
const distanceMC = distance(M, C);
if (distanceMC < distanceAC && distanceMC < distanceBC) {
// M is the nearest vertex
closestVertexLatLng = M;
shortestDistance = distanceMC;
}
}
// the distance that needs to be undercut to trigger priority
const priorityDistance = snapVertexPriorityDistance;
// the latlng we ultimately want to snap to
let snapLatlng;
// if C is closer to the closestVertexLatLng (A, B or M) than the snapDistance,
// the closestVertexLatLng has priority over C as the snapping point.
if (shortestDistance < priorityDistance) {
snapLatlng = closestVertexLatLng;
} else {
snapLatlng = C;
}
// return the copy of snapping point
const [lng, lat] = snapLatlng;
return { lng, lat };
}
function snapToPoint(closestLayer) {
return closestLayer.latlng;
}
const checkPrioritySnapping = (
closestLayer,
snapOptions,
snapVertexPriorityDistance = 1.25
) => {
let snappingToPoint = !Array.isArray(closestLayer.segment);
if (snappingToPoint) {
return snapToPoint(closestLayer);
} else {
return snapToLineOrPolygon(
closestLayer,
snapOptions,
snapVertexPriorityDistance
);
}
};
/**
* Returns snap points if there are any, otherwise the original lng/lat of the event
* Also, defines if vertices should show on the state object
*
* Mutates the state object
*
* @param state
* @param e
* @returns {{lng: number, lat: number}}
*/
export const snap = (state, e) => {
let lng = e.lngLat.lng;
let lat = e.lngLat.lat;
// Holding alt bypasses all snapping
if (e.originalEvent.altKey) {
state.showVerticalSnapLine = false;
state.showHorizontalSnapLine = false;
return { lng, lat };
}
if (state.snapList.length <= 0) {
return { lng, lat };
}
// snapping is on
let closestLayer, minDistance, snapLatLng;
if (state.options.snap) {
closestLayer = calcClosestLayer({ lng, lat }, state.snapList);
// if no layers found. Can happen when circle is the only visible layer on the map and the hidden snapping-border circle layer is also on the map
if (Object.keys(closestLayer).length === 0) {
return false;
}
const isMarker = closestLayer.isMarker;
const snapVertexPriorityDistance = state.options.snapOptions
? state.options.snapOptions.snapVertexPriorityDistance
: undefined;
if (!isMarker) {
snapLatLng = checkPrioritySnapping(
closestLayer,
state.options.snapOptions,
snapVertexPriorityDistance
);
// snapLatLng = closestLayer.latlng;
} else {
snapLatLng = closestLayer.latlng;
}
minDistance =
((state.options.snapOptions && state.options.snapOptions.snapPx) || 15) *
metersPerPixel(snapLatLng.lat, state.map.getZoom());
}
let verticalPx, horizontalPx;
if (state.options.guides) {
const nearestGuideline = getNearbyVertices(state.vertices, e.lngLat);
verticalPx = nearestGuideline.verticalPx;
horizontalPx = nearestGuideline.horizontalPx;
if (verticalPx) {
// Draw a line from top to bottom
const lngLatTop = { lng: verticalPx, lat: e.lngLat.lat + 10 };
const lngLatBottom = { lng: verticalPx, lat: e.lngLat.lat - 10 };
state.verticalGuide.updateCoordinate(0, lngLatTop.lng, lngLatTop.lat);
state.verticalGuide.updateCoordinate(
1,
lngLatBottom.lng,
lngLatBottom.lat
);
}
if (horizontalPx) {
// Draw a line from left to right
const lngLatTop = { lng: e.lngLat.lng + 10, lat: horizontalPx };
const lngLatBottom = { lng: e.lngLat.lng - 10, lat: horizontalPx };
state.horizontalGuide.updateCoordinate(0, lngLatTop.lng, lngLatTop.lat);
state.horizontalGuide.updateCoordinate(
1,
lngLatBottom.lng,
lngLatBottom.lat
);
}
state.showVerticalSnapLine = !!verticalPx;
state.showHorizontalSnapLine = !!horizontalPx;
}
if (closestLayer && closestLayer.distance * 1000 < minDistance) {
return snapLatLng;
} else if (verticalPx || horizontalPx) {
if (verticalPx) {
lng = verticalPx;
}
if (horizontalPx) {
lat = horizontalPx;
}
return { lng, lat };
} else {
return { lng, lat };
}
};
export const getGuideFeature = (id) => ({
id,
type: geojsonTypes.FEATURE,
properties: {
isSnapGuide: "true", // for styling
},
geometry: {
type: geojsonTypes.LINE_STRING,
coordinates: [],
},
});
export const shouldHideGuide = (state, geojson) => {
if (
geojson.properties.id === IDS.VERTICAL_GUIDE &&
(!state.options.guides || !state.showVerticalSnapLine)
) {
return true;
}
if (
geojson.properties.id === IDS.HORIZONTAL_GUIDE &&
(!state.options.guides || !state.showHorizontalSnapLine)
) {
return true;
}
return false;
};