Replace the per-popup "Nearby on Wikipedia" link with a live Wikipedia layer
The old link only ever surfaced one nearby article at a time, buried in whatever POI's popup you happened to open, and only for uploaded files with a category. Replaced it with a standalone "Wikipedia" toggle under the renamed "Tracks & Points" panel: enabling it adds a live map layer that refetches GET /api/kiwix/nearby on every pan/zoom and plots every nearby article as its own point. kiwix-serve's own geo search never echoes back a result's coordinates (confirmed live against the real production ZIM, both its HTML and ?format=xml output) — it only filters by distance from a point. So Maptool::Kiwix.nearby fetches each matching article's own page too and reads its <meta name="geo.position"> tag, which every geotagged Wikipedia article carries. Tried parallelizing those per-article fetches with threads first; measured no improvement against the real ZIM (kiwix-serve appears to serialize requests regardless), so it stayed sequential — ~1.6s for a 30-result page, fine for a refetch-on-pan-end layer. Clicking a point opens a small popup linking to the article (proxied through the existing /kiwix/* passthrough). New WIKI_ICON (NPS's bookstore-22.svg) marks these, kept out of POI_ICONS since that set doubles as notes.js's icon-picker option list and this fixed-glyph layer has no per-feature category to key off of.
This commit is contained in:
1 parent
47e3f9c7e8
commit
787df5478d
9 files changed
+354
-14
No files matched your search
@@ -16,7 +16,8 @@ independence from connectivity.
|
|||||||
* [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
|
* [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
|
||||||
** [x] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
|
** [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] 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] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — 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] Replaced the old per-popup "Nearby on Wikipedia" link (one POI at a time, easy to miss) with a standalone "Wikipedia" layer under Tracks & Points — a live `GET /api/kiwix/nearby` refetch on every pan/zoom, not a saved file. kiwix-serve's own geo search never echoes back a result's coordinates, so `Maptool::Kiwix.nearby` fetches each matching article's own page too and reads its `<meta name="geo.position">` tag — verified against the real production ZIM (~1.6s for a 30-result page; tried parallelizing those fetches with threads, no measured gain, kept sequential)
|
||||||
* [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
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,19 @@ module Maptool
|
|||||||
json available: reachable, url: (reachable ? "/kiwix" : "")
|
json available: reachable, url: (reachable ? "/kiwix" : "")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Backs the "Wikipedia" map layer (public/js/gpx.js) — replaces the old
|
||||||
|
# per-popup "Nearby on Wikipedia" link, which only ever pointed at one
|
||||||
|
# POI at a time. Read-only and login-free, same as the tree/plugin
|
||||||
|
# listing routes below: viewing the map needs no account, only writing
|
||||||
|
# to it does.
|
||||||
|
get "/api/kiwix/nearby" do
|
||||||
|
halt 404 if KIWIX_URL.empty?
|
||||||
|
bbox = %w[south west north east].map { |key| params[key].to_s }
|
||||||
|
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
|
||||||
|
|
||||||
|
json Kiwix.nearby(KIWIX_URL, bbox.map(&:to_f))
|
||||||
|
end
|
||||||
|
|
||||||
# Proxies kiwix-serve's own search UI and content pages through this
|
# Proxies kiwix-serve's own search UI and content pages through this
|
||||||
# app instead of linking the browser to it directly (see KIWIX_URL) —
|
# app instead of linking the browser to it directly (see KIWIX_URL) —
|
||||||
# kiwix-serve is started with --urlRootLocation=/kiwix
|
# kiwix-serve is started with --urlRootLocation=/kiwix
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
require "net/http"
|
require "net/http"
|
||||||
|
require "rexml/document"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
# A reachability check plus a passthrough proxy for an external
|
# A reachability check plus a passthrough proxy for an external
|
||||||
@@ -10,6 +11,13 @@ module Maptool
|
|||||||
# *this* app, leaving no address a browser could reach kiwix-serve at
|
# *this* app, leaving no address a browser could reach kiwix-serve at
|
||||||
# directly. GET /kiwix/* in app.rb calls .proxy below instead.
|
# directly. GET /kiwix/* in app.rb calls .proxy below instead.
|
||||||
module Kiwix
|
module Kiwix
|
||||||
|
# <meta name="geo.position" content="LAT;LON"> — a standard tag
|
||||||
|
# Wikipedia's own MediaWiki geo extension puts on every geotagged
|
||||||
|
# article's page (confirmed live against a real ZIM), semicolon rather
|
||||||
|
# than comma-separated. Far simpler to match than the human-facing
|
||||||
|
# "48°08′46″N 17°10′38″E / 48.14611°N 17.17722°E" markup the article
|
||||||
|
# body itself renders (DMS *and* decimal, in varying wrapper markup).
|
||||||
|
GEO_POSITION_PATTERN = /<meta name="geo\.position" content="(-?[\d.]+);(-?[\d.]+)"/
|
||||||
def self.reachable?(url, open_timeout: 1, read_timeout: 2)
|
def self.reachable?(url, open_timeout: 1, read_timeout: 2)
|
||||||
return false if url.to_s.strip.empty?
|
return false if url.to_s.strip.empty?
|
||||||
|
|
||||||
@@ -41,5 +49,82 @@ module Maptool
|
|||||||
rescue StandardError
|
rescue StandardError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# bbox: [south, west, north, east] — the "Wikipedia" map layer
|
||||||
|
# (public/js/gpx.js) refetches this on every pan/zoom for whatever's
|
||||||
|
# currently on screen. kiwix-serve's own geo search only *filters* by
|
||||||
|
# distance from a point, it doesn't echo each result's own coordinates
|
||||||
|
# back (confirmed live: neither its HTML nor its ?format=xml output
|
||||||
|
# carries one) — so every matching article's page is fetched too, and
|
||||||
|
# its coordinates read from GEO_POSITION_PATTERN above. Cheap since
|
||||||
|
# kiwix-serve serves its ZIM locally with no network round trip, unlike
|
||||||
|
# this same fetch-every-result shape would be against live Wikipedia.
|
||||||
|
def self.nearby(base_url, bbox, limit: 30, open_timeout: 2, read_timeout: 10)
|
||||||
|
south, west, north, east = bbox
|
||||||
|
lat = (south + north) / 2.0
|
||||||
|
lng = (west + east) / 2.0
|
||||||
|
radius_m = (haversine_km(lat, lng, north, east) * 1000).round
|
||||||
|
|
||||||
|
query = URI.encode_www_form(pattern: "", latitude: lat, longitude: lng, distance: radius_m, pageLength: limit, format: "xml")
|
||||||
|
response = proxy(base_url, "/kiwix/search", query, open_timeout: open_timeout, read_timeout: read_timeout)
|
||||||
|
return empty_collection unless response&.code == "200"
|
||||||
|
|
||||||
|
# One request per result — tried running these in parallel threads
|
||||||
|
# first (each is blocking I/O, so MRI's GIL releases during the
|
||||||
|
# actual wait), but measured no improvement against the real
|
||||||
|
# production ZIM (kiwix-serve itself appears to serialize incoming
|
||||||
|
# requests regardless), so plain sequential stayed. ~1.6s for a
|
||||||
|
# 30-result page on that box — fine for a refetch-on-pan-end layer,
|
||||||
|
# not for anything tighter; lower `limit` first if that's ever not
|
||||||
|
# enough headroom, before reaching for something more involved.
|
||||||
|
features = parse_search_results(response.body).filter_map { |result| point_feature(base_url, result, open_timeout, read_timeout) }
|
||||||
|
{ "type" => "FeatureCollection", "features" => features }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.empty_collection
|
||||||
|
{ "type" => "FeatureCollection", "features" => [] }
|
||||||
|
end
|
||||||
|
private_class_method :empty_collection
|
||||||
|
|
||||||
|
def self.parse_search_results(xml)
|
||||||
|
doc = REXML::Document.new(xml)
|
||||||
|
(doc.root&.get_elements("channel/item") || []).filter_map do |item|
|
||||||
|
title = item.elements["title"]&.text
|
||||||
|
link = item.elements["link"]&.text
|
||||||
|
{ title: title, link: link } if title && link
|
||||||
|
end
|
||||||
|
rescue REXML::ParseException
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
private_class_method :parse_search_results
|
||||||
|
|
||||||
|
# result[:link] arrives already percent-encoded, straight out of
|
||||||
|
# kiwix-serve's own XML (e.g. "/kiwix/content/wikipedia/%C5%A0tadi%C3%B3n_Rapid")
|
||||||
|
# — decoded first so .proxy's own escaping (built for Sinatra's
|
||||||
|
# already-*decoded* splat, see its own comment above) doesn't double-
|
||||||
|
# encode the "%" and 404 the request.
|
||||||
|
def self.point_feature(base_url, result, open_timeout, read_timeout)
|
||||||
|
response = proxy(base_url, URI::RFC2396_PARSER.unescape(result[:link]), nil, open_timeout: open_timeout, read_timeout: read_timeout)
|
||||||
|
return nil unless response&.code == "200"
|
||||||
|
|
||||||
|
match = response.body.match(GEO_POSITION_PATTERN)
|
||||||
|
return nil unless match
|
||||||
|
|
||||||
|
{
|
||||||
|
"type" => "Feature",
|
||||||
|
"properties" => { "name" => result[:title], "link" => result[:link] },
|
||||||
|
"geometry" => { "type" => "Point", "coordinates" => [match[2].to_f, match[1].to_f] },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
private_class_method :point_feature
|
||||||
|
|
||||||
|
def self.haversine_km(lat1, lng1, lat2, lng2)
|
||||||
|
earth_radius_km = 6371.0
|
||||||
|
dlat = (lat2 - lat1) * Math::PI / 180
|
||||||
|
dlng = (lng2 - lng1) * Math::PI / 180
|
||||||
|
a = (Math.sin(dlat / 2)**2) + (Math.cos(lat1 * Math::PI / 180) * Math.cos(lat2 * Math::PI / 180) * (Math.sin(dlng / 2)**2))
|
||||||
|
2 * earth_radius_km * Math.asin(Math.sqrt(a))
|
||||||
|
end
|
||||||
|
private_class_method :haversine_km
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -373,6 +373,21 @@ html, body {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpx-panel__wiki {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 12px 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-panel__wiki[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.gpx-tree {
|
.gpx-tree {
|
||||||
/* flex:1 (not just overflow-y) so the tree fills the rest of the panel
|
/* flex:1 (not just overflow-y) so the tree fills the rest of the panel
|
||||||
even when short/empty — otherwise the blank space below it wouldn't
|
even when short/empty — otherwise the blank space below it wouldn't
|
||||||
|
|||||||
+153
-12
@@ -11,6 +11,7 @@ import {
|
|||||||
MAP_MARKER_ICON,
|
MAP_MARKER_ICON,
|
||||||
POI_ICONS,
|
POI_ICONS,
|
||||||
PARK4NIGHT_TAGS,
|
PARK4NIGHT_TAGS,
|
||||||
|
WIKI_ICON,
|
||||||
} from "/js/icons.js";
|
} from "/js/icons.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js";
|
||||||
import { formatActivityType } from "/js/track_profile.js";
|
import { formatActivityType } from "/js/track_profile.js";
|
||||||
@@ -27,6 +28,13 @@ const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker b
|
|||||||
const MAP_MARKER_SIZE = 0.55;
|
const MAP_MARKER_SIZE = 0.55;
|
||||||
const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors
|
const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors
|
||||||
|
|
||||||
|
// The "Wikipedia" layer (a live GET /api/kiwix/nearby refetch on every
|
||||||
|
// pan/zoom, not a file in the tree — see _enableWikiLayer) — one fixed
|
||||||
|
// icon/color for every point on it, unlike a POI file's per-category ones.
|
||||||
|
const WIKI_SOURCE_ID = "wiki-points";
|
||||||
|
const WIKI_ICON_ID = "wiki-icon";
|
||||||
|
const WIKI_BADGE_COLOR = "#795548";
|
||||||
|
|
||||||
// Custom drag payload MIME type for moving an already-uploaded file between
|
// Custom drag payload MIME type for moving an already-uploaded file between
|
||||||
// folders, as opposed to an OS file drag (type "Files") for uploading a new
|
// folders, as opposed to an OS file drag (type "Files") for uploading a new
|
||||||
// one — both land on the same drop targets, told apart by this.
|
// one — both land on the same drop targets, told apart by this.
|
||||||
@@ -46,6 +54,9 @@ export class GpxPanelControl {
|
|||||||
this._linesByPath = new Map(); // path -> its lines/polygons FeatureCollection, same reason (a polygon/line note also carries a category)
|
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._hiddenCategories = new Set(); // category names toggled off in the legend
|
||||||
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
|
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
|
||||||
|
this._wikiEnabled = false; // the "Wikipedia" layer's checkbox state
|
||||||
|
this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled
|
||||||
|
this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded
|
||||||
|
|
||||||
// setStyle() (switching the Online/Offline base map) tears down all
|
// setStyle() (switching the Online/Offline base map) tears down all
|
||||||
// custom sources/layers/images — re-register the category glyphs before
|
// custom sources/layers/images — re-register the category glyphs before
|
||||||
@@ -57,6 +68,7 @@ export class GpxPanelControl {
|
|||||||
map.on("style.load", () => {
|
map.on("style.load", () => {
|
||||||
this._iconsReady = this._registerCategoryGlyphs();
|
this._iconsReady = this._registerCategoryGlyphs();
|
||||||
this._visible.forEach((path) => this._addLayers(path));
|
this._visible.forEach((path) => this._addLayers(path));
|
||||||
|
if (this._wikiEnabled) this._addWikiLayers().then(() => this._refreshWikiLayer());
|
||||||
});
|
});
|
||||||
|
|
||||||
// notes.js writes directly into the same GPX store (a hand-drawn note
|
// notes.js writes directly into the same GPX store (a hand-drawn note
|
||||||
@@ -114,10 +126,21 @@ export class GpxPanelControl {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same registration as _registerCategoryGlyphs above, for the one fixed
|
||||||
|
// glyph the "Wikipedia" layer uses — kept separate from POI_ICONS since
|
||||||
|
// that set is keyed by OSM/p4n2gpx category and doubles as notes.js's
|
||||||
|
// icon-picker option list, neither of which applies to this layer.
|
||||||
|
async _registerWikiIcon() {
|
||||||
|
if (this._map.hasImage(WIKI_ICON_ID)) return;
|
||||||
|
const whiteSvg = WIKI_ICON.replace(/currentColor/g, "#fff");
|
||||||
|
const imageData = await svgToImageData(whiteSvg, POI_ICON_IMAGE_SIZE);
|
||||||
|
if (!this._map.hasImage(WIKI_ICON_ID)) this._map.addImage(WIKI_ICON_ID, imageData);
|
||||||
|
}
|
||||||
|
|
||||||
mount(sidebar) {
|
mount(sidebar) {
|
||||||
this._container = document.createElement("div");
|
this._container = document.createElement("div");
|
||||||
this._container.className = "sidebar-item";
|
this._container.className = "sidebar-item";
|
||||||
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Tracks" aria-label="Tracks">${GPX_ICON}</button>`;
|
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Tracks & Points" aria-label="Tracks & Points">${GPX_ICON}</button>`;
|
||||||
this._icon = this._container.querySelector(".sidebar-icon");
|
this._icon = this._container.querySelector(".sidebar-icon");
|
||||||
this._icon.addEventListener("click", () => this._togglePanel());
|
this._icon.addEventListener("click", () => this._togglePanel());
|
||||||
|
|
||||||
@@ -125,6 +148,12 @@ export class GpxPanelControl {
|
|||||||
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
||||||
this._legendContainer = this._panel.querySelector(".gpx-legend");
|
this._legendContainer = this._panel.querySelector(".gpx-legend");
|
||||||
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
||||||
|
wikiToggleLabel = this._panel.querySelector(".gpx-panel__wiki");
|
||||||
|
wikiToggleLabel.hidden = !kiwixConfig.available;
|
||||||
|
wikiToggleLabel.querySelector(".gpx-panel__wiki-toggle").addEventListener("change", (event) => {
|
||||||
|
if (event.target.checked) this._enableWikiLayer();
|
||||||
|
else this._disableWikiLayer();
|
||||||
|
});
|
||||||
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
|
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
|
||||||
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
@@ -713,7 +742,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, lngLat: event.lngLat }))
|
.setHTML(popupHtml(feature.properties, { editable }))
|
||||||
.addTo(this._map);
|
.addTo(this._map);
|
||||||
if (editable) this._wireNoteActions(popup, path, feature);
|
if (editable) this._wireNoteActions(popup, path, feature);
|
||||||
});
|
});
|
||||||
@@ -765,7 +794,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, lngLat: popup.getLngLat() });
|
content.innerHTML = popupHtml(feature.properties, { editable: true });
|
||||||
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 () => {
|
||||||
@@ -924,6 +953,110 @@ export class GpxPanelControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The "Wikipedia" layer's checkbox toggle (see mount()) — not a file in
|
||||||
|
// _visible/the tree, so it gets its own small add/remove pair rather than
|
||||||
|
// going through _addLayers/_removeLayers, which are built around a
|
||||||
|
// GpxStore path. Refetches on every pan/zoom instead of once for a fixed
|
||||||
|
// area (contrast the import plugins' "Run for this area" button): cheap
|
||||||
|
// to do since GET /api/kiwix/nearby only ever talks to kiwix-serve's own
|
||||||
|
// local ZIM, none of the plugins' shared/rate-limited API concerns apply.
|
||||||
|
async _enableWikiLayer() {
|
||||||
|
this._wikiEnabled = true;
|
||||||
|
await this._addWikiLayers();
|
||||||
|
this._wikiMoveHandler = () => this._refreshWikiLayer();
|
||||||
|
this._map.on("moveend", this._wikiMoveHandler);
|
||||||
|
this._refreshWikiLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disableWikiLayer() {
|
||||||
|
this._wikiEnabled = false;
|
||||||
|
if (this._wikiMoveHandler) {
|
||||||
|
this._map.off("moveend", this._wikiMoveHandler);
|
||||||
|
this._wikiMoveHandler = null;
|
||||||
|
}
|
||||||
|
this._removeWikiLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _addWikiLayers() {
|
||||||
|
if (this._map.getSource(WIKI_SOURCE_ID)) return;
|
||||||
|
await Promise.all([this._registerWikiIcon(), this._registerBadgeColors([WIKI_BADGE_COLOR])]);
|
||||||
|
if (this._map.getSource(WIKI_SOURCE_ID)) return; // style.load raced this while awaiting
|
||||||
|
|
||||||
|
this._map.addSource(WIKI_SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${WIKI_SOURCE_ID}-badge`,
|
||||||
|
type: "symbol",
|
||||||
|
source: WIKI_SOURCE_ID,
|
||||||
|
layout: {
|
||||||
|
"icon-image": badgeImageId(WIKI_BADGE_COLOR),
|
||||||
|
"icon-size": MAP_MARKER_SIZE,
|
||||||
|
"icon-allow-overlap": true,
|
||||||
|
"icon-ignore-placement": true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${WIKI_SOURCE_ID}-icon`,
|
||||||
|
type: "symbol",
|
||||||
|
source: WIKI_SOURCE_ID,
|
||||||
|
layout: { "icon-image": WIKI_ICON_ID, "icon-size": POI_ICON_SIZE, "icon-allow-overlap": true, "icon-ignore-placement": true },
|
||||||
|
});
|
||||||
|
this._registerWikiInteractivity(`${WIKI_SOURCE_ID}-badge`);
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeWikiLayers() {
|
||||||
|
[`${WIKI_SOURCE_ID}-badge`, `${WIKI_SOURCE_ID}-icon`].forEach((layerId) => {
|
||||||
|
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
|
||||||
|
});
|
||||||
|
if (this._map.getSource(WIKI_SOURCE_ID)) this._map.removeSource(WIKI_SOURCE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not _registerPointInteractivity: that one's built around a GpxStore
|
||||||
|
// path for Note edit/delete, neither of which a wiki article has —
|
||||||
|
// simpler to keep this layer's own small click/hover pair separate than
|
||||||
|
// to bend the shared one around a case it was never meant to cover.
|
||||||
|
_registerWikiInteractivity(layerId) {
|
||||||
|
if (this._interactiveLayers.has(layerId)) return;
|
||||||
|
this._interactiveLayers.add(layerId);
|
||||||
|
|
||||||
|
this._map.on("click", layerId, (event) => {
|
||||||
|
const feature = event.features[0];
|
||||||
|
if (!feature) return;
|
||||||
|
new Popup({ closeButton: true, maxWidth: "260px" }).setLngLat(event.lngLat).setHTML(wikiPopupHtml(feature.properties)).addTo(this._map);
|
||||||
|
});
|
||||||
|
this._map.on("mouseenter", layerId, () => {
|
||||||
|
this._map.getCanvas().style.cursor = "pointer";
|
||||||
|
});
|
||||||
|
this._map.on("mouseleave", layerId, () => {
|
||||||
|
this._map.getCanvas().style.cursor = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// seq guards against an out-of-order response — a fast second pan's
|
||||||
|
// fetch resolving before a slower first one's — overwriting newer data
|
||||||
|
// with stale data; not a full abort-in-flight setup, just the ordering
|
||||||
|
// guarantee this actually needs.
|
||||||
|
async _refreshWikiLayer() {
|
||||||
|
const seq = ++this._wikiRequestSeq;
|
||||||
|
const bounds = this._map.getBounds();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
south: bounds.getSouth(),
|
||||||
|
west: bounds.getWest(),
|
||||||
|
north: bounds.getNorth(),
|
||||||
|
east: bounds.getEast(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let geojson;
|
||||||
|
try {
|
||||||
|
geojson = await (await fetch(`/api/kiwix/nearby?${params}`)).json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seq !== this._wikiRequestSeq) return;
|
||||||
|
|
||||||
|
const source = this._map.getSource(WIKI_SOURCE_ID);
|
||||||
|
if (source) source.setData(geojson);
|
||||||
|
}
|
||||||
|
|
||||||
async _uploadFiles(fileList, folder) {
|
async _uploadFiles(fileList, folder) {
|
||||||
// .zip: Garmin's bulk/FIT export format — the server expands it into
|
// .zip: Garmin's bulk/FIT export format — the server expands it into
|
||||||
// its .gpx/.fit/.geojson entries, so it's accepted here even though the
|
// its .gpx/.fit/.geojson entries, so it's accepted here even though the
|
||||||
@@ -1104,9 +1237,14 @@ function gpxFileUrl(path) {
|
|||||||
// module-level rather than threaded through every popupHtml call, since
|
// module-level rather than threaded through every popupHtml call, since
|
||||||
// it's the same answer for the whole page's lifetime.
|
// it's the same answer for the whole page's lifetime.
|
||||||
let kiwixConfig = { available: false, url: "" };
|
let kiwixConfig = { available: false, url: "" };
|
||||||
|
// Set once GpxPanelControl#mount runs — updated here too since map.js's
|
||||||
|
// GET /api/kiwix check resolves after mount() already ran, so the panel
|
||||||
|
// markup exists but its availability isn't known yet at that point.
|
||||||
|
let wikiToggleLabel = null;
|
||||||
|
|
||||||
export function setKiwixConfig(config) {
|
export function setKiwixConfig(config) {
|
||||||
kiwixConfig = config;
|
kiwixConfig = config;
|
||||||
|
if (wikiToggleLabel) wikiToggleLabel.hidden = !config.available;
|
||||||
}
|
}
|
||||||
|
|
||||||
// properties come straight from an uploaded GPX file — untrusted input —
|
// properties come straight from an uploaded GPX file — untrusted input —
|
||||||
@@ -1114,11 +1252,8 @@ export function setKiwixConfig(config) {
|
|||||||
// 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. lngLat (the
|
// only builds markup, it has no map/store access of its own.
|
||||||
// popup's own anchor point) drives the "Nearby on Wikipedia" link — kiwix-
|
function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) {
|
||||||
// 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>` : "";
|
||||||
@@ -1139,10 +1274,6 @@ 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>`,
|
||||||
@@ -1151,6 +1282,16 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab
|
|||||||
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// properties come from GET /api/kiwix/nearby — kiwix-serve's own ZIM
|
||||||
|
// content, not an uploaded file, but still untrusted-by-convention like
|
||||||
|
// every other popup builder here (escaped all the same). link already
|
||||||
|
// carries the /kiwix prefix this app proxies (see lib/maptool/kiwix.rb).
|
||||||
|
function wikiPopupHtml({ name, link }) {
|
||||||
|
const parts = [`<div class="gpx-popup__header"><strong>${escapeHtml(name)}</strong></div>`];
|
||||||
|
parts.push(`<p class="gpx-popup__wiki"><a href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Read on Wikipedia →</a></p>`);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
// properties come straight from an uploaded GPX/FIT file — untrusted input.
|
// properties come straight from an uploaded GPX/FIT file — untrusted input.
|
||||||
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
function trackPopupHtml({ name, activity_type, distance_meters, elevation_gain_meters, duration_secs }) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|||||||
@@ -353,6 +353,24 @@ Object.assign(POI_ICONS, {
|
|||||||
shop_convenience: STORE_ICON,
|
shop_convenience: STORE_ICON,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// src/bookstore-22.svg (NPS symbol library, same source/license as
|
||||||
|
// POI_ICONS above) — used as the fixed marker glyph for the "Wikipedia"
|
||||||
|
// map layer (gpx.js), not part of POI_ICONS itself: that set is keyed by
|
||||||
|
// OSM/p4n2gpx category and doubles as the note icon-picker's option list
|
||||||
|
// (notes.js), neither of which applies here — every point on this layer
|
||||||
|
// gets the same one glyph, there's no per-feature category to key it by.
|
||||||
|
export const WIKI_ICON = `
|
||||||
|
<svg viewBox="0 0 22 22" fill="currentColor">
|
||||||
|
<path d="M16.6,20.7c-0.6,0-0.9-0.4-0.9-0.9c0-0.5,0.3-0.9,0.9-0.9c0.3,0,0.5-0.2,0.5-0.5
|
||||||
|
S16.9,18,16.6,18c-1.1,0-1.9,0.8-1.9,1.9c0,1,0.7,1.9,1.9,1.9c0.3,0,0.5-0.2,0.5-0.5S16.9,20.7,16.6,20.7L16.6,20.7z"/>
|
||||||
|
<path d="M5,18c0,0,0,0,0,0c0,3.1,0.5,4,2.7,4l8.9,0c0.3,0,0.5-0.2,0.5-0.5
|
||||||
|
c0-0.3-0.2-0.5-0.5-0.5H8c-1.5,0-2-0.5-2-3c0,0,0,0,0-0.1c0-0.3-0.2-0.5-0.5-0.5S5,17.7,5,18L5,18z"/>
|
||||||
|
<path d="M5,8c0-0.6,5-5,5-5h5h1c0.6,0,1,0.4,1,1v14c0,0.6-0.4,1-1,1H6c-0.6,0-1-0.4-1-1
|
||||||
|
V8z M9,4L6,18c0,0,10,0,10,0c0,0,0-14,0-14C16,4,9,4,9,4z"/>
|
||||||
|
<path d="M5,6.8c0-0.5,0.4-1.3,0.8-1.6l6.4-4.7C12.7,0.2,13,0.4,13,1v10
|
||||||
|
c0,0.6-0.3,1.7-0.7,2.1l-6.5,5.3C5.4,18.8,5,18.6,5,18V6.8z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
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"/>
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal 404, request.get("/kiwix/search").status
|
assert_equal 404, request.get("/kiwix/search").status
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_kiwix_nearby_404s_when_no_url_configured
|
||||||
|
assert_equal 404, request.get("/api/kiwix/nearby", params: { south: "48", west: "17", north: "48.1", east: "17.1" }).status
|
||||||
|
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))
|
||||||
|
|||||||
@@ -70,4 +70,58 @@ class KiwixTest < Minitest::Test
|
|||||||
assert_equal "GET /kiwix/content/wikipedia/Dlh%C3%A9_diely HTTP/1.1\r\n", request_line
|
assert_equal "GET /kiwix/content/wikipedia/Dlh%C3%A9_diely HTTP/1.1\r\n", request_line
|
||||||
assert_equal "200", response.code
|
assert_equal "200", response.code
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# kiwix-serve's own geo search never echoes back a result's coordinates
|
||||||
|
# (verified live against a real ZIM) — .nearby fetches each matching
|
||||||
|
# article's page too and reads its <meta name="geo.position"> tag, so
|
||||||
|
# this stub server has to answer both the search request and each
|
||||||
|
# article-page request that follows, in turn.
|
||||||
|
def test_nearby_builds_a_feature_per_geotagged_search_result
|
||||||
|
server = TCPServer.new("127.0.0.1", 0)
|
||||||
|
port = server.addr[1]
|
||||||
|
search_xml = <<~XML
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss><channel>
|
||||||
|
<item><title>Foo</title><link>/kiwix/content/wikipedia/Foo</link></item>
|
||||||
|
<item><title>Bar</title><link>/kiwix/content/wikipedia/Bar</link></item>
|
||||||
|
</channel></rss>
|
||||||
|
XML
|
||||||
|
|
||||||
|
thread = Thread.new do
|
||||||
|
# 3, not 2: .nearby fetches every search result's own page to look
|
||||||
|
# for coordinates, including Bar's — which turns out to have none
|
||||||
|
# and gets filtered out, but the request still happens. Leaving this
|
||||||
|
# at 2 the first time round left that third connection's request
|
||||||
|
# unaccepted, and it hung for the full default read_timeout instead
|
||||||
|
# of failing fast.
|
||||||
|
3.times do
|
||||||
|
client = server.accept
|
||||||
|
request_line = client.gets
|
||||||
|
body =
|
||||||
|
if request_line.include?("/kiwix/search")
|
||||||
|
search_xml
|
||||||
|
elsif request_line.include?("/Foo")
|
||||||
|
'<meta name="geo.position" content="48.1;17.2">'
|
||||||
|
else
|
||||||
|
"no coordinates here"
|
||||||
|
end
|
||||||
|
client.print("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
||||||
|
client.close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
geojson = Maptool::Kiwix.nearby("http://127.0.0.1:#{port}", [48.0, 17.0, 48.2, 17.3])
|
||||||
|
thread.join
|
||||||
|
server.close
|
||||||
|
|
||||||
|
assert_equal 1, geojson["features"].size, "Bar has no geo.position tag and must be skipped, not crash"
|
||||||
|
feature = geojson["features"].first
|
||||||
|
assert_equal "Foo", feature["properties"]["name"]
|
||||||
|
assert_equal [17.2, 48.1], feature["geometry"]["coordinates"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_nearby_returns_an_empty_collection_when_the_search_itself_fails
|
||||||
|
geojson = Maptool::Kiwix.nearby("http://127.0.0.1:1", [48.0, 17.0, 48.2, 17.3], open_timeout: 1)
|
||||||
|
assert_equal({ "type" => "FeatureCollection", "features" => [] }, geojson)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
+10
-1
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div id="gpx-panel" class="gpx-panel" hidden>
|
<div id="gpx-panel" class="gpx-panel" hidden>
|
||||||
<div class="gpx-panel__header">
|
<div class="gpx-panel__header">
|
||||||
<strong>Tracks</strong>
|
<strong>Tracks & Points</strong>
|
||||||
<div class="gpx-panel__actions">
|
<div class="gpx-panel__actions">
|
||||||
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
|
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
|
||||||
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT/GeoJSON file" aria-label="Upload GPX/FIT/GeoJSON file"></button>
|
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT/GeoJSON file" aria-label="Upload GPX/FIT/GeoJSON file"></button>
|
||||||
@@ -58,6 +58,15 @@
|
|||||||
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.geojson,.zip" multiple hidden>
|
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.geojson,.zip" multiple hidden>
|
||||||
<p class="gpx-upload__status" hidden></p>
|
<p class="gpx-upload__status" hidden></p>
|
||||||
|
|
||||||
|
<!-- Not a real file in the tree below: a live layer, refetched from the
|
||||||
|
offline Wikipedia ZIM (via kiwix-serve) for whatever's on screen as
|
||||||
|
the map pans/zooms, not a saved .geojson. Hidden entirely when no
|
||||||
|
kiwix-serve is configured (see gpx.js's mount()). -->
|
||||||
|
<label class="gpx-panel__wiki" hidden>
|
||||||
|
<input type="checkbox" class="gpx-panel__wiki-toggle">
|
||||||
|
<span>Wikipedia</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="gpx-tree"></div>
|
<div class="gpx-tree"></div>
|
||||||
<div class="gpx-legend" hidden></div>
|
<div class="gpx-legend" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user