From dfeead73afd86aa19b4dcfe6e5aca1030538e7e6 Mon Sep 17 00:00:00 2001 From: Dustin Carlino Date: Fri, 22 Mar 2024 11:37:41 +0000 Subject: [PATCH] Use freehand polygon tool for a common repo --- package-lock.json | 12 + package.json | 1 + src/lib/draw/polygon/PolygonControls.svelte | 2 +- src/lib/draw/polygon/PolygonToolLayer.svelte | 2 +- src/lib/draw/polygon/polygon_tool.ts | 367 ------------------- src/lib/draw/polygon/stores.ts | 8 - src/lib/draw/stores.ts | 2 +- src/lib/sidebar/LeftSidebar.svelte | 2 +- 8 files changed, 17 insertions(+), 379 deletions(-) delete mode 100644 src/lib/draw/polygon/polygon_tool.ts delete mode 100644 src/lib/draw/polygon/stores.ts diff --git a/package-lock.json b/package-lock.json index 8d2d5ff2c..8522ca73d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "govuk-svelte": "github:acteng/govuk-svelte", "humanize-string": "^3.0.0", "js-cookie": "^3.0.5", + "maplibre-draw-polygon": "github:dabreegster/maplibre-draw-polygon", "maplibre-gl": "^4.0.2", "read-excel-file": "^5.7.1", "route-snapper": "^0.3.0", @@ -2552,6 +2553,17 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/maplibre-draw-polygon": { + "version": "0.0.1", + "resolved": "git+ssh://git@github.com/dabreegster/maplibre-draw-polygon.git#7230f333701b2663a77df9cb6bf375f35c0a9d61", + "dependencies": { + "@turf/nearest-point-on-line": "^6.5.0", + "svelte-maplibre": "^0.8.2" + }, + "peerDependencies": { + "svelte": "^4.0.0" + } + }, "node_modules/maplibre-gl": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.0.2.tgz", diff --git a/package.json b/package.json index e51f0bd88..126819578 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "govuk-svelte": "github:acteng/govuk-svelte", "humanize-string": "^3.0.0", "js-cookie": "^3.0.5", + "maplibre-draw-polygon": "github:dabreegster/maplibre-draw-polygon", "maplibre-gl": "^4.0.2", "read-excel-file": "^5.7.1", "route-snapper": "^0.3.0", diff --git a/src/lib/draw/polygon/PolygonControls.svelte b/src/lib/draw/polygon/PolygonControls.svelte index f93e92da4..19c2496df 100644 --- a/src/lib/draw/polygon/PolygonControls.svelte +++ b/src/lib/draw/polygon/PolygonControls.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/draw/polygon/polygon_tool.ts b/src/lib/draw/polygon/polygon_tool.ts deleted file mode 100644 index 40f1559f3..000000000 --- a/src/lib/draw/polygon/polygon_tool.ts +++ /dev/null @@ -1,367 +0,0 @@ -import nearestPointOnLine from "@turf/nearest-point-on-line"; -import type { Feature, LineString, Point, Polygon, Position } from "geojson"; -import { - emptyGeojson, - pointFeature, - setPrecision, - type FeatureWithProps, -} from "lib/maplibre"; -import type { Map, MapLayerMouseEvent, MapMouseEvent } from "maplibre-gl"; -import { polygonToolGj, undoLength } from "./stores"; - -const maxPreviousStates = 100; - -export class PolygonTool { - map: Map; - active: boolean; - eventListenersSuccess: ((f: FeatureWithProps) => void)[]; - eventListenersUpdated: ((f: FeatureWithProps) => void)[]; - eventListenersFailure: (() => void)[]; - points: Position[]; - cursor: Feature | null; - // The number is an index into points - hover: "polygon" | number | null; - dragFrom: Position | null; - // Storing a full copy of the previous points is sufficient - previousStates: Position[][]; - - // TODO Inconsistent ordering with point tool - constructor(map: Map) { - this.map = map; - this.active = false; - this.eventListenersSuccess = []; - this.eventListenersUpdated = []; - this.eventListenersFailure = []; - - // This doesn't repeat the first point at the end; it's not closed - this.points = []; - this.cursor = null; - // TODO This is lots of state. Consider - // https://maplibre.org/maplibre-gl-js-docs/example/drag-a-point/ or port - // widgetry's World - this.hover = null; - this.dragFrom = null; - this.previousStates = []; - - this.map.on("mousemove", this.onMouseMove); - this.map.on("click", this.onClick); - this.map.on("dblclick", this.onDoubleClick); - this.map.on("mousedown", this.onMouseDown); - this.map.on("mouseup", this.onMouseUp); - document.addEventListener("keypress", this.onKeypress); - document.addEventListener("keydown", this.onKeyDown); - } - - tearDown() { - this.map.off("mousemove", this.onMouseMove); - this.map.off("click", this.onClick); - this.map.off("dblclick", this.onDoubleClick); - this.map.off("mousedown", this.onMouseDown); - this.map.off("mouseup", this.onMouseUp); - document.removeEventListener("keypress", this.onKeypress); - document.removeEventListener("keydown", this.onKeyDown); - } - - // Either a success or failure event will happen, depending on current state - finish() { - let polygon = this.polygonFeature(); - if (polygon) { - // TODO RouteTool passes a copy to each callback for paranoia. Should we - // do the same everywhere here? - for (let cb of this.eventListenersSuccess) { - cb(polygon); - } - } else { - for (let cb of this.eventListenersFailure) { - cb(); - } - } - this.stop(); - } - - // This stops the tool and fires a failure event - cancel() { - for (let cb of this.eventListenersFailure) { - cb(); - } - this.stop(); - } - - onMouseMove = (e: MapMouseEvent) => { - // Don't call beforeUpdate here; just consider drag as one entire action - if (this.active && !this.dragFrom) { - this.recalculateHovering(e); - } else if (this.active && this.dragFrom) { - if (this.hover == "polygon") { - // Move entire polygon - let dx = this.dragFrom[0] - e.lngLat.lng; - let dy = this.dragFrom[1] - e.lngLat.lat; - for (let pt of this.points) { - pt[0] -= dx; - pt[1] -= dy; - } - } else { - this.points[this.hover as number] = e.lngLat.toArray(); - } - this.dragFrom = e.lngLat.toArray(); - this.redraw(); - } - }; - - onClick = (e: MapMouseEvent) => { - this.beforeUpdate(); - if (this.active && this.cursor) { - // Insert the new point in the "middle" of the closest line segment - let candidates: [number, number][] = []; - pointsToLineSegments(this.points).forEach((line, idx) => { - candidates.push([ - idx + 1, - nearestPointOnLine(line, this.cursor!).properties.dist!, - ]); - }); - candidates.sort((a, b) => a[1] - b[1]); - - if (candidates.length > 0) { - let idx = candidates[0][0]; - this.points.splice(idx, 0, this.cursor.geometry.coordinates); - this.hover = idx; - } else { - this.points.push(this.cursor.geometry.coordinates); - this.hover = this.points.length - 1; - } - this.redraw(); - this.pointsUpdated(); - } else if (this.active && typeof this.hover === "number") { - this.points.splice(this.hover, 1); - this.hover = null; - this.redraw(); - this.pointsUpdated(); - // TODO Doesn't seem to work; you still have to move the mouse to hover - // on the polygon - this.recalculateHovering(e); - } - }; - - onDoubleClick = (e: MapMouseEvent) => { - if (!this.active) { - return; - } - // When we finish, we'll re-enable doubleClickZoom, but we don't want this to zoom in - e.preventDefault(); - // Double clicks happen as [click, click, dblclick]. The first click adds a - // point, the second immediately deletes it, and so we simulate a third - // click to add it again. - // TODO But since the delete case currently doesn't set cursor during recalculateHovering, do this hack - this.cursor = pointFeature(e.lngLat.toArray()); - this.onClick(e); - this.finish(); - }; - - onMouseDown = (e: MapMouseEvent) => { - if (this.active && !this.dragFrom && this.hover != null) { - e.preventDefault(); - this.cursor = null; - this.dragFrom = e.lngLat.toArray(); - // TODO If no drag actually happens, this'll record a useless edit - this.beforeUpdate(); - this.redraw(); - } - }; - - onMouseUp = () => { - if (this.active && this.dragFrom) { - this.dragFrom = null; - this.redraw(); - this.pointsUpdated(); - } - }; - - onKeypress = (e: KeyboardEvent) => { - if (!this.active) { - return; - } - if (e.key == "Enter") { - e.stopPropagation(); - this.finish(); - } else if (e.key == "z" && e.ctrlKey) { - this.undo(); - } - }; - - onKeyDown = (e: KeyboardEvent) => { - if (!this.active) { - return; - } - if (e.key == "Escape") { - e.stopPropagation(); - this.cancel(); - } - }; - - addEventListenerSuccess(callback: (f: FeatureWithProps) => void) { - this.eventListenersSuccess.push(callback); - } - addEventListenerUpdated(callback: (f: FeatureWithProps) => void) { - this.eventListenersUpdated.push(callback); - } - addEventListenerFailure(callback: () => void) { - this.eventListenersFailure.push(callback); - } - clearEventListeners() { - this.eventListenersSuccess = []; - this.eventListenersUpdated = []; - this.eventListenersFailure = []; - } - - startNew() { - this.active = true; - // Otherwise, double clicking to finish breaks - this.map.doubleClickZoom.disable(); - } - - editExisting(feature: Feature) { - this.active = true; - this.map.doubleClickZoom.disable(); - this.points = JSON.parse(JSON.stringify(feature.geometry.coordinates[0])); - this.points.pop(); - this.redraw(); - // TODO recalculateHovering, but we need to know where the mouse is - } - - stop() { - this.map.doubleClickZoom.enable(); - this.points = []; - this.cursor = null; - this.active = false; - this.hover = null; - this.dragFrom = null; - this.previousStates = []; - this.redraw(); - this.map.getCanvas().style.cursor = "inherit"; - } - - undo() { - if (this.dragFrom != null || this.previousStates.length == 0) { - return; - } - this.points = this.previousStates.pop()!; - this.hover = null; - this.redraw(); - } - - private redraw() { - let gj = emptyGeojson(); - - this.points.forEach((pt, idx) => { - let f = pointFeature(pt); - f.properties!.hover = this.hover == idx; - f.properties!.idx = idx; - gj.features.push(f); - }); - - gj.features = gj.features.concat(pointsToLineSegments(this.points)); - - let polygon = this.polygonFeature(); - if (polygon) { - polygon.properties!.hover = this.hover == "polygon"; - gj.features.push(polygon); - } - - polygonToolGj.set(gj); - let cursorStyle = "crosshair"; - if (this.hover != null) { - cursorStyle = this.dragFrom ? "grabbing" : "pointer"; - } - this.map.getCanvas().style.cursor = cursorStyle; - - undoLength.set(this.previousStates.length); - } - - // If there's a valid polygon, also passes to eventListenersUpdated - private pointsUpdated() { - let polygon = this.polygonFeature(); - if (polygon) { - for (let cb of this.eventListenersUpdated) { - cb(polygon); - } - } - } - - private recalculateHovering(e: MapLayerMouseEvent) { - this.cursor = null; - this.hover = null; - - // Order of the layers matters! - for (let f of this.map.queryRenderedFeatures(e.point, { - layers: ["edit-polygon-fill", "edit-polygon-vertices"], - })) { - if (f.geometry.type == "Polygon") { - this.hover = "polygon"; - break; - } else if (f.geometry.type == "Point") { - // Ignore the cursor - if (Object.hasOwn(f.properties, "idx")) { - this.hover = f.properties.idx; - break; - } - } - } - if (this.hover == null) { - this.cursor = pointFeature(e.lngLat.toArray()); - } - - this.redraw(); - } - - // TODO Force the proper winding order that geojson requires - private polygonFeature(): FeatureWithProps | null { - if (this.points.length < 3) { - return null; - } - let trimmed = this.points.map(setPrecision); - // Deep clone here, or face the wrath of crazy bugs later! - let coordinates = [JSON.parse(JSON.stringify(trimmed))]; - coordinates[0].push(JSON.parse(JSON.stringify(coordinates[0][0]))); - return { - type: "Feature", - geometry: { - type: "Polygon", - coordinates, - }, - properties: {}, - }; - } - - private beforeUpdate() { - this.previousStates.push(JSON.parse(JSON.stringify(this.points))); - if (this.previousStates.length > maxPreviousStates) { - this.previousStates.shift(); - } - } -} - -// Includes the line connecting the last to the first point -function pointsToLineSegments(points: Position[]): Feature[] { - let lines = []; - for (let i = 0; i < points.length - 1; i++) { - lines.push({ - type: "Feature" as const, - geometry: { - type: "LineString" as const, - coordinates: [points[i], points[i + 1]], - }, - properties: {}, - }); - } - if (points.length >= 3) { - lines.push({ - type: "Feature" as const, - geometry: { - type: "LineString" as const, - coordinates: [points[points.length - 1], points[0]], - }, - properties: {}, - }); - } - return lines; -} diff --git a/src/lib/draw/polygon/stores.ts b/src/lib/draw/polygon/stores.ts deleted file mode 100644 index a4b8ca98c..000000000 --- a/src/lib/draw/polygon/stores.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { GeoJSON } from "geojson"; -import { emptyGeojson } from "lib/maplibre"; -import { writable, type Writable } from "svelte/store"; - -// These are necessary to communicate between components nested under the sidebar and map - -export const polygonToolGj: Writable = writable(emptyGeojson()); -export const undoLength: Writable = writable(0); diff --git a/src/lib/draw/stores.ts b/src/lib/draw/stores.ts index 01ab08125..e766a9485 100644 --- a/src/lib/draw/stores.ts +++ b/src/lib/draw/stores.ts @@ -2,7 +2,7 @@ import { emptyCollection } from "lib/sidebar/scheme_data"; import { writable, type Writable } from "svelte/store"; import type { Mode, SchemeCollection } from "types"; import { PointTool } from "./point/point_tool"; -import { PolygonTool } from "./polygon/polygon_tool"; +import { PolygonTool } from "maplibre-draw-polygon"; import { RouteTool } from "./route/route_tool"; // TODO Should we instead store a map from ID to feature? diff --git a/src/lib/sidebar/LeftSidebar.svelte b/src/lib/sidebar/LeftSidebar.svelte index 903c46225..e3394f2ca 100644 --- a/src/lib/sidebar/LeftSidebar.svelte +++ b/src/lib/sidebar/LeftSidebar.svelte @@ -8,7 +8,7 @@ import ImageMode from "../draw/image/ImageMode.svelte"; import { PointTool } from "../draw/point/point_tool"; import PointMode from "../draw/point/PointMode.svelte"; - import { PolygonTool } from "../draw/polygon/polygon_tool"; + import { PolygonTool } from "maplibre-draw-polygon"; import PolygonMode from "../draw/polygon/PolygonMode.svelte"; import RouteMode from "../draw/route/RouteMode.svelte"; import RouteSnapperLoader from "../draw/route/RouteSnapperLoader.svelte";