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:
randy committed 2026-09-10 20:45:41 +02:00
1 parent 47e3f9c7e8
commit 787df5478d
9 files changed
+354 -14

No files matched your search

+85
View File
@@ -1,4 +1,5 @@
require "net/http"
require "rexml/document"
module Maptool
# 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
# directly. GET /kiwix/* in app.rb calls .proxy below instead.
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)
return false if url.to_s.strip.empty?
@@ -41,5 +49,82 @@ module Maptool
rescue StandardError
nil
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