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

+37
View File
@@ -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))