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.
131 lines
6.5 KiB
Ruby
131 lines
6.5 KiB
Ruby
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
|