Add polygon notes (Area mode) to the user overlay tool
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s

notes.js: Point/Area mode toggle mirroring measure.js's UX — Area
mode accumulates clicked vertices with a live dashed-preview polygon
(own small preview source/layers), a Finish button once 3+ corners
are placed, then the same save form as Point mode.

gpx.js's _addLayers now also renders Polygon features from a file's
"lines" source (fill + outline, filtered by geometry-type — the
existing line layer already only draws LineString, so this is just
two more layers on the same source, not a new bucket) with
popup-on-click like a waypoint, not the track line's hover-summary/
profile-panel behavior. Without this a saved polygon wouldn't have
rendered back on reload.

Fixed along the way: .notes-panel__form/.notes-panel__modes both set
their own `display`, which silently wins over the browser's default
`[hidden] { display: none }` (author styles always beat user-agent
ones) — same gotcha .gpx-panel__action[hidden] already has a comment
about. The form was never actually invisible before being opened.

Verified live: real polygon file (fill/outline render, click ->
popup) and the full Area-mode draw flow (mode switch, live preview,
Finish, form) via headless Chrome. Full test suite: 104 runs, 0
failures — one new backend test confirms a Polygon feature round-
trips through POST /api/gpx/notes.

Known gap (noted in TODO.adoc): the category filter matrix only
re-slices the points source, so toggling "Note" off hides point notes
but not polygon ones.
This commit is contained in:
randy committed 2026-09-09 11:27:25 +02:00
1 parent f53c74e06f
commit db58468187
5 files changed
+233 -21

No files matched your search

