From aa2b96ac7016a0b3e538455627d26c97d04fe533 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 10:18:50 +0200 Subject: [PATCH] Add a "user overlay" tool: click the map, save a note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- TODO.adoc | 3 +- app.rb | 28 +++++++ lib/maptool/gpx_store.rb | 37 +++++++++ public/css/style.css | 90 +++++++++++++++++++++ public/js/gpx.js | 7 +- public/js/icons.js | 6 ++ public/js/map.js | 2 + public/js/notes.js | 168 +++++++++++++++++++++++++++++++++++++++ public/js/panels.js | 13 +++ test/test_app.rb | 49 ++++++++++++ test/test_gpx_store.rb | 36 +++++++++ 11 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 public/js/notes.js diff --git a/TODO.adoc b/TODO.adoc index 1fd7dd7..7c9a89e 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -40,7 +40,8 @@ independence from connectivity. == 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) -* [ ] 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 == Nice to have (later) diff --git a/app.rb b/app.rb index 42868f0..1f67a05 100644 --- a/app.rb +++ b/app.rb @@ -226,6 +226,34 @@ module Maptool json paths: paths, tree: GPX_STORE.tree 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 # 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 diff --git a/lib/maptool/gpx_store.rb b/lib/maptool/gpx_store.rb index 887ad46..914a750 100644 --- a/lib/maptool/gpx_store.rb +++ b/lib/maptool/gpx_store.rb @@ -128,6 +128,32 @@ module Maptool File.read(cache) 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 def resolve_dir(folder) @@ -163,6 +189,17 @@ module Maptool base 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) return name unless File.exist?(File.join(dir, name)) diff --git a/public/css/style.css b/public/css/style.css index 01fe5e3..7bd78d6 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -740,6 +740,96 @@ html, body { 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 { width: 29px; height: 29px; diff --git a/public/js/gpx.js b/public/js/gpx.js index cde888f..f741d0b 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -12,7 +12,7 @@ import { POI_ICONS, PARK4NIGHT_TAGS, } 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 { isLoggedIn } from "/js/auth.js"; @@ -57,6 +57,11 @@ export class GpxPanelControl { this._iconsReady = this._registerCategoryGlyphs(); 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 diff --git a/public/js/icons.js b/public/js/icons.js index 239e9b2..8b0376c 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -13,6 +13,12 @@ export const HILLSHADE_ICON = ` `; +export const NOTE_PIN_ICON = ` + + + +`; + export const GPX_ICON = ` diff --git a/public/js/map.js b/public/js/map.js index f9c05b3..45ce942 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -10,6 +10,7 @@ import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { MeasureControl } from "/js/measure.js"; +import { NotesControl } from "/js/notes.js"; import { LocateControl } from "/js/locate.js"; import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.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 ZoomLevelControl(), "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 // other sidebar controls) so LocateControl can hold the same instance — // its constructor only touches `map`, mount() (which needs the sidebar diff --git a/public/js/notes.js b/public/js/notes.js new file mode 100644 index 0000000..4c2e356 --- /dev/null +++ b/public/js/notes.js @@ -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 = ` + + + `; + 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) => ``).join("") + + ``; + 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(/ "Feature", + "properties" => { "name" => name, "category" => "Note" }, + "geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] }, + ) + end + def le32(n) [n].pack("l<") end diff --git a/test/test_gpx_store.rb b/test/test_gpx_store.rb index 8b1b929..9b3031f 100644 --- a/test/test_gpx_store.rb +++ b/test/test_gpx_store.rb @@ -174,8 +174,44 @@ class GpxStoreTest < Minitest::Test assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] } 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 + def sample_feature(name) + { "type" => "Feature", "properties" => { "name" => name, "category" => "Note" }, + "geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] } } + end + def sample_gpx <<~GPX Waypoint