Compare commits

..
2 Commits
Author SHA1 Message Date
randy b0576f3725 Make Measure's vertices draggable and deletable
CI / deploy (push) Successful in 12s
CI / check (push) Successful in 40s
Drag: mousedown on a vertex circle calls event.preventDefault() (the
documented way to opt a gesture out of MapLibre's own drag-to-pan),
then a map-wide mousemove/mouseup follows the cursor and updates that
point's coordinates live. Delete: clicking a vertex selects it (shown
larger/yellow); Delete or Backspace removes it, guarded against
eating a keystroke meant for an actual text input on the page.

A vertex's own mousedown sets a one-shot flag so the same click
doesn't also fall through to the generic map-click handler and add a
brand new point on top of it.
2026-09-09 12:47:44 +02:00
randy b5b91d0df5 Gate note-writing UI behind login
Adding a note (either tool) always ended in a 401 for a logged-out
viewer — the write-only affordance just wasn't hidden yet, unlike
GpxPanelControl's own upload/new-folder buttons, which already are.

NotesControl isn't added to the map at all when logged out (its whole
purpose is writing); MeasureControl's "Save as note" button is hidden
but the tool itself stays usable for plain measuring either way.
2026-09-09 12:44:52 +02:00
4 changed files with 98 additions and 4 deletions

No files matched your search

+2
View File
@@ -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] 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] 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] 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 ** [ ] 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 * [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
+6
View File
@@ -717,6 +717,12 @@ html, body {
cursor: default; cursor: default;
} }
/* Same "author display wins over the UA [hidden] rule" gotcha as
.gpx-panel__action[hidden] elsewhere in this file. */
.measure-panel__save[hidden] {
display: none;
}
.measure-panel .notes-panel__form { .measure-panel .notes-panel__form {
width: 200px; width: 200px;
} }
+6 -1
View File
@@ -14,6 +14,7 @@ import { NotesControl } from "/js/notes.js";
import { LocateControl } from "/js/locate.js"; import { LocateControl } from "/js/locate.js";
import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js"; import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { isLoggedIn } from "/js/auth.js";
import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js";
import OverviewMap from "/vendor/npmap-overview/overview.esm.js"; import OverviewMap from "/vendor/npmap-overview/overview.esm.js";
import * as maplibregl from "/vendor/maplibre-gl/maplibre-gl.mjs"; import * as maplibregl from "/vendor/maplibre-gl/maplibre-gl.mjs";
@@ -501,7 +502,11 @@ map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left");
map.addControl(new NavigationControl({ showCompass: false }), "top-left"); map.addControl(new NavigationControl({ showCompass: false }), "top-left");
map.addControl(new ZoomLevelControl(), "top-left"); map.addControl(new ZoomLevelControl(), "top-left");
map.addControl(new MeasureControl(), "top-left"); map.addControl(new MeasureControl(), "top-left");
map.addControl(new NotesControl(), "top-left"); // Writing a note needs a login (POST /api/gpx/notes) — no point adding a
// button that can only ever end in a 401 for a logged-out viewer, same
// "hide the write-only affordance" story as GpxPanelControl's own
// upload/new-folder buttons.
if (isLoggedIn()) map.addControl(new NotesControl(), "top-left");
// Constructed here (rather than down where it's mounted, alongside the // Constructed here (rather than down where it's mounted, alongside the
// other sidebar controls) so LocateControl can hold the same instance — // other sidebar controls) so LocateControl can hold the same instance —
// its constructor only touches `map`, mount() (which needs the sidebar // its constructor only touches `map`, mount() (which needs the sidebar
+84 -3
View File
@@ -1,5 +1,6 @@
import { RULER_ICON } from "/js/icons.js"; import { RULER_ICON } from "/js/icons.js";
import { NoteSaveForm } from "/js/notes.js"; import { NoteSaveForm } from "/js/notes.js";
import { isLoggedIn } from "/js/auth.js";
const SOURCE_ID = "measure"; const SOURCE_ID = "measure";
const LINE_LAYER_ID = "measure-line"; const LINE_LAYER_ID = "measure-line";
@@ -20,6 +21,9 @@ export class MeasureControl {
this._mode = "distance"; this._mode = "distance";
this._active = false; this._active = false;
this._points = []; 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) { onAdd(map) {
@@ -44,6 +48,10 @@ export class MeasureControl {
this._controls = this._container.querySelector(".measure-panel__controls"); this._controls = this._container.querySelector(".measure-panel__controls");
this._result = this._container.querySelector(".measure-panel__result"); this._result = this._container.querySelector(".measure-panel__result");
this._saveButton = this._container.querySelector(".measure-panel__save"); this._saveButton = this._container.querySelector(".measure-panel__save");
// Same "hide the write-only affordance for a logged-out viewer" story
// as notes.js's own button not being added at all — Measure itself
// stays available either way, only saving needs a login.
if (!isLoggedIn()) this._saveButton.hidden = true;
this._noteForm = new NoteSaveForm(this._panel, { this._noteForm = new NoteSaveForm(this._panel, {
onSaved: () => { onSaved: () => {
@@ -62,11 +70,72 @@ export class MeasureControl {
this._onClick = (event) => { this._onClick = (event) => {
if (!this._active || this._noteForm.isOpen) return; 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._points.push([event.lngLat.lng, event.lngLat.lat]);
this._render(); this._render();
}; };
map.on("click", this._onClick); 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 — // setStyle() (switching Online/Offline) tears down all sources/layers —
// same re-add-on-reload pattern as radar.js/gpx.js. // same re-add-on-reload pattern as radar.js/gpx.js.
map.on("style.load", () => { map.on("style.load", () => {
@@ -78,6 +147,10 @@ export class MeasureControl {
onRemove() { onRemove() {
this._map.off("click", this._onClick); 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._removeLayers();
this._container.remove(); this._container.remove();
} }
@@ -124,11 +197,14 @@ export class MeasureControl {
button.classList.toggle("active", button.dataset.mode === mode); button.classList.toggle("active", button.dataset.mode === mode);
}); });
this._points = []; this._points = [];
this._selectedIndex = null;
this._render(); this._render();
} }
_clear() { _clear() {
this._points = []; this._points = [];
this._selectedIndex = null;
this._draggingIndex = null;
this._render(); this._render();
} }
@@ -162,7 +238,12 @@ export class MeasureControl {
type: "circle", type: "circle",
source: SOURCE_ID, source: SOURCE_ID,
filter: ["==", ["get", "role"], "vertex"], 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 // Distance mode only (see _render) — each segment's own length, not
// just the running total, matching how a typical measure tool // just the running total, matching how a typical measure tool
@@ -195,9 +276,9 @@ export class MeasureControl {
const source = this._map.getSource(SOURCE_ID); const source = this._map.getSource(SOURCE_ID);
if (!source) return; if (!source) return;
const pointFeatures = this._points.map((coord) => ({ const pointFeatures = this._points.map((coord, index) => ({
type: "Feature", type: "Feature",
properties: { role: "vertex" }, properties: { role: "vertex", index, selected: index === this._selectedIndex },
geometry: { type: "Point", coordinates: coord }, geometry: { type: "Point", coordinates: coord },
})); }));
const geometry = this._currentShapeGeometry(); const geometry = this._currentShapeGeometry();