Files
maptool/lib/maptool/kiwix.rb
T
randy 787df5478d 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.
2026-09-10 20:45:41 +02:00

131 lines
6.5 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require "net/http"
require "rexml/document"
module Maptool
# A reachability check plus a passthrough proxy for an external
# kiwix-serve instance serving an offline Wikipedia ZIM (see
# docs/development.adoc for how to set one up). A POI popup's "Nearby on
# Wikipedia" link (public/js/gpx.js) used to point straight at
# kiwix-serve itself — fine on a LAN, broken once this app started
# running behind a reverse proxy (map.priesol.net) that only forwards to
# *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?
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
# path already carries the /kiwix prefix kiwix-serve itself expects
# (started with --urlRootLocation=/kiwix, see docker-compose.yaml) —
# this is a plain passthrough, not a rewrite, so its emitted links stay
# correct without this having to touch response bodies at all. Returns
# nil (halted as a 502 by the caller) rather than raising, same
# not-reachable-isn't-exceptional stance as .reachable? above.
#
# path arrives already URL-*decoded* — Sinatra decodes a route's splat
# before handing it over — so an article title with non-ASCII
# characters (e.g. "/kiwix/content/wikipedia/Dlhé_diely") has to be
# re-escaped before URI#path= will accept it at all; it raises
# URI::InvalidComponentError on raw UTF-8 bytes rather than escaping
# them itself. Article links otherwise 502'd for exactly this reason.
def self.proxy(base_url, path, query_string, open_timeout: 2, read_timeout: 10)
uri = URI(base_url)
uri.path = URI::RFC2396_PARSER.escape(path)
uri.query = query_string unless query_string.to_s.empty?
Net::HTTP.start(uri.host, uri.port, open_timeout: open_timeout, read_timeout: read_timeout) { |http| http.request(Net::HTTP::Get.new(uri)) }
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