+2 -2
View File
@@ -40,8 +40,8 @@ independence from connectivity.
== Custom layers & data == Custom layers & data
* [x] GPX/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks — also FIT), auto-styled by category with a generated elevation profile — KML not supported (only GPX/FIT/GeoJSON/.zip) * [x] GPX/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks — also FIT), auto-styled by category with a generated elevation profile — KML not supported (only GPX/FIT/GeoJSON/.zip)
* [x] User overlay: offline drawing of custom points with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") — click 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 *and areas* with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") — Point/Area mode toggle with a live drawing preview, saved into the same GpxStore the Tracks panel already reads (so visibility/popup come for free); gpx.js's `_addLayers` now renders Polygon features (fill + outline) alongside points/lines, not just points
** [ ] Polygon notes (not just points) — needs gpx.js's point/line split (`_addLayers`) taught a third, Polygon-shaped bucket first, or a saved polygon wouldn't render back on reload ** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon 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
== Nice to have (later) == Nice to have (later)
+47 -1
View File
@@ -774,17 +774,63 @@ html, body {
display: none; display: none;
} }
.notes-panel__modes {
display: flex;
gap: 4px;
margin-bottom: 6px;
}
.notes-panel__mode {
flex: 1;
width: auto;
height: auto;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: #f0f0f0;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.notes-panel__mode.active {
background: #dbe9ff;
font-weight: 600;
}
.notes-panel__hint { .notes-panel__hint {
margin: 0; margin: 0 0 6px;
color: #888; color: #888;
} }
.notes-panel__finish {
width: 100%;
box-sizing: border-box;
margin-bottom: 6px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: #1a5fb4;
color: #fff;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.notes-panel__form { .notes-panel__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
/* Same "author display wins over the UA [hidden] rule" gotcha as
.gpx-panel__action[hidden] above — both this and .notes-panel__modes set
their own "display", so hiding them needs an explicit override too. */
.notes-panel__form[hidden],
.notes-panel__modes[hidden] {
display: none;
}
.notes-panel__text { .notes-panel__text {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
+25
View File
@@ -540,6 +540,29 @@ export class GpxPanelControl {
}); });
this._registerLineInteractivity(`${linesId}-line`); this._registerLineInteractivity(`${linesId}-line`);
// Polygon notes (see notes.js) — the same "lines" source already only
// renders LineString geometry through the line layer above (MapLibre's
// own "line" layer type silently ignores Polygon features), so this is
// just two more layers reading the same source, not a new bucket.
// Popup-on-click like a waypoint (_registerPointInteractivity), not the
// track line's hover-summary/profile-panel behavior above — a note
// polygon has no elevation profile to open.
this._map.addLayer({
id: `${linesId}-polygon-fill`,
type: "fill",
source: linesId,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": TRACK_COLOR, "fill-opacity": 0.2 },
});
this._map.addLayer({
id: `${linesId}-polygon-outline`,
type: "line",
source: linesId,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "line-color": TRACK_COLOR, "line-width": 2 },
});
this._registerPointInteractivity(`${linesId}-polygon-fill`);
// clusterRadius/clusterMaxZoom are harmless when there's nothing dense // clusterRadius/clusterMaxZoom are harmless when there's nothing dense
// enough to cluster (a handful of waypoints just render individually). // enough to cluster (a handful of waypoints just render individually).
this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 }); this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 });
@@ -704,6 +727,8 @@ export class GpxPanelControl {
const pointsId = pointsSourceId(path); const pointsId = pointsSourceId(path);
[ [
`${linesId}-line`, `${linesId}-line`,
`${linesId}-polygon-fill`,
`${linesId}-polygon-outline`,
`${pointsId}-clusters`, `${pointsId}-clusters`,
`${pointsId}-cluster-count`, `${pointsId}-cluster-count`,
`${pointsId}-unclustered`, `${pointsId}-unclustered`,
+145 -17
View File
@@ -2,20 +2,26 @@ import { NOTE_PIN_ICON } from "/js/icons.js";
import { notifyGpxStoreChanged } from "/js/panels.js"; import { notifyGpxStoreChanged } from "/js/panels.js";
const NEW_LAYER_VALUE = "__new__"; 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, write a short note, // A quick "pin it with a note" tool — click the map (Point mode) or click
// pick which .geojson "layer" file it joins (or start a new one). Saved // out a few corners and Finish (Area mode), write a short note, pick which
// server-side (POST /api/gpx/notes) into the same GpxStore everything else // .geojson "layer" file it joins (or start a new one). Saved server-side
// in the Tracks panel already reads from, so a note shows up there with no // (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, // 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 // same popup — public/js/gpx.js's _addLayers renders a Polygon feature the
// point/line split (public/js/gpx.js's _addLayers) taught a third, // same way it already renders points/lines, no separate code path here.
// 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 { export class NotesControl {
constructor() { constructor() {
this._active = false; this._active = false;
this._pendingLngLat = null; this._mode = "point"; // "point" | "area"
this._areaPoints = []; // vertices while drawing an area, before Finish
this._pendingGeometry = null; // the finished Point/Polygon geometry, once placed
} }
onAdd(map) { onAdd(map) {
@@ -26,7 +32,12 @@ export class NotesControl {
this._container.innerHTML = ` this._container.innerHTML = `
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button> <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" hidden>
<p class="notes-panel__hint">Click the map to place a note.</p> <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> <div class="notes-panel__form" hidden>
<textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea> <textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea>
<select class="notes-panel__target"></select> <select class="notes-panel__target"></select>
@@ -41,12 +52,17 @@ export class NotesControl {
this._button = this._container.querySelector(".notes-ctrl__button"); this._button = this._container.querySelector(".notes-ctrl__button");
this._panel = this._container.querySelector(".notes-panel"); this._panel = this._container.querySelector(".notes-panel");
this._hint = this._container.querySelector(".notes-panel__hint"); 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._form = this._container.querySelector(".notes-panel__form");
this._text = this._container.querySelector(".notes-panel__text"); this._text = this._container.querySelector(".notes-panel__text");
this._target = this._container.querySelector(".notes-panel__target"); this._target = this._container.querySelector(".notes-panel__target");
this._newName = this._container.querySelector(".notes-panel__new-name"); this._newName = this._container.querySelector(".notes-panel__new-name");
this._button.addEventListener("click", () => this._setActive(!this._active)); 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._target.addEventListener("change", () => {
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE; this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
if (!this._newName.hidden) this._newName.focus(); if (!this._newName.hidden) this._newName.focus();
@@ -55,12 +71,23 @@ export class NotesControl {
this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm()); this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm());
this._onClick = (event) => { this._onClick = (event) => {
if (!this._active) return; if (!this._active || !this._form.hidden) return;
this._pendingLngLat = event.lngLat; if (this._mode === "point") {
this._openForm(); 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("click", this._onClick);
map.on("style.load", () => {
if (this._areaPoints.length > 0) this._addPreviewLayers();
});
this._updateHint();
return this._container; return this._container;
} }
@@ -78,11 +105,105 @@ export class NotesControl {
this._button.classList.toggle("active", active); this._button.classList.toggle("active", active);
this._panel.hidden = !active; this._panel.hidden = !active;
this._map.getCanvas().style.cursor = active ? "crosshair" : ""; this._map.getCanvas().style.cursor = active ? "crosshair" : "";
if (!active) this._resetForm(); 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() { async _openForm() {
this._hint.hidden = true; this._hint.hidden = true;
this._finishButton.hidden = true;
this._panel.querySelector(".notes-panel__modes").hidden = true;
this._form.hidden = false; this._form.hidden = false;
this._text.value = ""; this._text.value = "";
this._text.focus(); this._text.focus();
@@ -90,11 +211,13 @@ export class NotesControl {
} }
_resetForm() { _resetForm() {
this._pendingLngLat = null; this._pendingGeometry = null;
this._hint.hidden = false; this._hint.hidden = false;
this._panel.querySelector(".notes-panel__modes").hidden = false;
this._form.hidden = true; this._form.hidden = true;
this._newName.hidden = true; this._newName.hidden = true;
this._newName.value = ""; this._newName.value = "";
this._updateAreaUi();
} }
async _populateTargets() { async _populateTargets() {
@@ -117,7 +240,7 @@ export class NotesControl {
async _save() { async _save() {
const text = this._text.value.trim(); const text = this._text.value.trim();
if (!text || !this._pendingLngLat) return; if (!text || !this._pendingGeometry) return;
const isNewLayer = this._target.value === NEW_LAYER_VALUE; const isNewLayer = this._target.value === NEW_LAYER_VALUE;
const name = isNewLayer ? this._newName.value.trim() : ""; const name = isNewLayer ? this._newName.value.trim() : "";
@@ -129,7 +252,7 @@ export class NotesControl {
const feature = { const feature = {
type: "Feature", type: "Feature",
properties: { name: text, category: "Note", time: new Date().toISOString() }, properties: { name: text, category: "Note", time: new Date().toISOString() },
geometry: { type: "Point", coordinates: [this._pendingLngLat.lng, this._pendingLngLat.lat] }, geometry: this._pendingGeometry,
}; };
const body = new URLSearchParams({ feature: JSON.stringify(feature) }); const body = new URLSearchParams({ feature: JSON.stringify(feature) });
@@ -142,6 +265,7 @@ export class NotesControl {
const response = await fetch("/api/gpx/notes", { method: "POST", body }); const response = await fetch("/api/gpx/notes", { method: "POST", body });
if (!response.ok) throw new Error(`save failed: ${response.status}`); if (!response.ok) throw new Error(`save failed: ${response.status}`);
notifyGpxStoreChanged(); notifyGpxStoreChanged();
this._clearArea();
this._resetForm(); this._resetForm();
} catch (err) { } catch (err) {
window.alert("Could not save the note — check your connection and try again."); window.alert("Could not save the note — check your connection and try again.");
@@ -151,6 +275,10 @@ export class NotesControl {
} }
} }
function emptyCollection() {
return { type: "FeatureCollection", features: [] };
}
function geojsonFiles(node, results = []) { function geojsonFiles(node, results = []) {
if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) { if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) {
results.push({ path: node.path }); results.push({ path: node.path });
+14 -1
View File
@@ -176,7 +176,20 @@ class AppTest < Minitest::Test
assert_equal 400, response.status assert_equal 400, response.status
end end
# --- Notes (hand-drawn points, notes.js) --- # --- Notes (hand-drawn points/areas, notes.js) ---
def test_notes_accepts_a_polygon_feature
polygon = JSON.generate(
"type" => "Feature",
"properties" => { "name" => "Turning area", "category" => "Note" },
"geometry" => { "type" => "Polygon", "coordinates" => [[[17.1, 48.1], [17.2, 48.1], [17.2, 48.2], [17.1, 48.1]]] },
)
response = request.post("/api/gpx/notes", params: { feature: polygon, name: "Areas" }, **logged_in_env)
assert_equal 200, response.status
geometry = json_body(request.get("/api/gpx/file/Areas.geojson"))["features"][0]["geometry"]
assert_equal "Polygon", geometry["type"]
end
def test_notes_requires_login def test_notes_requires_login
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status