Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e3f9c7e8 | ||
|
|
ea9cd5fc40 | ||
|
|
7c664578af | ||
|
|
d9cc468478 |
No files matched your search
@@ -12,9 +12,10 @@ 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
|
||||
** [ ] Terrain slope readout for parking (numeric, not just visual shading)
|
||||
* [ ] Contour lines from OSM for hikes and mountain passes
|
||||
* [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. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query)
|
||||
* [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. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query). Each category now carries a real `POI_ICONS` glyph and color (not p4n2gpx's plain red marker) plus a popup description built from opening_hours/operator/website tags
|
||||
* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed
|
||||
** [ ] Park4Night / iOverlander / Freecampsites data itself — no plugin written yet for any of these, the infra above just makes adding one (in a private repo if needed) straightforward whenever that happens
|
||||
** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
|
||||
** [x] A private plugin's own dependencies now have somewhere to live too: `data/plugins/` is under the persistent `data/` bind mount (survives a redeploy, unlike `build/`), and `docs/deployment.adoc` documents the convention
|
||||
* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link to kiwix-serve's own geo search (`GET /kiwix/search?latitude=&longitude=&distance=`), proxied through this app (`GET /kiwix/*` in app.rb) rather than a direct link to kiwix-serve — production sits behind a reverse proxy that only forwards to this app, so no address exists for a browser to reach kiwix-serve at directly; kiwix-serve runs with `--urlRootLocation=/kiwix` so its own emitted links already match. `KIWIX_URL` + `GET /api/kiwix` reachability check, same optional-integration pattern as offline.pmtiles/terrain.pmtiles; `docker-compose.yaml` now runs a `kiwix` service, ZIM not included (see docs/development.adoc for where to get one)
|
||||
* [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 the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb
|
||||
@@ -51,7 +52,7 @@ independence from connectivity.
|
||||
** [x] The clicked point stays marked on the map while the note form is open (a plain `Marker`, removed on save/cancel/deactivate) — it was disappearing back into the flat map with nothing showing where the note would land
|
||||
** [x] Icon picker for point notes (a grid in the save form) — extends `POI_ICONS` with more of the symbols `PARK4NIGHT_TAGS` already vendors (water, dump station, toilets, playground, viewpoint, swimming, fishing, climbing, hiking, …), reusable as real marker-badge glyphs, not just inline tag icons. Hidden for a Measure-drawn line/polygon note — no single spot for a badge to sit at
|
||||
** [x] Fixed: the Save button's own blue background was losing to `.notes-panel__actions button`'s gray — same class-vs-class+element specificity trap as `.gpx-panel__action[hidden]` already has a comment about elsewhere in this file, just not yet applied here
|
||||
** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon/line ones sharing that category
|
||||
** [x] Fixed: the Unigrid Matrix category filter only re-sliced the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hid point notes but not polygon/line ones sharing that category. `_linesByPath` now tracks those alongside `_pointsByPath`, re-sliced/scanned the same way
|
||||
* [ ] 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)
|
||||
|
||||
@@ -106,11 +106,22 @@ module Maptool
|
||||
private_class_method :category_for
|
||||
|
||||
def self.description_for(tags)
|
||||
lines = DESCRIPTION_TAGS.filter_map { |tag, label| "#{label}: #{tags[tag]}" if tags[tag] }
|
||||
lines = DESCRIPTION_TAGS.filter_map { |tag, label| "#{label}: #{clean_tag(tags[tag])}" if tags[tag] }
|
||||
website = tags["website"] || tags["contact:website"]
|
||||
lines << website if website
|
||||
lines << clean_tag(website) if website
|
||||
lines.empty? ? nil : lines.join("\n")
|
||||
end
|
||||
private_class_method :description_for
|
||||
|
||||
# An OSM tag value is free text from a world-editable map — an embedded
|
||||
# newline could otherwise inject an extra "line" of its own into the
|
||||
# description, e.g. an operator tag ending in "\nhttps://evil.example"
|
||||
# would render as its own separate, legitimate-looking link line once
|
||||
# gpx.js's formatDescription (written for p4n2gpx's own well-formed
|
||||
# "Label: value" lines) splits on "\n".
|
||||
def self.clean_tag(value)
|
||||
value.to_s.tr("\n\r", " ").strip
|
||||
end
|
||||
private_class_method :clean_tag
|
||||
end
|
||||
end
|
||||
@@ -55,7 +55,11 @@ module Maptool
|
||||
|
||||
manifest_path = File.join(plugin_dir, "plugin.json")
|
||||
|
||||
command = JSON.parse(File.read(manifest_path))["command"]
|
||||
command = begin
|
||||
JSON.parse(File.read(manifest_path))["command"]
|
||||
rescue JSON::ParserError
|
||||
raise RunError, "plugin has an invalid plugin.json"
|
||||
end
|
||||
raise RunError, "plugin has no command" unless command
|
||||
|
||||
south, west, north, east = bbox
|
||||
|
||||
@@ -1487,8 +1487,17 @@ html, body {
|
||||
|
||||
.plugins-panel__status {
|
||||
min-width: 3.5em;
|
||||
max-width: 200px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
/* Overrides the parent's nowrap: "Running…"/"Done" never wrap either
|
||||
way, but a real RunError message (e.g. "plugin exited with status 1")
|
||||
needs to, or it just overflows the flyout unreadably. */
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.plugins-panel__status--error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.plugins-panel__empty {
|
||||
|
||||
+15
-2
@@ -43,6 +43,7 @@ export class GpxPanelControl {
|
||||
this._visible = new Set(); // file paths currently shown on the map
|
||||
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
||||
this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend
|
||||
this._linesByPath = new Map(); // path -> its lines/polygons FeatureCollection, same reason (a polygon/line note also carries a category)
|
||||
this._hiddenCategories = new Set(); // category names toggled off in the legend
|
||||
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
|
||||
|
||||
@@ -597,6 +598,7 @@ export class GpxPanelControl {
|
||||
this._addClusterLayers(pointsId, path);
|
||||
|
||||
if (points.features.length > 0) this._pointsByPath.set(path, points);
|
||||
if (lines.features.length > 0) this._linesByPath.set(path, lines);
|
||||
this._updateLegend();
|
||||
this._applyCategoryFilter();
|
||||
|
||||
@@ -843,6 +845,7 @@ export class GpxPanelControl {
|
||||
if (this._map.getSource(pointsId)) this._map.removeSource(pointsId);
|
||||
|
||||
this._pointsByPath.delete(path);
|
||||
this._linesByPath.delete(path);
|
||||
this._updateLegend();
|
||||
}
|
||||
|
||||
@@ -853,8 +856,8 @@ export class GpxPanelControl {
|
||||
// on the map, not just a read-only color key.
|
||||
_updateLegend() {
|
||||
const categories = new Map(); // category name -> { color, icon }
|
||||
for (const points of this._pointsByPath.values()) {
|
||||
for (const feature of points.features) {
|
||||
for (const collection of [...this._pointsByPath.values(), ...this._linesByPath.values()]) {
|
||||
for (const feature of collection.features) {
|
||||
const category = feature.properties.category;
|
||||
if (category && !categories.has(category)) {
|
||||
categories.set(category, { color: feature.properties.color || TRACK_COLOR, icon: feature.properties.icon });
|
||||
@@ -909,6 +912,16 @@ export class GpxPanelControl {
|
||||
const visible = points.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
|
||||
source.setData({ type: "FeatureCollection", features: visible });
|
||||
}
|
||||
// Same re-slicing for polygon/line notes sharing the "lines" source with
|
||||
// plain tracks/routes — those have no `category` of their own, so
|
||||
// `_hiddenCategories.has(undefined)` is always false and they're never
|
||||
// affected by this, only a hidden Note (or other categorized) shape is.
|
||||
for (const [path, lines] of this._linesByPath) {
|
||||
const source = this._map.getSource(linesSourceId(path));
|
||||
if (!source) continue;
|
||||
const visible = lines.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
|
||||
source.setData({ type: "FeatureCollection", features: visible });
|
||||
}
|
||||
}
|
||||
|
||||
async _uploadFiles(fileList, folder) {
|
||||
|
||||
@@ -24,6 +24,8 @@ const ICON_LABELS = {
|
||||
leisure_fishing: "Fishing",
|
||||
sport_climbing: "Climbing",
|
||||
route_hiking: "Hiking",
|
||||
shop_supermarket: "Supermarket",
|
||||
shop_convenience: "Convenience store",
|
||||
};
|
||||
|
||||
const NEW_LAYER_VALUE = "__new__";
|
||||
|
||||
+15
-4
@@ -83,12 +83,23 @@ export class PluginsControl {
|
||||
east: bounds.getEast(),
|
||||
});
|
||||
const response = await fetch(`/api/plugins/${encodeURIComponent(plugin.name)}/run`, { method: "POST", body });
|
||||
if (!response.ok) throw new Error(`run failed: ${response.status}`);
|
||||
const { path } = await response.json();
|
||||
notifyGpxStoreChanged(path);
|
||||
// Every app.rb response on this route is JSON, success or error —
|
||||
// the catch below is only for an infra-level failure in front of it
|
||||
// (a proxy's own HTML error page, a dropped connection) that never
|
||||
// reached app.rb at all.
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `run failed: ${response.status}`);
|
||||
notifyGpxStoreChanged(data.path);
|
||||
status.textContent = "Done";
|
||||
status.classList.remove("plugins-panel__status--error");
|
||||
} catch (err) {
|
||||
status.textContent = "Failed — see server log";
|
||||
// Plugins::RunError's message (e.g. "plugin exited with status 1")
|
||||
// reaches here as `data.error` above — nothing about a plugin run
|
||||
// failure gets logged server-side (app.rb turns it into a plain 502
|
||||
// JSON response, not an exception), so showing that message directly
|
||||
// is the only way to see why, short of SSHing in to reproduce it.
|
||||
status.textContent = err.message;
|
||||
status.classList.add("plugins-panel__status--error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,23 @@ class OverpassPoisTest < Minitest::Test
|
||||
assert_equal "Opening hours: 24/7\nOperator: Town Council\nhttps://example.com", description
|
||||
end
|
||||
|
||||
def test_to_geojson_strips_embedded_newlines_from_tag_values
|
||||
response = {
|
||||
"elements" => [
|
||||
{
|
||||
"type" => "node",
|
||||
"lat" => 48.1,
|
||||
"lon" => 17.2,
|
||||
"tags" => { "amenity" => "toilets", "operator" => "Acme Co\nhttps://evil.example" },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
description = Maptool::OverpassPois.to_geojson(response)["features"].first["properties"]["description"]
|
||||
|
||||
assert_equal "Operator: Acme Co https://evil.example", description, "an embedded newline must not inject a second, independently-rendered line"
|
||||
end
|
||||
|
||||
def test_to_geojson_omits_description_when_no_recognized_tags_are_present
|
||||
response = { "elements" => [{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "toilets" } }] }
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@ class PluginsTest < Minitest::Test
|
||||
assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) }
|
||||
end
|
||||
|
||||
# Distinct from the "did not print valid JSON" message above — that one's
|
||||
# about the script's stdout, this is about plugin.json itself, before the
|
||||
# script is ever run. Same exception class either way, but a clear
|
||||
# message matters since this is what a "Failed" plugin run shows the user
|
||||
# (see public/js/plugins.js).
|
||||
def test_run_raises_a_distinct_message_for_an_invalid_manifest
|
||||
File.write(File.join(@plugin_dir, "plugin.json"), "{not valid json")
|
||||
error = assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) }
|
||||
assert_match(/invalid plugin\.json/, error.message)
|
||||
end
|
||||
|
||||
# Multiple dirs (app.rb's PLUGINS_DIRS: the built-in plugins/ plus an
|
||||
# optional private one under data/plugins/) — list/run both accept an
|
||||
# array, not just the single-dir form the tests above exercise.
|
||||
|
||||
Reference in new issue
Block a user