Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24ac5931f8 | ||
|
|
7cd3171998 | ||
|
|
5576d2edf1 |
No files matched your search
@@ -12,6 +12,11 @@
|
|||||||
# see docs/development.adoc)
|
# see docs/development.adoc)
|
||||||
/data/*.pmtiles
|
/data/*.pmtiles
|
||||||
|
|
||||||
|
# Offline Wikipedia ZIM for kiwix-serve (also large, also local-only —
|
||||||
|
# same story, see docs/development.adoc)
|
||||||
|
/data/kiwix/*
|
||||||
|
!/data/kiwix/.gitkeep
|
||||||
|
|
||||||
# User-uploaded GPX files
|
# User-uploaded GPX files
|
||||||
/data/gpx/*
|
/data/gpx/*
|
||||||
!/data/gpx/.gitkeep
|
!/data/gpx/.gitkeep
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ independence from connectivity.
|
|||||||
* [ ] Contour lines from OSM for hikes and mountain passes
|
* [ ] 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, not called by the running app itself
|
* [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
|
* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link straight to kiwix-serve's own geo search (`GET /search?latitude=&longitude=&distance=`, confirmed from its actual source, `libkiwix/internalServer.cpp`), not proxied/embedded — it already has a full search UI. `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). Not live-tested against a real ZIM (none available here) — verified via a stand-in local HTTP server + real popup-link generation
|
||||||
* [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
|
||||||
** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb
|
** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb
|
||||||
|
|
||||||
== NPS visual style
|
== NPS visual style
|
||||||
|
|
||||||
* [ ] Map Park4Night/OSM place types to NPS pictograms (campground, primitive camp, drinking water, dump station, viewpoint, picnic table) for instant recognition without reading text
|
* [x] Map Park4Night/OSM place types to NPS pictograms (campground, primitive camp, drinking water, dump station, viewpoint, picnic table) for instant recognition without reading text — verified against real p4n2gpx exports (`data/gpx/p4n_*.gpx`, ~5000 waypoints, 15 distinct `<type>` values): every one already resolves to a registered `POI_ICONS` key, 0 waypoints fall back iconless. Drinking water/dump station aren't standalone p4n place types (they're description tags on a camping spot, already shown via `PARK4NIGHT_TAGS`); viewpoint doesn't appear in these exports but has an icon (`tourism_viewpoint`) ready if it ever does
|
||||||
* [ ] Restyle popovers, side panels, and point detail views after the NPS Unigrid layout system (Massimo Vignelli) with Frutiger/Rawlinson Roadside-style typography
|
* [ ] Restyle popovers, side panels, and point detail views after the NPS Unigrid layout system (Massimo Vignelli) with Frutiger/Rawlinson Roadside-style typography
|
||||||
** [x] Top black banner (full width): "MAPTOOL" on the left, live map-center coordinates in the center, offline-data status dot on the right, white sans-serif type — DEM elevation and a hamburger menu skipped (no DEM layer yet; sidebar isn't mobile-collapsed, so nothing for a hamburger to reveal)
|
** [x] Top black banner (full width): "MAPTOOL" on the left, live map-center coordinates in the center, offline-data status dot on the right, white sans-serif type — DEM elevation and a hamburger menu skipped (no DEM layer yet; sidebar isn't mobile-collapsed, so nothing for a hamburger to reveal)
|
||||||
** [ ] Point detail/sidepanel: black header banner with white location name and an NPS pictogram in the corner; amenities (water, waste, power, slope) shown as a pictogram grid, no extra text; action buttons for "route here" (Valhalla), "start leveling", "save offline"
|
** [ ] Point detail/sidepanel: black header banner with white location name and an NPS pictogram in the corner; amenities (water, waste, power, slope) shown as a pictogram grid, no extra text; action buttons for "route here" (Valhalla), "start leveling", "save offline"
|
||||||
@@ -47,6 +47,9 @@ independence from connectivity.
|
|||||||
** [x] Fixed: a note saved before `properties.id` existed (the earliest test notes) had no Edit/Delete-able target — the popup no longer offers them without an id, and `rake gpx:backfill_note_ids` assigns one to any pre-existing Note feature missing it
|
** [x] Fixed: a note saved before `properties.id` existed (the earliest test notes) had no Edit/Delete-able target — the popup no longer offers them without an id, and `rake gpx:backfill_note_ids` assigns one to any pre-existing Note feature missing it
|
||||||
** [x] Title + body split (name in the black header, body in a separate field) with light self-authored markdown (`**bold**`, `*italic*`, auto-linked URLs) in the body — a *new*, separate `formatNoteBody` (not `formatDescription`, which stays plain/untrusted-safe for uploaded GPX/park4night content per docs/nps-resources.adoc's earlier markdown-rendering call)
|
** [x] Title + body split (name in the black header, body in a separate field) with light self-authored markdown (`**bold**`, `*italic*`, auto-linked URLs) in the body — a *new*, separate `formatNoteBody` (not `formatDescription`, which stays plain/untrusted-safe for uploaded GPX/park4night content per docs/nps-resources.adoc's earlier markdown-rendering call)
|
||||||
** [x] Gated behind login: NotesControl isn't added to the map at all when logged out, Measure's "Save as note" button is hidden (Measure itself stays usable either way)
|
** [x] Gated behind login: NotesControl isn't added to the map at all when logged out, Measure's "Save as note" button is hidden (Measure itself stays usable either way)
|
||||||
|
** [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
|
** [ ] 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
|
||||||
* [ ] 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
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require_relative "lib/maptool/gpx_store"
|
|||||||
require_relative "lib/maptool/gpsd_client"
|
require_relative "lib/maptool/gpsd_client"
|
||||||
require_relative "lib/maptool/livetrack_store"
|
require_relative "lib/maptool/livetrack_store"
|
||||||
require_relative "lib/maptool/livetrack_client"
|
require_relative "lib/maptool/livetrack_client"
|
||||||
|
require_relative "lib/maptool/kiwix"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
class App < Sinatra::Base
|
class App < Sinatra::Base
|
||||||
@@ -69,6 +70,12 @@ module Maptool
|
|||||||
# OAuth2 secrets: fine outside production, nothing works without it set.
|
# OAuth2 secrets: fine outside production, nothing works without it set.
|
||||||
LIVETRACK_WEBHOOK_SECRET = ENV.fetch("LIVETRACK_WEBHOOK_SECRET", "")
|
LIVETRACK_WEBHOOK_SECRET = ENV.fetch("LIVETRACK_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
# An external kiwix-serve instance (see docs/development.adoc), not
|
||||||
|
# something this app runs itself — blank disables the "Nearby on
|
||||||
|
# Wikipedia" popup link the same way the other optional-integration
|
||||||
|
# blanks above do.
|
||||||
|
KIWIX_URL = ENV.fetch("KIWIX_URL", "")
|
||||||
|
|
||||||
# Login delegates to the Gitea instance the user already has an account
|
# Login delegates to the Gitea instance the user already has an account
|
||||||
# on, rather than maintaining a local username/password — see
|
# on, rather than maintaining a local username/password — see
|
||||||
# docs/architecture.adoc. Only users listed in ALLOWED_GITEA_USERS may
|
# docs/architecture.adoc. Only users listed in ALLOWED_GITEA_USERS may
|
||||||
@@ -199,6 +206,13 @@ module Maptool
|
|||||||
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Pinged once per page load (public/js/map.js) — url is handed back so
|
||||||
|
# the frontend can build the geo-search link itself; this server never
|
||||||
|
# proxies to kiwix-serve, just says whether it's there.
|
||||||
|
get "/api/kiwix" do
|
||||||
|
json available: Kiwix.reachable?(KIWIX_URL), url: KIWIX_URL
|
||||||
|
end
|
||||||
|
|
||||||
get "/api/gpx/tree" do
|
get "/api/gpx/tree" do
|
||||||
json GPX_STORE.tree
|
json GPX_STORE.tree
|
||||||
end
|
end
|
||||||
|
|||||||
Whitespace-only changes.
@@ -21,6 +21,13 @@ services:
|
|||||||
# Optional: blank disables the LiveTrack webhook endpoint rather than
|
# Optional: blank disables the LiveTrack webhook endpoint rather than
|
||||||
# failing to start, since it's not needed for the app's core function.
|
# failing to start, since it's not needed for the app's core function.
|
||||||
LIVETRACK_WEBHOOK_SECRET: ${LIVETRACK_WEBHOOK_SECRET:-}
|
LIVETRACK_WEBHOOK_SECRET: ${LIVETRACK_WEBHOOK_SECRET:-}
|
||||||
|
# The kiwix service below, reached by its compose service name (both
|
||||||
|
# are on the same default compose network) — port matches its own
|
||||||
|
# --port=8080 in the command below. Not made conditional on the ZIM
|
||||||
|
# actually being present: GET /api/kiwix pings it and just reports
|
||||||
|
# unavailable if the container isn't up/reachable, same story as
|
||||||
|
# every other optional-local-file feature (offline.pmtiles, etc).
|
||||||
|
KIWIX_URL: "http://kiwix:8080"
|
||||||
volumes:
|
volumes:
|
||||||
# Uploaded GPX files and the offline PMTiles extract — kept outside
|
# Uploaded GPX files and the offline PMTiles extract — kept outside
|
||||||
# the image so a rebuild/redeploy doesn't wipe them (see
|
# the image so a rebuild/redeploy doesn't wipe them (see
|
||||||
@@ -43,3 +50,19 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: "service:maptool"
|
network_mode: "service:maptool"
|
||||||
command: ["ruby", "bin/gnss_simulator.rb"]
|
command: ["ruby", "bin/gnss_simulator.rb"]
|
||||||
|
|
||||||
|
# Serves an offline Wikipedia ZIM for the popup's "Nearby on Wikipedia"
|
||||||
|
# link (see docs/development.adoc for where to get one and drop it in —
|
||||||
|
# same "not part of the repo, place it under ./data locally" story as
|
||||||
|
# offline.pmtiles/terrain.pmtiles). On the default compose network (not
|
||||||
|
# network_mode: "service:maptool" — unlike gnss-simulator this doesn't
|
||||||
|
# need a hardcoded localhost port, KIWIX_URL above reaches it by this
|
||||||
|
# service's own name), so maptool and kiwix stay independent containers.
|
||||||
|
# Keeps restarting (harmlessly) if data/kiwix/wikipedia.zim isn't there
|
||||||
|
# yet — GET /api/kiwix just reports unavailable until it comes up.
|
||||||
|
kiwix:
|
||||||
|
image: ghcr.io/kiwix/kiwix-serve:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["--port=8080", "/data/wikipedia.zim"]
|
||||||
|
volumes:
|
||||||
|
- ./data/kiwix:/data
|
||||||
@@ -35,9 +35,11 @@ expect this to flip the other way, `/srv/docker` symlinked to
|
|||||||
below) — `Dockerfile`, `docker-compose.yaml`, application code.
|
below) — `Dockerfile`, `docker-compose.yaml`, application code.
|
||||||
`podman compose` reads the same `docker-compose.yaml` — no
|
`podman compose` reads the same `docker-compose.yaml` — no
|
||||||
Podman-specific compose file needed.
|
Podman-specific compose file needed.
|
||||||
* `data/`, *not* part of that sync — uploaded GPX files and
|
* `data/`, *not* part of that sync — uploaded GPX files,
|
||||||
`offline.pmtiles`. Persisted via a bind mount (`./data:/app/data` in
|
`offline.pmtiles`, `terrain.pmtiles`, and `kiwix/wikipedia.zim` (see
|
||||||
`docker-compose.yaml`), so a redeploy never touches it.
|
`docs/development.adoc` for each). Persisted via a bind mount
|
||||||
|
(`./data:/app/data` in `docker-compose.yaml`), so a redeploy never
|
||||||
|
touches it.
|
||||||
|
|
||||||
== The native deploy runner
|
== The native deploy runner
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,32 @@ something the running app calls itself — drop the output into
|
|||||||
mapped as a way/relation, e.g. a supermarket traced as a building
|
mapped as a way/relation, e.g. a supermarket traced as a building
|
||||||
outline rather than a single point).
|
outline rather than a single point).
|
||||||
|
|
||||||
|
== Offline Wikipedia (kiwix-serve)
|
||||||
|
|
||||||
|
A point popup's "Nearby on Wikipedia" link needs
|
||||||
|
https://github.com/kiwix/kiwix-tools[kiwix-serve] running with a
|
||||||
|
Wikipedia https://library.kiwix.org[ZIM] loaded — set `KIWIX_URL` to
|
||||||
|
wherever it's reachable (`docker-compose.yaml` already runs a `kiwix`
|
||||||
|
service pointed at `data/kiwix/wikipedia.zim` and wires `KIWIX_URL` to
|
||||||
|
it; blank/unset disables the link, same as every other optional
|
||||||
|
integration here). Not part of the repo — a ZIM is a large,
|
||||||
|
self-contained archive, obtain one locally:
|
||||||
|
|
||||||
|
* https://library.kiwix.org/#lang=eng&category=wikipedia[Kiwix's own
|
||||||
|
library] has pre-built Wikipedia ZIMs — a `_maxi` variant per
|
||||||
|
language/region includes images, `_nopic` is much smaller if
|
||||||
|
browsing images offline doesn't matter here.
|
||||||
|
* Download it to `data/kiwix/wikipedia.zim` (the exact filename
|
||||||
|
`docker-compose.yaml`'s `kiwix` service expects — rename after
|
||||||
|
downloading, Kiwix's own filenames are much more specific).
|
||||||
|
|
||||||
|
Once it's there, `docker compose up -d kiwix` (or a full restart)
|
||||||
|
picks it up — `GET /api/kiwix` reports `available: true` and the
|
||||||
|
"Nearby on Wikipedia" link appears. kiwix-serve's own geo search
|
||||||
|
(`GET /search?pattern=&latitude=&longitude=&distance=`,
|
||||||
|
`lib/maptool/kiwix.rb`'s docstring) is linked to directly, not
|
||||||
|
proxied/embedded — it already has a full search UI of its own.
|
||||||
|
|
||||||
== 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
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
require "net/http"
|
||||||
|
|
||||||
|
module Maptool
|
||||||
|
# A thin reachability check for an external kiwix-serve instance serving
|
||||||
|
# an offline Wikipedia ZIM (see docs/development.adoc for how to set one
|
||||||
|
# up) — the app itself never talks to it beyond this ping. Linking a POI
|
||||||
|
# popup to a geo search there (public/js/gpx.js) is a plain browser <a
|
||||||
|
# href> straight to kiwix-serve, not something this server proxies —
|
||||||
|
# kiwix-serve already has its own full search UI, no reason to rebuild it.
|
||||||
|
module Kiwix
|
||||||
|
def self.reachable?(url, open_timeout: 1, read_timeout: 2)
|
||||||
|
return false if url.to_s.strip.empty?
|
||||||
|
|
||||||
|
uri = URI(url)
|
||||||
|
Net::HTTP.start(uri.host, uri.port, open_timeout: open_timeout, read_timeout: read_timeout) { |http| http.head("/") }
|
||||||
|
true
|
||||||
|
rescue StandardError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+52
-1
@@ -888,6 +888,48 @@ html, body {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notes-panel__icons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same "author display wins over the UA [hidden] rule" gotcha as
|
||||||
|
.gpx-panel__action[hidden] elsewhere in this file. */
|
||||||
|
.notes-panel__icons[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__icon-option {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__icon-option svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__icon-option:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__icon-option.active {
|
||||||
|
border-color: #1a5fb4;
|
||||||
|
color: #1a5fb4;
|
||||||
|
background: #dbe9ff;
|
||||||
|
}
|
||||||
|
|
||||||
.notes-panel__target,
|
.notes-panel__target,
|
||||||
.notes-panel__new-name {
|
.notes-panel__new-name {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -917,7 +959,11 @@ html, body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes-panel__save {
|
/* ".notes-panel__actions " prefix (not just ".notes-panel__save") to
|
||||||
|
outrank ".notes-panel__actions button" — same class-count-vs-class+
|
||||||
|
element specificity trap as elsewhere in this file; without it this
|
||||||
|
lost and the Save button just looked like a second Cancel button. */
|
||||||
|
.notes-panel__actions .notes-panel__save {
|
||||||
background: #1a5fb4;
|
background: #1a5fb4;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -962,6 +1008,11 @@ html, body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpx-popup__wiki {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.gpx-popup__category {
|
.gpx-popup__category {
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+21
-4
@@ -702,7 +702,7 @@ export class GpxPanelControl {
|
|||||||
const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn();
|
const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn();
|
||||||
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
|
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
|
||||||
.setLngLat(event.lngLat)
|
.setLngLat(event.lngLat)
|
||||||
.setHTML(popupHtml(feature.properties, { editable }))
|
.setHTML(popupHtml(feature.properties, { editable, lngLat: event.lngLat }))
|
||||||
.addTo(this._map);
|
.addTo(this._map);
|
||||||
if (editable) this._wireNoteActions(popup, path, feature);
|
if (editable) this._wireNoteActions(popup, path, feature);
|
||||||
});
|
});
|
||||||
@@ -754,7 +754,7 @@ export class GpxPanelControl {
|
|||||||
const bodyInput = content.querySelector(".gpx-popup__edit-body");
|
const bodyInput = content.querySelector(".gpx-popup__edit-body");
|
||||||
titleInput.focus();
|
titleInput.focus();
|
||||||
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
|
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
|
||||||
content.innerHTML = popupHtml(feature.properties, { editable: true });
|
content.innerHTML = popupHtml(feature.properties, { editable: true, lngLat: popup.getLngLat() });
|
||||||
this._wireNoteActions(popup, path, feature);
|
this._wireNoteActions(popup, path, feature);
|
||||||
});
|
});
|
||||||
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
|
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
|
||||||
@@ -1077,13 +1077,26 @@ function gpxFileUrl(path) {
|
|||||||
return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`;
|
return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set once from map.js after its one-time GET /api/kiwix check (same
|
||||||
|
// fetch-once-at-load story as the offline-tiles/terrain-tiles checks) —
|
||||||
|
// module-level rather than threaded through every popupHtml call, since
|
||||||
|
// it's the same answer for the whole page's lifetime.
|
||||||
|
let kiwixConfig = { available: false, url: "" };
|
||||||
|
|
||||||
|
export function setKiwixConfig(config) {
|
||||||
|
kiwixConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
// properties come straight from an uploaded GPX file — untrusted input —
|
// properties come straight from an uploaded GPX file — untrusted input —
|
||||||
// so every value is escaped before landing in the popup's HTML.
|
// so every value is escaped before landing in the popup's HTML.
|
||||||
// editable (only ever true for a "Note" feature the viewer is logged in
|
// editable (only ever true for a "Note" feature the viewer is logged in
|
||||||
// for — see _registerPointInteractivity) adds Edit/Delete buttons; the
|
// for — see _registerPointInteractivity) adds Edit/Delete buttons; the
|
||||||
// click handling for them lives in _wireNoteActions, not here, since this
|
// click handling for them lives in _wireNoteActions, not here, since this
|
||||||
// only builds markup, it has no map/store access of its own.
|
// only builds markup, it has no map/store access of its own. lngLat (the
|
||||||
function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) {
|
// popup's own anchor point) drives the "Nearby on Wikipedia" link — kiwix-
|
||||||
|
// serve's own geo search, linked to directly rather than proxied/embedded
|
||||||
|
// (it already has a full search UI of its own, see docs/development.adoc).
|
||||||
|
function popupHtml({ name, description, time, category, photos, icon }, { editable = false, lngLat = null } = {}) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (name) {
|
if (name) {
|
||||||
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
|
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
|
||||||
@@ -1104,6 +1117,10 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab
|
|||||||
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (kiwixConfig.available && lngLat) {
|
||||||
|
const wikiUrl = `${kiwixConfig.url}/search?pattern=&latitude=${lngLat.lat}&longitude=${lngLat.lng}&distance=2000`;
|
||||||
|
parts.push(`<p class="gpx-popup__wiki"><a href="${escapeHtml(wikiUrl)}" target="_blank" rel="noopener noreferrer">Nearby on Wikipedia →</a></p>`);
|
||||||
|
}
|
||||||
if (editable) {
|
if (editable) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<div class="gpx-popup__note-actions"><button type="button" class="gpx-popup__edit">Edit</button><button type="button" class="gpx-popup__delete">Delete</button></div>`,
|
`<div class="gpx-popup__note-actions"><button type="button" class="gpx-popup__edit">Edit</button><button type="button" class="gpx-popup__delete">Delete</button></div>`,
|
||||||
|
|||||||
@@ -305,6 +305,29 @@ export const PARK4NIGHT_TAGS = {
|
|||||||
windsurf: { icon: WIND_SURFING_ICON, label: "Windsurfing" },
|
windsurf: { icon: WIND_SURFING_ICON, label: "Windsurfing" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extends POI_ICONS (and so POI_ICON_KEYS, and what a marker badge can
|
||||||
|
// show — see gpx.js's _registerCategoryGlyphs) with a handful of the same
|
||||||
|
// symbols PARK4NIGHT_TAGS above already vendors, reused rather than
|
||||||
|
// duplicated — this app's own hand-drawn notes (notes.js's icon picker)
|
||||||
|
// need marker-badge glyphs for "water source"/"dump station"/"viewpoint"
|
||||||
|
// far more than the original OSM-POI-upload set (parking/hotel/fuel) ever
|
||||||
|
// covered. Object.assign, not more entries in the POI_ICONS literal
|
||||||
|
// itself, since these constants are declared below it in this file.
|
||||||
|
Object.assign(POI_ICONS, {
|
||||||
|
amenity_drinking_water: DRINKING_WATER_ICON,
|
||||||
|
amenity_sanitary_dump_station: SANITARY_DISPOSAL_ICON,
|
||||||
|
amenity_toilets: RESTROOMS_ICON,
|
||||||
|
shop_gas: PROPANE_ICON,
|
||||||
|
amenity_electricity: ELECTRICAL_HOOKUP_ICON,
|
||||||
|
shop_laundry: LAUNDRY_ICON,
|
||||||
|
leisure_playground: PLAYGROUND_ICON,
|
||||||
|
tourism_viewpoint: SCENIC_VIEWPOINT_ICON,
|
||||||
|
leisure_swimming: SWIMMING_ICON,
|
||||||
|
leisure_fishing: FISHING_ICON,
|
||||||
|
sport_climbing: CLIMBING_ICON,
|
||||||
|
route_hiking: WALKING_ICON,
|
||||||
|
});
|
||||||
|
|
||||||
export const LIVETRACK_ICON = `
|
export const LIVETRACK_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">
|
||||||
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
|
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
|
||||||
|
|||||||
+6
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
addProtocol,
|
addProtocol,
|
||||||
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
import { RadarControl } from "/js/radar.js";
|
import { RadarControl } from "/js/radar.js";
|
||||||
import { GpxPanelControl } from "/js/gpx.js";
|
import { GpxPanelControl, setKiwixConfig } from "/js/gpx.js";
|
||||||
import { GnssControl } from "/js/gnss.js";
|
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";
|
||||||
@@ -609,6 +609,11 @@ fetch("/api/terrain-tiles")
|
|||||||
.then(({ available }) => hillshadeControl.setOfflineAvailable(available))
|
.then(({ available }) => hillshadeControl.setOfflineAvailable(available))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
fetch("/api/kiwix")
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then(setKiwixConfig)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
// Alt+click anywhere shows every rendered feature at that point — base map
|
// Alt+click anywhere shows every rendered feature at that point — base map
|
||||||
// (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it —
|
// (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it —
|
||||||
// with its raw properties, inspired by NPMap4's infoboxControl (a generic
|
// with its raw properties, inspired by NPMap4's infoboxControl (a generic
|
||||||
|
|||||||
+69
-4
@@ -1,6 +1,31 @@
|
|||||||
import { NOTE_PIN_ICON } from "/js/icons.js";
|
import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
|
import { NOTE_PIN_ICON, POI_ICONS } from "/js/icons.js";
|
||||||
import { notifyGpxStoreChanged } from "/js/panels.js";
|
import { notifyGpxStoreChanged } from "/js/panels.js";
|
||||||
|
|
||||||
|
// Human labels for the icon picker's title attribute — POI_ICONS' own keys
|
||||||
|
// are internal (OSM-tag-shaped) ids, not display text.
|
||||||
|
const ICON_LABELS = {
|
||||||
|
amenity_parking: "Parking",
|
||||||
|
tourism_camp_site: "Camp site",
|
||||||
|
leisure_picnic_table: "Picnic table",
|
||||||
|
amenity_fuel: "Fuel",
|
||||||
|
tourism_hotel: "Lodging",
|
||||||
|
forest: "Forest",
|
||||||
|
sport_cycling: "Cycling",
|
||||||
|
amenity_drinking_water: "Drinking water",
|
||||||
|
amenity_sanitary_dump_station: "Dump station",
|
||||||
|
amenity_toilets: "Toilets",
|
||||||
|
shop_gas: "Gas/LPG refill",
|
||||||
|
amenity_electricity: "Electricity",
|
||||||
|
shop_laundry: "Laundry",
|
||||||
|
leisure_playground: "Playground",
|
||||||
|
tourism_viewpoint: "Viewpoint",
|
||||||
|
leisure_swimming: "Swimming",
|
||||||
|
leisure_fishing: "Fishing",
|
||||||
|
sport_climbing: "Climbing",
|
||||||
|
route_hiking: "Hiking",
|
||||||
|
};
|
||||||
|
|
||||||
const NEW_LAYER_VALUE = "__new__";
|
const NEW_LAYER_VALUE = "__new__";
|
||||||
|
|
||||||
// Every hand-drawn note (from here or measure.js's "Save as note") gets one
|
// Every hand-drawn note (from here or measure.js's "Save as note") gets one
|
||||||
@@ -29,6 +54,15 @@ export class NoteSaveForm {
|
|||||||
this._el.hidden = true;
|
this._el.hidden = true;
|
||||||
this._el.innerHTML = `
|
this._el.innerHTML = `
|
||||||
<input type="text" class="notes-panel__title" placeholder="Title">
|
<input type="text" class="notes-panel__title" placeholder="Title">
|
||||||
|
<div class="notes-panel__icons">
|
||||||
|
<button type="button" class="notes-panel__icon-option active" data-icon="" title="No icon">${NOTE_PIN_ICON}</button>
|
||||||
|
${Object.keys(POI_ICONS)
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`<button type="button" class="notes-panel__icon-option" data-icon="${key}" title="${escapeAttr(ICON_LABELS[key] || key)}">${POI_ICONS[key]}</button>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
<textarea class="notes-panel__body" rows="3" placeholder="Details (optional) — **bold**, *italic*, links"></textarea>
|
<textarea class="notes-panel__body" rows="3" placeholder="Details (optional) — **bold**, *italic*, links"></textarea>
|
||||||
<select class="notes-panel__target"></select>
|
<select class="notes-panel__target"></select>
|
||||||
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
||||||
@@ -40,11 +74,19 @@ export class NoteSaveForm {
|
|||||||
container.appendChild(this._el);
|
container.appendChild(this._el);
|
||||||
|
|
||||||
this._title = this._el.querySelector(".notes-panel__title");
|
this._title = this._el.querySelector(".notes-panel__title");
|
||||||
|
this._iconPicker = this._el.querySelector(".notes-panel__icons");
|
||||||
this._body = this._el.querySelector(".notes-panel__body");
|
this._body = this._el.querySelector(".notes-panel__body");
|
||||||
this._target = this._el.querySelector(".notes-panel__target");
|
this._target = this._el.querySelector(".notes-panel__target");
|
||||||
this._newName = this._el.querySelector(".notes-panel__new-name");
|
this._newName = this._el.querySelector(".notes-panel__new-name");
|
||||||
this._saveButton = this._el.querySelector(".notes-panel__save");
|
this._saveButton = this._el.querySelector(".notes-panel__save");
|
||||||
|
this._selectedIcon = "";
|
||||||
|
|
||||||
|
this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
this._selectedIcon = button.dataset.icon;
|
||||||
|
this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((b) => b.classList.toggle("active", b === button));
|
||||||
|
});
|
||||||
|
});
|
||||||
this._target.addEventListener("change", () => {
|
this._target.addEventListener("change", () => {
|
||||||
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
|
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
|
||||||
if (!this._newName.hidden) this._newName.focus();
|
if (!this._newName.hidden) this._newName.focus();
|
||||||
@@ -62,6 +104,12 @@ export class NoteSaveForm {
|
|||||||
this._el.hidden = false;
|
this._el.hidden = false;
|
||||||
this._title.value = "";
|
this._title.value = "";
|
||||||
this._body.value = "";
|
this._body.value = "";
|
||||||
|
this._selectedIcon = "";
|
||||||
|
this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((b, i) => b.classList.toggle("active", i === 0));
|
||||||
|
// An icon only makes sense on a single point — Measure's "Save as
|
||||||
|
// note" can hand this a LineString/Polygon, which has no one spot for
|
||||||
|
// a badge glyph to sit at.
|
||||||
|
this._iconPicker.hidden = geometry.type !== "Point";
|
||||||
this._title.focus();
|
this._title.focus();
|
||||||
await this._populateTargets();
|
await this._populateTargets();
|
||||||
}
|
}
|
||||||
@@ -112,6 +160,7 @@ export class NoteSaveForm {
|
|||||||
category: "Note",
|
category: "Note",
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
...(details ? { description: details } : {}),
|
...(details ? { description: details } : {}),
|
||||||
|
...(this._selectedIcon ? { icon: this._selectedIcon } : {}),
|
||||||
},
|
},
|
||||||
geometry: this._geometry,
|
geometry: this._geometry,
|
||||||
};
|
};
|
||||||
@@ -143,6 +192,7 @@ export class NoteSaveForm {
|
|||||||
export class NotesControl {
|
export class NotesControl {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._active = false;
|
this._active = false;
|
||||||
|
this._marker = null; // where the pending note will land — visible until saved/cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd(map) {
|
onAdd(map) {
|
||||||
@@ -161,8 +211,14 @@ export class NotesControl {
|
|||||||
this._hint = this._container.querySelector(".notes-panel__hint");
|
this._hint = this._container.querySelector(".notes-panel__hint");
|
||||||
|
|
||||||
this._form = new NoteSaveForm(this._panel, {
|
this._form = new NoteSaveForm(this._panel, {
|
||||||
onSaved: () => (this._hint.hidden = false),
|
onSaved: () => {
|
||||||
onCancel: () => (this._hint.hidden = false),
|
this._hint.hidden = false;
|
||||||
|
this._removeMarker();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this._hint.hidden = false;
|
||||||
|
this._removeMarker();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
this._button.addEventListener("click", () => this._setActive(!this._active));
|
||||||
@@ -170,6 +226,7 @@ export class NotesControl {
|
|||||||
this._onClick = (event) => {
|
this._onClick = (event) => {
|
||||||
if (!this._active || this._form.isOpen) return;
|
if (!this._active || this._form.isOpen) return;
|
||||||
this._hint.hidden = true;
|
this._hint.hidden = true;
|
||||||
|
this._marker = new Marker({ color: "#c0392b" }).setLngLat(event.lngLat).addTo(this._map);
|
||||||
this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] });
|
this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] });
|
||||||
};
|
};
|
||||||
map.on("click", this._onClick);
|
map.on("click", this._onClick);
|
||||||
@@ -177,6 +234,11 @@ export class NotesControl {
|
|||||||
return this._container;
|
return this._container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_removeMarker() {
|
||||||
|
this._marker?.remove();
|
||||||
|
this._marker = null;
|
||||||
|
}
|
||||||
|
|
||||||
onRemove() {
|
onRemove() {
|
||||||
this._map.off("click", this._onClick);
|
this._map.off("click", this._onClick);
|
||||||
this._container.remove();
|
this._container.remove();
|
||||||
@@ -191,7 +253,10 @@ export class NotesControl {
|
|||||||
this._button.classList.toggle("active", active);
|
this._button.classList.toggle("active", active);
|
||||||
this._panel.hidden = !active;
|
this._panel.hidden = !active;
|
||||||
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
||||||
if (!active) this._form.close();
|
if (!active) {
|
||||||
|
this._form.close();
|
||||||
|
this._removeMarker();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal({ "available" => false }, json_body(response))
|
assert_equal({ "available" => false }, json_body(response))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_kiwix_reports_unavailable_when_no_url_configured
|
||||||
|
response = request.get("/api/kiwix")
|
||||||
|
assert_equal({ "available" => false, "url" => "" }, json_body(response))
|
||||||
|
end
|
||||||
|
|
||||||
def test_gnss_position_unavailable_with_no_fix
|
def test_gnss_position_unavailable_with_no_fix
|
||||||
response = request.get("/api/gnss/position", **logged_in_env)
|
response = request.get("/api/gnss/position", **logged_in_env)
|
||||||
assert_equal({ "available" => false }, json_body(response))
|
assert_equal({ "available" => false }, json_body(response))
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
require_relative "test_helper"
|
||||||
|
require "maptool/kiwix"
|
||||||
|
|
||||||
|
# No real network calls — a blank URL and a guaranteed-local connection
|
||||||
|
# refusal are both fully offline/deterministic; simulating a genuine
|
||||||
|
# reachable kiwix-serve would need spinning up a real HTTP server for what
|
||||||
|
# is, at heart, a two-line Net::HTTP wrapper — not worth it, same
|
||||||
|
# restraint LiveTrackClientTest already applies to its own network path.
|
||||||
|
class KiwixTest < Minitest::Test
|
||||||
|
def test_reachable_is_false_for_a_blank_url
|
||||||
|
refute Maptool::Kiwix.reachable?("")
|
||||||
|
refute Maptool::Kiwix.reachable?(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_reachable_is_false_when_nothing_is_listening
|
||||||
|
refute Maptool::Kiwix.reachable?("http://127.0.0.1:1", open_timeout: 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user