Compare commits
2
Commits
5caf865fc8
...
f53c74e06f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53c74e06f | ||
|
|
aa2b96ac70 |
No files matched your search
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, "&").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(value) {
|
||||||
|
return value.replace(/&/g, "&").replace(/</g, "<");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user