Add a "user overlay" tool: click the map, save a note
New map-corner "Add note" control (public/js/notes.js), mirroring
measure.js's click-to-place UX. Click the map, write a short note,
pick an existing .geojson layer file or name a new one — saved via a
new GpxStore#append_feature (grows the target file in place, unlike
save()'s always-disambiguate-the-filename behavior) through a new
POST /api/gpx/notes route.
No new rendering path: a note is just another point in a .geojson
file the Tracks panel's tree already lists, checkboxes, and pops up —
visibility toggling ("layers") comes for free from that existing
machinery, per-file, with no changes needed there.
Points only for now — a polygon mode would need gpx.js's point/line
split taught a third Polygon bucket first, or a saved polygon
wouldn't render back on reload (see TODO.adoc).
Covered by new tests: GpxStore#append_feature (create/append/missing-
path/filename-safety) and the /api/gpx/notes route (auth gate, create,
append, malformed/missing-geometry rejection, 404 on a missing
target). Full suite: 97 runs, 0 failures.
This commit is contained in:
1 parent
5caf865fc8
commit
aa2b96ac70
11 files changed
+437
-2
No files matched your search
@@ -0,0 +1,168 @@
|
||||
import { NOTE_PIN_ICON } from "/js/icons.js";
|
||||
import { notifyGpxStoreChanged } from "/js/panels.js";
|
||||
|
||||
const NEW_LAYER_VALUE = "__new__";
|
||||
|
||||
// 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). 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. Points only for now — a polygon mode would need gpx.js's
|
||||
// point/line split (public/js/gpx.js's _addLayers) taught a third,
|
||||
// Polygon-shaped bucket first, so it's left for a follow-up rather than
|
||||
// saving areas that wouldn't render back once reloaded.
|
||||
export class NotesControl {
|
||||
constructor() {
|
||||
this._active = false;
|
||||
this._pendingLngLat = null;
|
||||
}
|
||||
|
||||
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 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>
|
||||
</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 = 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");
|
||||
|
||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
||||
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) return;
|
||||
this._pendingLngLat = event.lngLat;
|
||||
this._openForm();
|
||||
};
|
||||
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._resetForm();
|
||||
}
|
||||
|
||||
async _openForm() {
|
||||
this._hint.hidden = true;
|
||||
this._form.hidden = false;
|
||||
this._text.value = "";
|
||||
this._text.focus();
|
||||
await this._populateTargets();
|
||||
}
|
||||
|
||||
_resetForm() {
|
||||
this._pendingLngLat = null;
|
||||
this._hint.hidden = false;
|
||||
this._form.hidden = true;
|
||||
this._newName.hidden = true;
|
||||
this._newName.value = "";
|
||||
}
|
||||
|
||||
async _populateTargets() {
|
||||
const previousValue = this._target.value;
|
||||
let files = [];
|
||||
try {
|
||||
const tree = await (await fetch("/api/gpx/tree")).json();
|
||||
files = geojsonFiles(tree);
|
||||
} catch (err) {
|
||||
// Tree fetch failed — target select just falls back to "new layer"
|
||||
// only, still usable.
|
||||
}
|
||||
|
||||
this._target.innerHTML =
|
||||
files.map((file) => `<option value="${escapeAttr(file.path)}">${escapeText(file.path)}</option>`).join("") +
|
||||
`<option value="${NEW_LAYER_VALUE}">+ New layer…</option>`;
|
||||
this._target.value = files.some((file) => file.path === previousValue) ? previousValue : NEW_LAYER_VALUE;
|
||||
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
|
||||
}
|
||||
|
||||
async _save() {
|
||||
const text = this._text.value.trim();
|
||||
if (!text || !this._pendingLngLat) return;
|
||||
|
||||
const isNewLayer = this._target.value === NEW_LAYER_VALUE;
|
||||
const name = isNewLayer ? this._newName.value.trim() : "";
|
||||
if (isNewLayer && !name) {
|
||||
this._newName.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = {
|
||||
type: "Feature",
|
||||
properties: { name: text, category: "Note", time: new Date().toISOString() },
|
||||
geometry: { type: "Point", coordinates: [this._pendingLngLat.lng, this._pendingLngLat.lat] },
|
||||
};
|
||||
|
||||
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;
|
||||
try {
|
||||
const response = await fetch("/api/gpx/notes", { method: "POST", body });
|
||||
if (!response.ok) throw new Error(`save failed: ${response.status}`);
|
||||
notifyGpxStoreChanged();
|
||||
this._resetForm();
|
||||
} catch (err) {
|
||||
window.alert("Could not save the note — check your connection and try again.");
|
||||
} finally {
|
||||
saveButton.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function geojsonFiles(node, results = []) {
|
||||
if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) {
|
||||
results.push({ path: node.path });
|
||||
}
|
||||
(node.children || []).forEach((child) => geojsonFiles(child, results));
|
||||
return results;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return value.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<");
|
||||
}
|
||||
Reference in new issue
Block a user