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:
randy committed 2026-09-09 10:18:50 +02:00
1 parent 5caf865fc8
commit aa2b96ac70
11 files changed
+437 -2

No files matched your search

+2 -1
View File
@@ -40,7 +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)
* [ ] User overlay: offline drawing of custom points/polygons with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") * [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)
** [ ] 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
* [ ] 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)
+28
View File
@@ -226,6 +226,34 @@ module Maptool
json paths: paths, tree: GPX_STORE.tree json paths: paths, tree: GPX_STORE.tree
end end
# A hand-drawn point/note (see notes.js) — unlike the upload above, this
# grows one existing/new .geojson "layer" file in place (GpxStore#
# append_feature) rather than always landing as its own new file.
post "/api/gpx/notes" do
require_login!
begin
feature = JSON.parse(params[:feature].to_s)
rescue JSON::ParserError
halt 400, json(error: "invalid feature")
end
halt 400, json(error: "invalid feature") unless feature.is_a?(Hash) && feature["geometry"]
begin
path = GPX_STORE.append_feature(
feature: feature,
path: params[:path],
folder: params[:folder].to_s,
name: params[:name].to_s
)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404, json(error: "file not found")
end
json path: path, tree: GPX_STORE.tree
end
# ?download=geojson exports the file's GeoJSON as an attachment instead # ?download=geojson exports the file's GeoJSON as an attachment instead
# of rendering it on the map — the only way to get a .gpx/.fit's # of rendering it on the map — the only way to get a .gpx/.fit's
# converted form out of the app, since GpxStore#geojson's cache is # converted form out of the app, since GpxStore#geojson's cache is
+37
View File
@@ -128,6 +128,32 @@ module Maptool
File.read(cache) File.read(cache)
end end
# Appends one GeoJSON Feature (a hand-drawn point/note, see notes.js) to
# the FeatureCollection at `path` — or, if `path` is nil, to a new or
# existing file named `name` inside `folder`. Deliberately overwrites in
# place rather than disambiguating like save() does: repeat calls with
# the same path/name are meant to grow one running "layer" file, not
# spawn "notes-1.geojson", "notes-2.geojson", ... siblings. Returns the
# store-relative path written to, so a caller that didn't already know
# it (the new-file case) can show/select it afterwards.
def append_feature(feature:, path: nil, folder: "", name: "")
if path && !path.to_s.strip.empty?
file = resolve_file(path)
raise Errno::ENOENT, file unless File.file?(file)
else
dir = resolve_dir(folder.to_s)
FileUtils.mkdir_p(dir)
filename = safe_geojson_filename(name)
file = File.join(dir, filename)
path = folder.to_s.empty? ? filename : "#{folder}/#{filename}"
end
collection = File.file?(file) ? JSON.parse(File.read(file)) : { "type" => "FeatureCollection", "features" => [] }
(collection["features"] ||= []) << feature
File.write(file, JSON.generate(collection))
path
end
private private
def resolve_dir(folder) def resolve_dir(folder)
@@ -163,6 +189,17 @@ module Maptool
base base
end end
# Same path-safety idea as safe_filename above, but for a brand-new
# notes layer: always .geojson (never falls back to .gpx), and named
# after the user's own "Parking spots"-style label rather than an
# uploaded file's original name.
def safe_geojson_filename(name)
base = File.basename(name.to_s.strip)
base = "notes" if base.empty? || base == "." || base == ".."
base = base.sub(/\.geojson\z/i, "")
"#{base}.geojson"
end
def unique_filename(dir, name) def unique_filename(dir, name)
return name unless File.exist?(File.join(dir, name)) return name unless File.exist?(File.join(dir, name))
+90
View File
@@ -740,6 +740,96 @@ html, body {
color: #333; color: #333;
} }
.notes-ctrl {
position: relative;
}
.notes-ctrl__button svg {
display: block;
margin: 0 auto;
width: 18px;
height: 18px;
}
.notes-ctrl__button.active {
background: #dbe9ff;
}
.notes-panel {
position: absolute;
top: 0;
left: 100%;
margin-left: 8px;
z-index: 20;
width: 200px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 8px;
font-size: 13px;
}
.notes-panel[hidden] {
display: none;
}
.notes-panel__hint {
margin: 0;
color: #888;
}
.notes-panel__form {
display: flex;
flex-direction: column;
gap: 6px;
}
.notes-panel__text {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 12px;
padding: 4px 6px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
}
.notes-panel__target,
.notes-panel__new-name {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 12px;
padding: 4px 6px;
border: 1px solid #ddd;
border-radius: 4px;
}
.notes-panel__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.notes-panel__actions button {
width: auto;
height: auto;
padding: 4px 10px;
border: none;
border-radius: 4px;
background: #f0f0f0;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.notes-panel__save {
background: #1a5fb4;
color: #fff;
}
.zoom-level-ctrl__value { .zoom-level-ctrl__value {
width: 29px; width: 29px;
height: 29px; height: 29px;
+6 -1
View File
@@ -12,7 +12,7 @@ import {
POI_ICONS, POI_ICONS,
PARK4NIGHT_TAGS, PARK4NIGHT_TAGS,
} from "/js/icons.js"; } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged } from "/js/panels.js";
import { formatActivityType } from "/js/track_profile.js"; import { formatActivityType } from "/js/track_profile.js";
import { isLoggedIn } from "/js/auth.js"; import { isLoggedIn } from "/js/auth.js";
@@ -57,6 +57,11 @@ export class GpxPanelControl {
this._iconsReady = this._registerCategoryGlyphs(); this._iconsReady = this._registerCategoryGlyphs();
this._visible.forEach((path) => this._addLayers(path)); this._visible.forEach((path) => this._addLayers(path));
}); });
// notes.js writes directly into the same GPX store (a hand-drawn note
// saved into a .geojson layer) — refresh so a newly created/grown file
// shows up in the tree without a manual reopen.
onGpxStoreChanged(() => this._loadTree());
} }
// Registered once, always white, as plain (non-SDF) images: these used to // Registered once, always white, as plain (non-SDF) images: these used to
+6
View File
@@ -13,6 +13,12 @@ export const HILLSHADE_ICON = `
<path d="M2 18h20"/> <path d="M2 18h20"/>
</svg>`; </svg>`;
export const NOTE_PIN_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
<path d="M12 6.5v5M9.5 9h5"/>
</svg>`;
export const GPX_ICON = ` export const GPX_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="6" r="2"/> <circle cx="5" cy="6" r="2"/>
+2
View File
@@ -10,6 +10,7 @@ import { GnssControl } from "/js/gnss.js";
import { LiveTrackPanelControl } from "/js/livetrack.js"; import { LiveTrackPanelControl } from "/js/livetrack.js";
import { TrackProfilePanel } from "/js/track_profile.js"; import { TrackProfilePanel } from "/js/track_profile.js";
import { MeasureControl } from "/js/measure.js"; import { MeasureControl } from "/js/measure.js";
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";
@@ -500,6 +501,7 @@ 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");
// 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
+168
View File
@@ -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, "&amp;").replace(/"/g, "&quot;");
}
function escapeText(value) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;");
}
+13
View File
@@ -15,3 +15,16 @@ export function onlyOneSidePanelOpen(panel, onClosedByOther) {
} }
}); });
} }
// Lets a control that writes into the GPX store (e.g. notes.js saving a
// drawn point) tell GpxPanelControl its tree is stale, without either one
// holding a reference to the other.
const STORE_CHANGED_EVENT = "gpx-store-changed";
export function notifyGpxStoreChanged() {
document.dispatchEvent(new CustomEvent(STORE_CHANGED_EVENT));
}
export function onGpxStoreChanged(callback) {
document.addEventListener(STORE_CHANGED_EVENT, callback);
}
+49
View File
@@ -176,6 +176,47 @@ class AppTest < Minitest::Test
assert_equal 400, response.status assert_equal 400, response.status
end end
# --- Notes (hand-drawn points, notes.js) ---
def test_notes_requires_login
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status
end
def test_notes_creates_a_new_layer_file
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "My Notes" }, **logged_in_env)
assert_equal 200, response.status
body = json_body(response)
assert_equal "My Notes.geojson", body["path"]
assert_equal ["My Notes.geojson"], body["tree"]["children"].map { |c| c["name"] }
end
def test_notes_appends_to_an_existing_layer_file_instead_of_disambiguating
first = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot"), name: "My Notes" }, **logged_in_env))
second = request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate"), path: first["path"] }, **logged_in_env)
assert_equal 200, second.status
assert_equal first["path"], json_body(second)["path"]
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(first["path"])}"))["features"]
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
end
def test_notes_rejects_malformed_feature_json
response = request.post("/api/gpx/notes", params: { feature: "not json", name: "Notes" }, **logged_in_env)
assert_equal 400, response.status
end
def test_notes_rejects_a_feature_with_no_geometry
response = request.post("/api/gpx/notes", params: { feature: '{"type":"Feature","properties":{}}', name: "Notes" }, **logged_in_env)
assert_equal 400, response.status
end
def test_notes_to_a_missing_path_is_404
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, path: "nope.geojson" }, **logged_in_env)
assert_equal 404, response.status
end
# --- LiveTrack webhook --- # --- LiveTrack webhook ---
def test_livetrack_webhook_requires_the_configured_secret def test_livetrack_webhook_requires_the_configured_secret
@@ -214,6 +255,14 @@ class AppTest < Minitest::Test
private private
def sample_note_feature(name = "Turning spot")
JSON.generate(
"type" => "Feature",
"properties" => { "name" => name, "category" => "Note" },
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] },
)
end
def le32(n) def le32(n)
[n].pack("l<") [n].pack("l<")
end end
+36
View File
@@ -174,8 +174,44 @@ class GpxStoreTest < Minitest::Test
assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] } assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] }
end end
def test_append_feature_creates_a_new_geojson_file
path = @store.append_feature(feature: sample_feature("Turning spot"), folder: "", name: "My Notes")
assert_equal "My Notes.geojson", path
file = @store.tree["children"].find { |c| c["name"] == "My Notes.geojson" }
assert_equal ["waypoint"], file["kinds"]
end
def test_append_feature_grows_the_same_file_instead_of_disambiguating
first_path = @store.append_feature(feature: sample_feature("Turning spot"), folder: "", name: "My Notes")
second_path = @store.append_feature(feature: sample_feature("Narrow gate"), path: first_path)
assert_equal first_path, second_path
features = JSON.parse(@store.geojson(first_path))["features"]
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
end
def test_append_feature_to_a_missing_path_raises_enoent
assert_raises(Errno::ENOENT) { @store.append_feature(feature: sample_feature("x"), path: "nope.geojson") }
end
def test_append_feature_name_always_gets_a_geojson_extension
path = @store.append_feature(feature: sample_feature("x"), folder: "", name: "notes.txt")
assert_equal "notes.txt.geojson", path
end
def test_append_feature_blank_name_falls_back_to_notes
path = @store.append_feature(feature: sample_feature("x"), folder: "", name: "")
assert_equal "notes.geojson", path
end
private private
def sample_feature(name)
{ "type" => "Feature", "properties" => { "name" => name, "category" => "Note" },
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] } }
end
def sample_gpx def sample_gpx
<<~GPX <<~GPX
<gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx> <gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx>