Compare commits

..
2 Commits
Author SHA1 Message Date
randy f53c74e06f Add rake overpass:pois to fetch camper POIs from Overpass
CI / deploy (push) Successful in 12s
CI / check (push) Successful in 40s
lib/maptool/overpass_pois.rb: queries Overpass for drinking water,
dump stations, LPG refill, toilets, laundromats, supermarkets/
convenience stores, and springs within a bbox, writes GeoJSON. A
dev-time tool (like the offline.pmtiles/terrain.pmtiles generation
steps already documented) — not called by the running app, drop the
output into data/gpx/ or upload it through the Tracks panel like any
other .geojson layer.

Verified against the real Overpass API (88 POIs for central
Bratislava) in addition to the new unit tests (pure to_geojson/
build_query logic, no network in the suite itself). Full suite: 103
runs, 0 failures.
2026-09-09 10:21:41 +02:00
randy aa2b96ac70 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.
2026-09-09 10:18:50 +02:00
15 changed files with 617 additions and 3 deletions

No files matched your search

+17
View File
@@ -6,3 +6,20 @@ Rake::TestTask.new(:test) do |t|
end end
task default: :test task default: :test
namespace :overpass do
desc "Fetch camper-relevant POIs (water, dump stations, LPG, toilets, laundry, shops, springs) " \
"for a bbox from Overpass and write GeoJSON. Usage: rake overpass:pois[south,west,north,east,out.geojson]"
task :pois, [:south, :west, :north, :east, :out] do |_t, args|
%i[south west north east out].each { |key| abort("usage: rake overpass:pois[south,west,north,east,out.geojson]") unless args[key] }
require_relative "lib/maptool/overpass_pois"
bbox = [args[:south], args[:west], args[:north], args[:east]].map(&:to_f)
puts "Querying Overpass for #{bbox.join(",")}..."
response = Maptool::OverpassPois.fetch(bbox)
geojson = Maptool::OverpassPois.to_geojson(response)
File.write(args[:out], JSON.generate(geojson))
puts "Wrote #{geojson["features"].size} POIs to #{args[:out]}"
end
end
+3 -2
View File
@@ -12,7 +12,7 @@ independence from connectivity.
* [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now * [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now
** [ ] Terrain slope readout for parking (numeric, not just visual shading) ** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes * [ ] Contour lines from OSM for hikes and mountain passes
* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) * [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload, not called by the running app itself
* [ ] Import Park4Night / iOverlander / Freecampsites data * [ ] Import Park4Night / iOverlander / Freecampsites data
* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates * [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates
* [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline
@@ -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
+13
View File
@@ -59,6 +59,19 @@ Terrarium-encoded PNG tiles in an `.mbtiles`), then
https://github.com/protomaps/go-pmtiles[go-pmtiles], same as https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
`data/offline.pmtiles`. `data/offline.pmtiles`.
== Camper-relevant POIs (Overpass)
`rake overpass:pois[south,west,north,east,out.geojson]` queries
https://overpass-api.de[Overpass] for drinking water, dump stations,
LPG refill, public toilets, laundromats, supermarkets/convenience
stores, and springs within a bbox, and writes the result as a
`.geojson` file (`lib/maptool/overpass_pois.rb`). A dev-time tool, not
something the running app calls itself — drop the output into
`data/gpx/` (or upload it through the Tracks panel) like any other
`.geojson` layer once it's generated. Nodes only (nothing that's
mapped as a way/relation, e.g. a supermarket traced as a building
outline rather than a single point).
== GNSS position (gpsd) == GNSS position (gpsd)
The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at
+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))
+81
View File
@@ -0,0 +1,81 @@
require "net/http"
require "json"
module Maptool
# A dev-time tool (see the "overpass:pois" Rake task), not something the
# deployed app calls itself — same "generate locally, drop the result in
# data/gpx/" story as offline.pmtiles/terrain.pmtiles (docs/development.adoc),
# kept off the running server so it never depends on Overpass's public
# instance being up, or eats into its (fairly strict, shared) rate limits
# on every page load.
module OverpassPois
class FetchError < StandardError; end
OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze
# Each entry: an Overpass tag filter -> a human category label, used
# both to build the query (unioning every filter) and to label results
# back (matched by scanning an element's own tags for the same
# key/value, not by which branch of the query found it — Overpass's
# `out body` doesn't say which union member matched).
CATEGORIES = {
{ "amenity" => "drinking_water" } => "Drinking water",
{ "amenity" => "sanitary_dump_station" } => "Dump station",
{ "amenity" => "fuel", "fuel:lpg" => "yes" } => "LPG refill",
{ "shop" => "gas" } => "LPG refill",
{ "amenity" => "toilets" } => "Public toilet",
{ "shop" => "laundry" } => "Laundry",
{ "shop" => "supermarket" } => "Supermarket",
{ "shop" => "convenience" } => "Convenience store",
{ "natural" => "spring" } => "Spring",
}.freeze
# bbox: [south, west, north, east] (Overpass's own axis order).
def self.fetch(bbox, timeout: 60)
query = build_query(bbox, timeout: timeout)
uri = URI(OVERPASS_API)
response = Net::HTTP.post_form(uri, "data" => query)
raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
def self.build_query(bbox, timeout: 60)
bbox_str = bbox.join(",")
filters = CATEGORIES.keys.map do |tags|
conditions = tags.map { |k, v| %(["#{k}"="#{v}"]) }.join
"node#{conditions}(#{bbox_str});"
end.join("\n ")
"[out:json][timeout:#{timeout}];\n(\n #{filters}\n);\nout body;"
end
# Pure — no network — so it's cheaply testable against a canned
# Overpass response. Only `type: "node"` elements are handled: ways/
# relations (a mapped-as-an-area supermarket, say) would need their own
# centroid computation, and the categories above are overwhelmingly
# mapped as plain nodes in practice.
def self.to_geojson(overpass_response)
features = (overpass_response["elements"] || []).filter_map do |element|
next unless element["type"] == "node"
category = category_for(element["tags"] || {})
next unless category
{
"type" => "Feature",
"properties" => { "name" => (element["tags"] || {})["name"] || category, "category" => category },
"geometry" => { "type" => "Point", "coordinates" => [element["lon"], element["lat"]] },
}
end
{ "type" => "FeatureCollection", "features" => features }
end
def self.category_for(tags)
CATEGORIES.each do |filter, label|
return label if filter.all? { |k, v| tags[k] == v }
end
nil
end
private_class_method :category_for
end
end
+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>
+68
View File
@@ -0,0 +1,68 @@
require_relative "test_helper"
require "maptool/overpass_pois"
# No real network calls — to_geojson is pure (canned Overpass response ->
# GeoJSON), and that's the part worth protecting from regressions. fetch()
# itself is a thin Net::HTTP.post_form + JSON.parse wrapper, same as
# LiveTrackClient's own fetch_html, not independently tested for the same
# reason that one isn't.
class OverpassPoisTest < Minitest::Test
def test_to_geojson_converts_matching_node_elements
response = {
"elements" => [
{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "drinking_water" } },
{ "type" => "node", "lat" => 48.3, "lon" => 17.4, "tags" => { "amenity" => "toilets", "name" => "Rest area" } },
],
}
geojson = Maptool::OverpassPois.to_geojson(response)
assert_equal "FeatureCollection", geojson["type"]
assert_equal 2, geojson["features"].size
water = geojson["features"].find { |f| f["properties"]["category"] == "Drinking water" }
assert_equal [17.2, 48.1], water["geometry"]["coordinates"]
assert_equal "Drinking water", water["properties"]["name"], "falls back to the category when the node has no name tag"
end
def test_to_geojson_uses_the_node_name_when_present
response = { "elements" => [{ "type" => "node", "lat" => 48.3, "lon" => 17.4, "tags" => { "amenity" => "toilets", "name" => "Rest area" } }] }
feature = Maptool::OverpassPois.to_geojson(response)["features"].first
assert_equal "Rest area", feature["properties"]["name"]
end
def test_to_geojson_skips_ways_and_relations
response = { "elements" => [{ "type" => "way", "tags" => { "amenity" => "drinking_water" } }] }
assert_empty Maptool::OverpassPois.to_geojson(response)["features"]
end
def test_to_geojson_skips_nodes_matching_no_known_category
response = { "elements" => [{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "restaurant" } }] }
assert_empty Maptool::OverpassPois.to_geojson(response)["features"]
end
def test_lpg_matches_either_fuel_stations_with_the_lpg_tag_or_gas_bottle_shops
response = {
"elements" => [
{ "type" => "node", "lat" => 1, "lon" => 1, "tags" => { "amenity" => "fuel", "fuel:lpg" => "yes" } },
{ "type" => "node", "lat" => 2, "lon" => 2, "tags" => { "shop" => "gas" } },
{ "type" => "node", "lat" => 3, "lon" => 3, "tags" => { "amenity" => "fuel" } }, # no lpg tag - not a match
],
}
categories = Maptool::OverpassPois.to_geojson(response)["features"].map { |f| f["properties"]["category"] }
assert_equal ["LPG refill", "LPG refill"], categories
end
def test_build_query_includes_the_bbox_and_every_category_filter
query = Maptool::OverpassPois.build_query([48.0, 17.0, 49.0, 18.0])
assert_includes query, "48.0,17.0,49.0,18.0"
assert_includes query, %(["amenity"="drinking_water"])
assert_includes query, %(["natural"="spring"])
end
end