Redesign UI as an OsmAnd-style sidebar, add live GNSS position via gpsd
Restructure the map controls from MapLibre corner buttons into a left sidebar of icons with flyouts/panels (radar, GPX, GNSS), matching OsmAnd's layout more closely: - Base map and GPX selection are now full-height side panels docked next to the sidebar (not small dropdown flyouts or a right-docked panel), opening/closing exclusively via a small shared event bus (panels.js) so only one is ever open at a time. - The GPX panel's upload/new-folder controls are now icon buttons in the header instead of an always-visible dropzone and form; dragging a GPX file onto the tree, a specific folder, or straight onto the map itself all upload it. - New: Maptool::GpsdClient (lib/maptool/gpsd_client.rb) streams live GNSS fixes from a local gpsd daemon over Server-Sent Events (/api/gnss/stream) to a marker on the map (public/js/gnss.js) — one background gpsd connection shared by every browser tab. - The online map style (public/js/osmand-style.js) is now styled to resemble OsmAnd's own rendering, applied to both the online and offline (PMTiles) styles. Also adds a GeolocateControl (browser geolocation) next to the zoom controls.
This commit is contained in:
1 parent
008e034932
commit
87df90f75c
14 files changed
+939
-200
No files matched your search
@@ -83,6 +83,46 @@ No PMTiles file ships with the repository — it is expected to be
|
||||
supplied locally (see `docs/development.adoc`) and stays gitignored
|
||||
because such extracts are typically large binary files.
|
||||
|
||||
=== Visual style
|
||||
|
||||
Both styles are recolored, by `public/js/osmand-style.js`, to
|
||||
approximate the look of https://osmand.net/map[OsmAnd's own web map]
|
||||
(cream background, green forests, orange roads, purple admin
|
||||
boundaries) — deliberately *not* by using OsmAnd's tile server
|
||||
directly: its terms restrict tile use to "personal, non-commercial use
|
||||
within OsmAnd applications and platforms", which this app isn't.
|
||||
|
||||
One shared color palette drives two independent recolorings:
|
||||
|
||||
* *Online*: `onlineStyle()` fetches OpenFreeMap's raw "liberty" style
|
||||
JSON and patches `paint` colors on its layers in place — matching a
|
||||
layer to a category (water, wood, park, landuse, building, major/
|
||||
minor road, boundary) by its id, then walking that layer's color
|
||||
properties (`background-color`, `fill-color`, `line-color`,
|
||||
`fill-extrusion-color`, and any `*-outline-color`) and replacing
|
||||
every color-shaped leaf — including inside `interpolate`/other
|
||||
expressions — with the category's palette color, leaving non-color
|
||||
leaves (operators, zoom stops, `["get", ...]`) untouched. Labels and
|
||||
railways are left at their original colors — only categorized layer
|
||||
types (`background`/`fill`/`line`/`fill-extrusion`) are touched at
|
||||
all, so `symbol` layers (all labels/icons) are never in scope.
|
||||
Result is cached after the first fetch.
|
||||
* *Offline*: `offlineTheme()` overrides the relevant color roles on a
|
||||
`protomaps-themes-base` `Theme` object (starting from
|
||||
`namedTheme("light")`), leaving roles it doesn't set (labels — moot,
|
||||
see above — rail, tunnels, POI colors) at their defaults.
|
||||
|
||||
=== Known limitation: coarser road colors offline
|
||||
|
||||
protomaps-themes-base's `Theme` only has two paint-relevant road tiers
|
||||
(`major` / `minor_a`+`minor_b`, plus `link` for ramps) versus
|
||||
OpenFreeMap's finer-grained classes (`road_motorway`,
|
||||
`road_trunk_primary`, `road_secondary_tertiary`, `road_minor`, ...).
|
||||
Mapping `major` to the palette's road-major orange colors more roads
|
||||
orange offline than online (protomaps' "major" bucket is broader than
|
||||
just motorway/trunk/primary) — a real difference in how coarse the two
|
||||
underlying style systems are, not a bug in the recoloring itself.
|
||||
|
||||
== Weather radar overlay
|
||||
|
||||
`public/js/radar.js` adds a "Radar" map control that overlays recent
|
||||
@@ -177,6 +217,46 @@ assembling any HTML; the URL pattern excludes quote characters, so a
|
||||
matched URL can never break out of the `href="..."` attribute. Every
|
||||
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
||||
|
||||
== Live GNSS position
|
||||
|
||||
A "GNSS" map control (`public/js/gnss.js`) shows the fix from a local
|
||||
GNSS receiver (a real one, plugged into the machine running the
|
||||
server — distinct from `NavigationControl`'s browser
|
||||
`navigator.geolocation`, which the OS may or may not derive from such
|
||||
a receiver at all).
|
||||
|
||||
`lib/maptool/gpsd_client.rb` is a small client for
|
||||
https://gpsd.io[gpsd]'s JSON protocol over TCP (port 2947 by default):
|
||||
it watches for TPV — "time-position-velocity", gpsd's term for a fix —
|
||||
reports and yields each as a plain hash, reconnecting on any I/O error
|
||||
so a receiver that isn't plugged in yet (or gpsd not running) is not
|
||||
fatal. `app.rb` runs one of these in a background thread for the
|
||||
whole process and keeps the latest fix in `GNSS_LATEST`, guarded by a
|
||||
mutex since the HTTP threads read it concurrently.
|
||||
|
||||
`GET /api/gnss/position`, polled by the frontend every 1.5s, is a
|
||||
deliberate choice over a push channel (SSE/WebSocket): gpsd itself
|
||||
only reports at roughly 1Hz, so a push channel buys nothing here, and
|
||||
it sidesteps a real problem — Sinatra's `stream(:keep_open)` helper,
|
||||
absent an EventMachine reactor (i.e. under plain threaded Puma, as
|
||||
here), calls its block in a tight loop instead of waiting for external
|
||||
writes. That is fine for a stream that itself blocks between writes,
|
||||
but for a "register once, get pushed to later" connection — which is
|
||||
what a live position feed is — the block returns immediately and gets
|
||||
re-invoked immediately, forever: every iteration re-registers the
|
||||
connection, so held connections and memory grow unbounded. This was
|
||||
caught in testing (the dev server hit ~98% CPU and ~28GB RSS within
|
||||
minutes) before it shipped.
|
||||
|
||||
The endpoint reports `available: false` once the last fix is more than
|
||||
`GNSS_STALE_AFTER` (10s) old, rather than serving a stale position
|
||||
forever after the receiver loses its fix or gets unplugged.
|
||||
|
||||
MapLibre `Marker`s (used for the fix) are plain positioned DOM
|
||||
elements, not tied to the style's sources/layers, so — unlike the
|
||||
radar/GPX layers — the marker survives `map.setStyle()` (switching
|
||||
Online/Offline) with no extra re-add handling needed.
|
||||
|
||||
== Networking
|
||||
|
||||
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
|
||||
|
||||
@@ -42,3 +42,51 @@ build one locally:
|
||||
|
||||
Once the file is in place, `GET /api/offline-tiles` reports
|
||||
`available: true` and the map defaults to the offline style.
|
||||
|
||||
== GNSS position (gpsd)
|
||||
|
||||
The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at
|
||||
a receiver (`gpsd /dev/ttyUSB0`, or whatever device the receiver shows
|
||||
up as). On openSUSE, `gpsd` is a package with systemd units
|
||||
(`gpsd.service` / `gpsd.socket`); on other distributions, check your
|
||||
package manager. No app-side configuration is needed — it just
|
||||
connects to `127.0.0.1:2947`, gpsd's default port.
|
||||
|
||||
To test without hardware, feed gpsd a fake NMEA stream over TCP
|
||||
instead of a real serial device — gpsd accepts a `tcp://host:port`
|
||||
source:
|
||||
|
||||
[source,shell]
|
||||
----
|
||||
# terminal 1: a TCP server repeating one NMEA GGA/RMC sentence pair
|
||||
python3 - <<'PY' 5001
|
||||
import socket, sys, time
|
||||
|
||||
def checksum(s):
|
||||
cs = 0
|
||||
for ch in s:
|
||||
cs ^= ord(ch)
|
||||
return f"{cs:02X}"
|
||||
|
||||
def sentence(body):
|
||||
return f"${body}*{checksum(body)}\r\n"
|
||||
|
||||
port = int(sys.argv[1])
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", port))
|
||||
srv.listen(1)
|
||||
conn, _ = srv.accept()
|
||||
gga = "GPGGA,123519,4808.916,N,01706.462,E,1,08,0.9,140.4,M,46.9,M,,"
|
||||
rmc = "GPRMC,123519,A,4808.916,N,01706.462,E,000.5,084.4,230824,003.1,W"
|
||||
while True:
|
||||
conn.sendall((sentence(gga) + sentence(rmc)).encode())
|
||||
time.sleep(1)
|
||||
PY
|
||||
|
||||
# terminal 2: point gpsd at it instead of a real device
|
||||
gpsd -N -n tcp://127.0.0.1:5001
|
||||
----
|
||||
|
||||
`GET /api/gnss/position` should then report the fake fix (Bratislava
|
||||
Castle, in the sentences above) within a couple of seconds.
|
||||
Reference in new issue
Block a user