Consolidate note-drawing into Measure; add edit/delete
Measure already draws distance lines and area polygons by hand — a second click-to-draw UI in the notes tool (Point/Area mode, from the previous commit) duplicated that. Replaced it with a "Save as note" button on the Measure panel that saves whatever shape is currently drawn there; NotesControl goes back to being just a one-click point pin. Both share a single NoteSaveForm (notes.js) for the note-text + target-layer-file UI instead of two copies of it. gpx.js: the "lines" source now also renders a note-flagged LineString (dashed, popup-on-click) alongside the Polygon fill/outline from before — the plain track-line layer excludes category:"Note" features so a saved distance note doesn't also get the track hover-summary/ profile-panel treatment. Notes are now individually editable/deletable from their own popup (logged-in only): Edit swaps the popup content for an inline textarea, Delete confirms and removes. Both target the feature by a new client-generated properties.id rather than array position (new GpxStore#update_feature/#delete_feature, PUT/DELETE /api/gpx/notes), since neither array index nor the note's own text (the thing being edited) is a stable identifier. Verified live: Measure's Save button (disabled/enabled state, saves the exact on-screen shape), the popup's Edit/Delete buttons and inline edit form. Full test suite: 115 runs, 0 failures (new coverage for update_feature/delete_feature and the PUT/DELETE routes).
This commit is contained in:
1 parent
db58468187
commit
aafb4cb4a0
9 files changed
+499
-239
No files matched your search
+111
-205
@@ -2,222 +2,73 @@ import { NOTE_PIN_ICON } from "/js/icons.js";
|
||||
import { notifyGpxStoreChanged } from "/js/panels.js";
|
||||
|
||||
const NEW_LAYER_VALUE = "__new__";
|
||||
const PREVIEW_SOURCE_ID = "notes-preview";
|
||||
const PREVIEW_FILL_LAYER_ID = "notes-preview-fill";
|
||||
const PREVIEW_LINE_LAYER_ID = "notes-preview-line";
|
||||
const PREVIEW_POINT_LAYER_ID = "notes-preview-points";
|
||||
const PREVIEW_COLOR = "#c0392b";
|
||||
|
||||
// A quick "pin it with a note" tool — click the map (Point mode) or click
|
||||
// out a few corners and Finish (Area mode), write a short note, pick which
|
||||
// .geojson "layer" file it joins (or start a new one). Saved server-side
|
||||
// (POST /api/gpx/notes) into the same GpxStore everything else in the
|
||||
// Tracks panel already reads from, so a note shows up there with no
|
||||
// separate rendering path of its own: same tree, same checkbox visibility,
|
||||
// same popup — public/js/gpx.js's _addLayers renders a Polygon feature the
|
||||
// same way it already renders points/lines, no separate code path here.
|
||||
export class NotesControl {
|
||||
constructor() {
|
||||
this._active = false;
|
||||
this._mode = "point"; // "point" | "area"
|
||||
this._areaPoints = []; // vertices while drawing an area, before Finish
|
||||
this._pendingGeometry = null; // the finished Point/Polygon geometry, once placed
|
||||
}
|
||||
// Every hand-drawn note (from here or measure.js's "Save as note") gets one
|
||||
// of these — the only stable way to find a specific feature again for
|
||||
// editing/deleting later (see GpxStore#update_feature/#delete_feature):
|
||||
// array position shifts as other notes are added/removed, and the note's
|
||||
// own text is exactly the thing an edit changes.
|
||||
export function generateNoteId() {
|
||||
return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
onAdd(map) {
|
||||
this._map = map;
|
||||
// The "note text + which .geojson layer" form, shared by NotesControl (a
|
||||
// single click) and measure.js's "Save as note" button (its current
|
||||
// distance/area shape) — extracted here rather than duplicated in both,
|
||||
// since the two differ only in *what* the note attaches a location to,
|
||||
// not in how it's captured or where it's saved. Renders into whatever
|
||||
// container it's given (each caller owns where that sits in its own panel);
|
||||
// hidden until open() is called.
|
||||
export class NoteSaveForm {
|
||||
constructor(container, { onSaved, onCancel } = {}) {
|
||||
this._onSaved = onSaved;
|
||||
this._onCancel = onCancel;
|
||||
|
||||
this._container = document.createElement("div");
|
||||
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group notes-ctrl";
|
||||
this._container.innerHTML = `
|
||||
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
|
||||
<div class="notes-panel" hidden>
|
||||
<div class="notes-panel__modes">
|
||||
<button type="button" class="notes-panel__mode active" data-mode="point">Point</button>
|
||||
<button type="button" class="notes-panel__mode" data-mode="area">Area</button>
|
||||
</div>
|
||||
<p class="notes-panel__hint"></p>
|
||||
<button type="button" class="notes-panel__finish" hidden>Finish shape</button>
|
||||
<div class="notes-panel__form" hidden>
|
||||
<textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea>
|
||||
<select class="notes-panel__target"></select>
|
||||
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
||||
<div class="notes-panel__actions">
|
||||
<button type="button" class="notes-panel__save">Save</button>
|
||||
<button type="button" class="notes-panel__cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
this._el = document.createElement("div");
|
||||
this._el.className = "notes-panel__form";
|
||||
this._el.hidden = true;
|
||||
this._el.innerHTML = `
|
||||
<textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea>
|
||||
<select class="notes-panel__target"></select>
|
||||
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
||||
<div class="notes-panel__actions">
|
||||
<button type="button" class="notes-panel__save">Save</button>
|
||||
<button type="button" class="notes-panel__cancel">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
this._button = this._container.querySelector(".notes-ctrl__button");
|
||||
this._panel = this._container.querySelector(".notes-panel");
|
||||
this._hint = this._container.querySelector(".notes-panel__hint");
|
||||
this._finishButton = this._container.querySelector(".notes-panel__finish");
|
||||
this._form = this._container.querySelector(".notes-panel__form");
|
||||
this._text = this._container.querySelector(".notes-panel__text");
|
||||
this._target = this._container.querySelector(".notes-panel__target");
|
||||
this._newName = this._container.querySelector(".notes-panel__new-name");
|
||||
container.appendChild(this._el);
|
||||
|
||||
this._text = this._el.querySelector(".notes-panel__text");
|
||||
this._target = this._el.querySelector(".notes-panel__target");
|
||||
this._newName = this._el.querySelector(".notes-panel__new-name");
|
||||
this._saveButton = this._el.querySelector(".notes-panel__save");
|
||||
|
||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
||||
this._panel.querySelectorAll(".notes-panel__mode").forEach((button) => {
|
||||
button.addEventListener("click", () => this._setMode(button.dataset.mode));
|
||||
});
|
||||
this._finishButton.addEventListener("click", () => this._finishArea());
|
||||
this._target.addEventListener("change", () => {
|
||||
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
|
||||
if (!this._newName.hidden) this._newName.focus();
|
||||
});
|
||||
this._container.querySelector(".notes-panel__save").addEventListener("click", () => this._save());
|
||||
this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm());
|
||||
|
||||
this._onClick = (event) => {
|
||||
if (!this._active || !this._form.hidden) return;
|
||||
if (this._mode === "point") {
|
||||
this._pendingGeometry = { type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] };
|
||||
this._openForm();
|
||||
} else {
|
||||
this._areaPoints.push([event.lngLat.lng, event.lngLat.lat]);
|
||||
this._renderPreview();
|
||||
this._updateAreaUi();
|
||||
}
|
||||
};
|
||||
map.on("click", this._onClick);
|
||||
|
||||
map.on("style.load", () => {
|
||||
if (this._areaPoints.length > 0) this._addPreviewLayers();
|
||||
});
|
||||
|
||||
this._updateHint();
|
||||
return this._container;
|
||||
this._saveButton.addEventListener("click", () => this._save());
|
||||
this._el.querySelector(".notes-panel__cancel").addEventListener("click", () => this.close(true));
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this._map.off("click", this._onClick);
|
||||
this._container.remove();
|
||||
get isOpen() {
|
||||
return !this._el.hidden;
|
||||
}
|
||||
|
||||
getDefaultPosition() {
|
||||
return "top-left";
|
||||
}
|
||||
|
||||
_setActive(active) {
|
||||
this._active = active;
|
||||
this._button.classList.toggle("active", active);
|
||||
this._panel.hidden = !active;
|
||||
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
||||
if (!active) {
|
||||
this._clearArea();
|
||||
this._resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
_setMode(mode) {
|
||||
this._mode = mode;
|
||||
this._panel.querySelectorAll(".notes-panel__mode").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.mode === mode);
|
||||
});
|
||||
this._clearArea();
|
||||
this._updateHint();
|
||||
}
|
||||
|
||||
_clearArea() {
|
||||
this._areaPoints = [];
|
||||
this._renderPreview();
|
||||
this._updateAreaUi();
|
||||
}
|
||||
|
||||
_updateAreaUi() {
|
||||
this._finishButton.hidden = this._mode !== "area" || this._areaPoints.length < 3;
|
||||
this._updateHint();
|
||||
}
|
||||
|
||||
_updateHint() {
|
||||
if (this._mode === "point") {
|
||||
this._hint.textContent = "Click the map to place a note.";
|
||||
} else if (this._areaPoints.length === 0) {
|
||||
this._hint.textContent = "Click to start drawing an area.";
|
||||
} else {
|
||||
this._hint.textContent = `${this._areaPoints.length} corner${this._areaPoints.length === 1 ? "" : "s"} — click to add more${
|
||||
this._areaPoints.length >= 3 ? ", or Finish" : ""
|
||||
}.`;
|
||||
}
|
||||
}
|
||||
|
||||
_finishArea() {
|
||||
if (this._areaPoints.length < 3) return;
|
||||
this._pendingGeometry = { type: "Polygon", coordinates: [[...this._areaPoints, this._areaPoints[0]]] };
|
||||
this._openForm();
|
||||
}
|
||||
|
||||
_addPreviewLayers() {
|
||||
if (this._map.getSource(PREVIEW_SOURCE_ID)) return;
|
||||
this._map.addSource(PREVIEW_SOURCE_ID, { type: "geojson", data: emptyCollection() });
|
||||
this._map.addLayer({
|
||||
id: PREVIEW_FILL_LAYER_ID,
|
||||
type: "fill",
|
||||
source: PREVIEW_SOURCE_ID,
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: { "fill-color": PREVIEW_COLOR, "fill-opacity": 0.15 },
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: PREVIEW_LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: PREVIEW_SOURCE_ID,
|
||||
filter: ["!=", ["geometry-type"], "Point"],
|
||||
paint: { "line-color": PREVIEW_COLOR, "line-width": 2, "line-dasharray": [2, 1] },
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: PREVIEW_POINT_LAYER_ID,
|
||||
type: "circle",
|
||||
source: PREVIEW_SOURCE_ID,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": PREVIEW_COLOR, "circle-stroke-width": 2 },
|
||||
});
|
||||
}
|
||||
|
||||
_removePreviewLayers() {
|
||||
[PREVIEW_FILL_LAYER_ID, PREVIEW_LINE_LAYER_ID, PREVIEW_POINT_LAYER_ID].forEach((id) => {
|
||||
if (this._map.getLayer(id)) this._map.removeLayer(id);
|
||||
});
|
||||
if (this._map.getSource(PREVIEW_SOURCE_ID)) this._map.removeSource(PREVIEW_SOURCE_ID);
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
if (this._areaPoints.length === 0) {
|
||||
this._removePreviewLayers();
|
||||
return;
|
||||
}
|
||||
this._addPreviewLayers();
|
||||
const pointFeatures = this._areaPoints.map((coord) => ({ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: coord } }));
|
||||
// A closed ring needs 3+ vertices to be valid; with fewer, preview it as
|
||||
// an open line so a half-drawn shape still shows something.
|
||||
const shape =
|
||||
this._areaPoints.length >= 3
|
||||
? { type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[...this._areaPoints, this._areaPoints[0]]] } }
|
||||
: this._areaPoints.length === 2
|
||||
? { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: this._areaPoints } }
|
||||
: null;
|
||||
this._map.getSource(PREVIEW_SOURCE_ID)?.setData({ type: "FeatureCollection", features: [...(shape ? [shape] : []), ...pointFeatures] });
|
||||
}
|
||||
|
||||
async _openForm() {
|
||||
this._hint.hidden = true;
|
||||
this._finishButton.hidden = true;
|
||||
this._panel.querySelector(".notes-panel__modes").hidden = true;
|
||||
this._form.hidden = false;
|
||||
async open(geometry) {
|
||||
this._geometry = geometry;
|
||||
this._el.hidden = false;
|
||||
this._text.value = "";
|
||||
this._text.focus();
|
||||
await this._populateTargets();
|
||||
}
|
||||
|
||||
_resetForm() {
|
||||
this._pendingGeometry = null;
|
||||
this._hint.hidden = false;
|
||||
this._panel.querySelector(".notes-panel__modes").hidden = false;
|
||||
this._form.hidden = true;
|
||||
close(viaCancel = false) {
|
||||
this._geometry = null;
|
||||
this._el.hidden = true;
|
||||
this._newName.hidden = true;
|
||||
this._newName.value = "";
|
||||
this._updateAreaUi();
|
||||
if (viaCancel) this._onCancel?.();
|
||||
}
|
||||
|
||||
async _populateTargets() {
|
||||
@@ -240,7 +91,7 @@ export class NotesControl {
|
||||
|
||||
async _save() {
|
||||
const text = this._text.value.trim();
|
||||
if (!text || !this._pendingGeometry) return;
|
||||
if (!text || !this._geometry) return;
|
||||
|
||||
const isNewLayer = this._target.value === NEW_LAYER_VALUE;
|
||||
const name = isNewLayer ? this._newName.value.trim() : "";
|
||||
@@ -251,32 +102,87 @@ export class NotesControl {
|
||||
|
||||
const feature = {
|
||||
type: "Feature",
|
||||
properties: { name: text, category: "Note", time: new Date().toISOString() },
|
||||
geometry: this._pendingGeometry,
|
||||
properties: { id: generateNoteId(), name: text, category: "Note", time: new Date().toISOString() },
|
||||
geometry: this._geometry,
|
||||
};
|
||||
|
||||
const body = new URLSearchParams({ feature: JSON.stringify(feature) });
|
||||
if (isNewLayer) body.set("name", name);
|
||||
else body.set("path", this._target.value);
|
||||
|
||||
const saveButton = this._container.querySelector(".notes-panel__save");
|
||||
saveButton.disabled = true;
|
||||
this._saveButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/gpx/notes", { method: "POST", body });
|
||||
if (!response.ok) throw new Error(`save failed: ${response.status}`);
|
||||
notifyGpxStoreChanged();
|
||||
this._clearArea();
|
||||
this._resetForm();
|
||||
this.close();
|
||||
this._onSaved?.();
|
||||
} catch (err) {
|
||||
window.alert("Could not save the note — check your connection and try again.");
|
||||
} finally {
|
||||
saveButton.disabled = false;
|
||||
this._saveButton.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyCollection() {
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
// A quick "pin it with a note" tool — click the map, write a short note,
|
||||
// pick which .geojson "layer" file it joins (or start a new one). Point
|
||||
// only: for a distance/area shape, draw it with the Measure tool and use
|
||||
// its own "Save as note" button instead of duplicating that click-to-place
|
||||
// UX here — see measure.js.
|
||||
export class NotesControl {
|
||||
constructor() {
|
||||
this._active = false;
|
||||
}
|
||||
|
||||
onAdd(map) {
|
||||
this._map = map;
|
||||
|
||||
this._container = document.createElement("div");
|
||||
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group notes-ctrl";
|
||||
this._container.innerHTML = `
|
||||
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
|
||||
<div class="notes-panel" hidden>
|
||||
<p class="notes-panel__hint">Click the map to place a note.</p>
|
||||
</div>
|
||||
`;
|
||||
this._button = this._container.querySelector(".notes-ctrl__button");
|
||||
this._panel = this._container.querySelector(".notes-panel");
|
||||
this._hint = this._container.querySelector(".notes-panel__hint");
|
||||
|
||||
this._form = new NoteSaveForm(this._panel, {
|
||||
onSaved: () => (this._hint.hidden = false),
|
||||
onCancel: () => (this._hint.hidden = false),
|
||||
});
|
||||
|
||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
||||
|
||||
this._onClick = (event) => {
|
||||
if (!this._active || this._form.isOpen) return;
|
||||
this._hint.hidden = true;
|
||||
this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] });
|
||||
};
|
||||
map.on("click", this._onClick);
|
||||
|
||||
return this._container;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this._map.off("click", this._onClick);
|
||||
this._container.remove();
|
||||
}
|
||||
|
||||
getDefaultPosition() {
|
||||
return "top-left";
|
||||
}
|
||||
|
||||
_setActive(active) {
|
||||
this._active = active;
|
||||
this._button.classList.toggle("active", active);
|
||||
this._panel.hidden = !active;
|
||||
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
||||
if (!active) this._form.close();
|
||||
}
|
||||
}
|
||||
|
||||
function geojsonFiles(node, results = []) {
|
||||
|
||||
Reference in new issue
Block a user