diff --git a/TODO.adoc b/TODO.adoc index 0621844..960827a 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -43,6 +43,8 @@ independence from connectivity. * [x] User overlay: offline drawing of custom points with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") — one click on the map, pick or name a `.geojson` layer, saved into the same GpxStore the Tracks panel already reads (so visibility/popup come for free) ** [x] Save a distance/area shape as a note too — Measure already draws these by hand, so a "Save" button there (not a second click-to-draw UI in the notes tool) reuses that instead of duplicating it; both share one `NoteSaveForm` (notes.js). `gpx.js`'s `_addLayers` now renders Polygon and note-flagged LineString features (fill/outline, dashed line), not just points ** [x] Edit/delete individual notes from their popup (logged-in only) — targets a feature by a client-generated `properties.id` (`GpxStore#update_feature`/`#delete_feature`), not array position +** [x] Measure's vertices are draggable (mousedown + move, `event.preventDefault()` to stop the map's own drag-pan) and deletable (click to select — shown in yellow — then Delete/Backspace) +** [x] Gated behind login: NotesControl isn't added to the map at all when logged out, Measure's "Save as note" button is hidden (Measure itself stays usable either way) ** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon/line ones sharing that category * [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping diff --git a/public/js/measure.js b/public/js/measure.js index f452dc8..29e10ec 100644 --- a/public/js/measure.js +++ b/public/js/measure.js @@ -21,6 +21,9 @@ export class MeasureControl { this._mode = "distance"; this._active = false; this._points = []; + this._selectedIndex = null; // which vertex Delete/Backspace acts on + this._draggingIndex = null; + this._suppressNextClick = false; // a mousedown on a vertex already handled this click; don't also add a new point } onAdd(map) { @@ -67,11 +70,72 @@ export class MeasureControl { this._onClick = (event) => { if (!this._active || this._noteForm.isOpen) return; + // A mousedown on an existing vertex (below) already selected/started + // dragging it — without this, that same click would also land here + // and add a brand new point right on top of it. + if (this._suppressNextClick) { + this._suppressNextClick = false; + return; + } + this._selectedIndex = null; this._points.push([event.lngLat.lng, event.lngLat.lat]); this._render(); }; map.on("click", this._onClick); + // Vertex drag: mousedown on a point starts it, a map-wide mousemove + // follows the cursor while dragging, mouseup anywhere ends it — not + // scoped to the layer since the cursor is very likely to leave the + // (few-pixel) vertex circle mid-drag. preventDefault on the mousedown + // is what stops MapLibre's own drag-to-pan from also engaging for the + // same gesture (its documented way to opt out per-interaction). + this._onVertexMouseDown = (event) => { + if (!this._active || this._noteForm.isOpen) return; + const index = event.features[0]?.properties?.index; + if (index === undefined) return; + event.preventDefault(); + this._draggingIndex = index; + this._selectedIndex = index; + this._suppressNextClick = true; + this._map.getCanvas().style.cursor = "grabbing"; + this._render(); + }; + map.on("mousedown", POINT_LAYER_ID, this._onVertexMouseDown); + + this._onMouseMove = (event) => { + if (this._draggingIndex === null) return; + this._points[this._draggingIndex] = [event.lngLat.lng, event.lngLat.lat]; + this._render(); + }; + map.on("mousemove", this._onMouseMove); + + this._onMouseUp = () => { + if (this._draggingIndex === null) return; + this._draggingIndex = null; + this._map.getCanvas().style.cursor = this._active ? "crosshair" : ""; + }; + map.on("mouseup", this._onMouseUp); + + map.on("mouseenter", POINT_LAYER_ID, () => { + if (this._draggingIndex === null) this._map.getCanvas().style.cursor = "grab"; + }); + map.on("mouseleave", POINT_LAYER_ID, () => { + if (this._draggingIndex === null) this._map.getCanvas().style.cursor = this._active ? "crosshair" : ""; + }); + + this._onKeyDown = (event) => { + if (!this._active || this._selectedIndex === null) return; + if (event.key !== "Delete" && event.key !== "Backspace") return; + // Don't eat Backspace while the note-text form (or anything else on + // the page) actually has focus for typing. + if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return; + event.preventDefault(); + this._points.splice(this._selectedIndex, 1); + this._selectedIndex = null; + this._render(); + }; + document.addEventListener("keydown", this._onKeyDown); + // setStyle() (switching Online/Offline) tears down all sources/layers — // same re-add-on-reload pattern as radar.js/gpx.js. map.on("style.load", () => { @@ -83,6 +147,10 @@ export class MeasureControl { onRemove() { this._map.off("click", this._onClick); + this._map.off("mousedown", POINT_LAYER_ID, this._onVertexMouseDown); + this._map.off("mousemove", this._onMouseMove); + this._map.off("mouseup", this._onMouseUp); + document.removeEventListener("keydown", this._onKeyDown); this._removeLayers(); this._container.remove(); } @@ -129,11 +197,14 @@ export class MeasureControl { button.classList.toggle("active", button.dataset.mode === mode); }); this._points = []; + this._selectedIndex = null; this._render(); } _clear() { this._points = []; + this._selectedIndex = null; + this._draggingIndex = null; this._render(); } @@ -167,7 +238,12 @@ export class MeasureControl { type: "circle", source: SOURCE_ID, filter: ["==", ["get", "role"], "vertex"], - paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 }, + paint: { + "circle-radius": ["case", ["get", "selected"], 6, 4], + "circle-color": ["case", ["get", "selected"], "#ffd43b", "#fff"], + "circle-stroke-color": "#1e5fbf", + "circle-stroke-width": 2, + }, }); // Distance mode only (see _render) — each segment's own length, not // just the running total, matching how a typical measure tool @@ -200,9 +276,9 @@ export class MeasureControl { const source = this._map.getSource(SOURCE_ID); if (!source) return; - const pointFeatures = this._points.map((coord) => ({ + const pointFeatures = this._points.map((coord, index) => ({ type: "Feature", - properties: { role: "vertex" }, + properties: { role: "vertex", index, selected: index === this._selectedIndex }, geometry: { type: "Point", coordinates: coord }, })); const geometry = this._currentShapeGeometry();