diff --git a/README.adoc b/README.adoc index 7794b24..abcb053 100644 --- a/README.adoc +++ b/README.adoc @@ -10,9 +10,11 @@ The project's shape is still open — this is the initial scaffold. == Status Early scaffold. The web application serves a vector map (MapLibre GL -JS), with an online style and an optional offline style backed by a -local PMTiles file, an animated RainViewer radar overlay, and GPX -upload/display (points and tracks, organized in folders). +JS), styled to resemble https://osmand.net/map[OsmAnd's own map], with +an online style and an optional offline style backed by a local +PMTiles file, an animated RainViewer radar overlay, GPX +upload/display (points and tracks, organized in folders), and a live +position marker from a local GNSS receiver via gpsd. == Requirements diff --git a/app.rb b/app.rb index 54e450f..7ac9be3 100644 --- a/app.rb +++ b/app.rb @@ -2,6 +2,7 @@ require "sinatra/base" require "sinatra/json" require_relative "lib/maptool/gpx" require_relative "lib/maptool/gpx_store" +require_relative "lib/maptool/gpsd_client" module Maptool class App < Sinatra::Base @@ -11,6 +12,34 @@ module Maptool OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx")) + # Live GNSS position, polled by the frontend from /api/gnss/position. A + # single background thread reads gpsd (if it's running — harmless retry + # loop if not, see GpsdClient) and keeps this updated; polling instead + # of push (SSE/WebSocket) is deliberate — gpsd itself only reports at + # ~1Hz, so there is nothing to gain from a push channel, and it avoids + # Sinatra's `stream(:keep_open)` helper, which — absent an EventMachine + # reactor (i.e. under plain threaded Puma, as here) — busy-loops calling + # its block repeatedly instead of waiting for external writes; fine for + # a single blocking per-message write, not for "register once, get + # pushed to later" like this. + # A fix is only reported back as "available" for this long after it + # last arrived — otherwise a receiver that's lost its fix, or been + # unplugged, would keep showing its last position forever as if it + # were still live. + GNSS_STALE_AFTER = 10 # seconds + + GNSS_LATEST = {} + GNSS_LATEST_MUTEX = Mutex.new + + Thread.new do + GpsdClient.new.each_position do |position| + GNSS_LATEST_MUTEX.synchronize do + GNSS_LATEST.replace(position) + GNSS_LATEST[:received_at] = Time.now.to_f + end + end + end + get "/" do erb :index end @@ -109,5 +138,13 @@ module Maptool json path: path, tree: GPX_STORE.tree end + + get "/api/gnss/position" do + GNSS_LATEST_MUTEX.synchronize do + received_at = GNSS_LATEST[:received_at] + fresh = received_at && (Time.now.to_f - received_at) < GNSS_STALE_AFTER + json(fresh ? GNSS_LATEST.merge(available: true) : { available: false }) + end + end end end diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 57030f4..8952c88 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -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 diff --git a/docs/development.adoc b/docs/development.adoc index c039903..c802ddd 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -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. diff --git a/lib/maptool/gpsd_client.rb b/lib/maptool/gpsd_client.rb new file mode 100644 index 0000000..5c2a23d --- /dev/null +++ b/lib/maptool/gpsd_client.rb @@ -0,0 +1,57 @@ +require "socket" +require "json" + +module Maptool + # A minimal client for gpsd's JSON protocol (https://gpsd.io/gpsd_json.html). + # Connects over TCP, asks gpsd to watch for reports, and yields a plain + # position hash for every TPV ("time-position-velocity") report — gpsd's + # term for a GNSS fix — for as long as the block keeps running. + class GpsdClient + RECONNECT_DELAY = 5 # seconds, after gpsd is unreachable or drops us + + def initialize(host: "127.0.0.1", port: 2947) + @host = host + @port = port + end + + # Never returns under normal operation: reconnects on any I/O error + # (gpsd not running yet, connection dropped, etc.) after a short delay, + # so a missing/disconnected receiver is not fatal to the caller. + def each_position + loop do + watch { |position| yield position } + rescue SystemCallError, EOFError, SocketError => e + warn "[gpsd] #{e.class}: #{e.message} — retrying in #{RECONNECT_DELAY}s" + sleep RECONNECT_DELAY + end + end + + private + + def watch + socket = TCPSocket.new(@host, @port) + socket.puts('?WATCH={"enable":true,"json":true};') + + socket.each_line do |line| + report = JSON.parse(line) + next unless report["class"] == "TPV" + next unless report["lat"] && report["lon"] + + yield( + lat: report["lat"], + lon: report["lon"], + # altHAE (height above ellipsoid) is gpsd's modern field; older + # gpsd versions only send "alt". + alt: report["altHAE"] || report["alt"], + speed: report["speed"], + track: report["track"], + mode: report["mode"], # 0/1 = no fix, 2 = 2D, 3 = 3D + eph: report["eph"], # estimated horizontal position error, metres + time: report["time"], + ) + end + ensure + socket&.close + end + end +end diff --git a/public/css/style.css b/public/css/style.css index 7985772..2d040f3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,50 +1,129 @@ html, body { height: 100%; margin: 0; + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } -#map { +#app { height: 100%; - width: 100%; -} - -.layer-toggle { display: flex; + flex-direction: column; } -.layer-toggle button { - /* Override maplibregl-ctrl-group's 29x29px square icon-button sizing — - these are text buttons. */ - width: auto; - height: auto; - border: none; +.topbar { + flex: 0 0 auto; + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; background: #fff; + border-bottom: 1px solid #ddd; +} + +.topbar__brand { + font-size: 15px; + font-weight: 700; + color: #333; +} + +.app-body { + flex: 1; + display: flex; + min-height: 0; +} + +.sidebar { + flex: 0 0 52px; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 0; + background: #fff; + border-right: 1px solid #ddd; + /* No overflow-y:auto here: setting only one axis to a clipping value + forces the browser to compute the other axis as "auto" too (CSS + Overflow spec), which would clip the flyouts — they intentionally + poke out past the sidebar's own width via left:44px. */ +} + +.sidebar-item { + position: relative; +} + +.sidebar-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: none; + color: #444; + cursor: pointer; +} + +.sidebar-icon svg { + width: 20px; + height: 20px; +} + +.sidebar-icon:hover { + background: #f0f0f0; +} + +.sidebar-icon.active { + background: #dbe9ff; + color: #1a5fb4; +} + +.sidebar-flyout { + position: absolute; + top: 0; + left: 44px; + z-index: 20; + min-width: max-content; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 6px; + font-size: 13px; +} + +.sidebar-flyout[hidden] { + display: none; +} + +.sidebar-flyout__option { + display: block; + width: 100%; padding: 6px 10px; + border: none; + border-radius: 4px; + background: none; font: inherit; font-size: 13px; + text-align: left; white-space: nowrap; cursor: pointer; } -.layer-toggle button.active { +.sidebar-flyout__option:hover { + background: #f0f0f0; +} + +.sidebar-flyout__option.active { background: #dbe9ff; font-weight: 600; } -.radar-control { - display: flex; - flex-direction: column; - gap: 4px; - padding: 6px 8px; - font-size: 13px; -} - -.radar-control__toggle { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; - white-space: nowrap; +#map { + flex: 1; + min-width: 0; + position: relative; } .radar-control__playback { @@ -53,14 +132,7 @@ html, body { gap: 6px; } -.radar-control__playback[hidden] { - display: none; -} - .radar-control__play { - /* Override maplibregl-ctrl-group's 29x29px square icon-button sizing. */ - width: auto; - height: auto; border: none; background: none; padding: 2px 4px; @@ -77,31 +149,90 @@ html, body { white-space: nowrap; } -.gpx-toggle button { - /* Override maplibregl-ctrl-group's 29x29px square icon-button sizing — - this is a text button. */ - width: auto; - height: auto; - border: none; +.basemap-panel { + position: fixed; + top: 0; + left: 52px; + width: 260px; + max-width: 90vw; + height: 100%; + display: flex; + flex-direction: column; background: #fff; - padding: 6px 10px; - font: inherit; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15); font-size: 13px; - white-space: nowrap; + z-index: 10; +} + +.basemap-panel[hidden] { + display: none; +} + +.basemap-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid #eee; +} + +.basemap-panel__close { + border: none; + background: none; + font-size: 18px; + line-height: 1; + padding: 0 4px; cursor: pointer; } +.basemap-panel__list { + padding: 6px; +} + +.basemap-panel__option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 10px; + border: none; + border-radius: 4px; + background: none; + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +.basemap-panel__option:hover { + background: #f0f0f0; +} + +.basemap-panel__option.active { + background: #dbe9ff; + font-weight: 600; +} + +.basemap-panel__check { + color: #1a5fb4; + visibility: hidden; +} + +.basemap-panel__option.active .basemap-panel__check { + visibility: visible; +} + .gpx-panel { position: fixed; top: 0; - right: 0; + left: 52px; width: 300px; max-width: 90vw; height: 100%; display: flex; flex-direction: column; background: #fff; - box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15); font-size: 13px; z-index: 10; } @@ -118,6 +249,34 @@ html, body { border-bottom: 1px solid #eee; } +.gpx-panel__actions { + display: flex; + align-items: center; + gap: 2px; +} + +.gpx-panel__action { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: none; + color: #444; + cursor: pointer; +} + +.gpx-panel__action svg { + width: 18px; + height: 18px; +} + +.gpx-panel__action:hover { + background: #f0f0f0; +} + .gpx-panel__close { border: none; background: none; @@ -127,49 +286,11 @@ html, body { cursor: pointer; } -.gpx-dropzone { - margin: 10px 12px; - padding: 14px 10px; - border: 2px dashed #ccc; - border-radius: 4px; - text-align: center; - color: #666; - cursor: pointer; -} - -.gpx-dropzone.gpx-drop-hover { - border-color: #4a90d9; - background: #eef5fd; - color: #333; -} - .gpx-upload__status { margin: 0 12px 10px; color: #666; } -.gpx-new-folder { - display: flex; - gap: 6px; - margin: 0 12px 10px; - padding-bottom: 10px; - border-bottom: 1px solid #eee; -} - -.gpx-new-folder input { - flex: 1; - min-width: 0; - font: inherit; - font-size: 13px; -} - -.gpx-new-folder button { - font: inherit; - font-size: 13px; - white-space: nowrap; - cursor: pointer; -} - .gpx-tree { overflow-y: auto; padding: 8px 12px; @@ -252,6 +373,16 @@ html, body { outline-offset: -2px; } +#map.gpx-drop-hover::after { + content: ""; + position: absolute; + inset: 0; + outline: 3px dashed #4a90d9; + outline-offset: -3px; + background: rgba(74, 144, 217, 0.08); + pointer-events: none; +} + .maplibregl-popup-content p { margin: 4px 0 0; } @@ -260,3 +391,17 @@ html, body { color: #888; font-size: 12px; } + +.gnss-control__status { + color: #888; + white-space: nowrap; +} + +.gnss-marker { + width: 14px; + height: 14px; + border: 2px solid #fff; + border-radius: 50%; + background: #2a9d8f; + box-shadow: 0 0 0 2px rgba(42, 157, 143, 0.4); +} diff --git a/public/js/gnss.js b/public/js/gnss.js new file mode 100644 index 0000000..772a3a5 --- /dev/null +++ b/public/js/gnss.js @@ -0,0 +1,101 @@ +import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs"; +import { GNSS_ICON } from "/js/icons.js"; + +const POSITION_URL = "/api/gnss/position"; +const POLL_INTERVAL_MS = 1500; + +// A sidebar icon that polls /api/gnss/position (backed server-side by a +// gpsd connection, see lib/maptool/gpsd_client.rb) and shows the fix as a +// marker, with fix status in a flyout. Polling rather than a push channel +// is deliberate — see the comment in app.rb next to GNSS_LATEST — and fits +// gpsd's own ~1Hz report rate fine. +export class GnssControl { + constructor(map) { + this._map = map; + this._marker = null; + this._timer = null; + this._enabled = false; + } + + mount(sidebar) { + this._container = document.createElement("div"); + this._container.className = "sidebar-item"; + this._container.innerHTML = ` + + + `; + this._icon = this._container.querySelector(".sidebar-icon"); + this._flyout = this._container.querySelector(".sidebar-flyout"); + this._status = this._container.querySelector(".gnss-control__status"); + this._icon.addEventListener("click", () => this._setEnabled(!this._enabled)); + + sidebar.appendChild(this._container); + } + + _setEnabled(enabled) { + this._enabled = enabled; + this._icon.classList.toggle("active", enabled); + this._flyout.hidden = !enabled; + if (enabled) { + this._poll(); + this._timer = setInterval(() => this._poll(), POLL_INTERVAL_MS); + } else { + this._stopPolling(); + this._removeMarker(); + this._status.textContent = ""; + } + } + + _stopPolling() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + + async _poll() { + let data; + try { + const response = await fetch(POSITION_URL); + data = await response.json(); + } catch (err) { + this._status.textContent = "unreachable"; + return; + } + + if (!data.available) { + this._removeMarker(); + this._status.textContent = "no fix"; + return; + } + + this._status.textContent = fixLabel(data); + + const lngLat = [data.lon, data.lat]; + if (!this._marker) { + const el = document.createElement("div"); + el.className = "gnss-marker"; + // MapLibre markers are plain positioned DOM elements, not tied to the + // style's sources/layers, so — unlike the radar/GPX layers — this + // survives map.setStyle() (switching Online/Offline) with no extra + // re-add handling needed. + this._marker = new Marker({ element: el }).setLngLat(lngLat).addTo(this._map); + } else { + this._marker.setLngLat(lngLat); + } + } + + _removeMarker() { + if (this._marker) { + this._marker.remove(); + this._marker = null; + } + } +} + +function fixLabel({ mode, eph }) { + const kind = mode === 3 ? "3D" : mode === 2 ? "2D" : "no fix"; + return eph ? `${kind} (±${Math.round(eph)}m)` : kind; +} diff --git a/public/js/gpx.js b/public/js/gpx.js index 8f4531a..5f284c5 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -1,4 +1,6 @@ import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; +import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON } from "/js/icons.js"; +import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; const TREE_URL = "/api/gpx/tree"; const UPLOAD_URL = "/api/gpx"; @@ -11,10 +13,10 @@ const TRACK_COLOR = "#e63946"; // one — both land on the same drop targets, told apart by this. const MOVE_MIME = "application/x-gpx-path"; -// A MapLibre IControl: a "GPX" button that opens a side panel for -// uploading GPX files (via drag & drop, stored server-side in a folder -// hierarchy the user can add to and reorganize by dragging), and toggling -// their tracks/waypoints on and off the map. +// A sidebar icon that opens a side panel for uploading GPX files (via +// drag & drop, stored server-side in a folder hierarchy the user can add to +// and reorganize by dragging), and toggling their tracks/waypoints on and +// off the map. export class GpxPanelControl { constructor(map) { this._map = map; @@ -28,88 +30,74 @@ export class GpxPanelControl { }); } - onAdd() { - this._button = document.createElement("div"); - this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle"; - this._button.innerHTML = ``; - this._button - .querySelector("button") - .addEventListener("click", () => this._togglePanel()); + mount(sidebar) { + this._container = document.createElement("div"); + this._container.className = "sidebar-item"; + this._container.innerHTML = ``; + this._icon = this._container.querySelector(".sidebar-icon"); + this._icon.addEventListener("click", () => this._togglePanel()); this._panel = document.getElementById("gpx-panel"); this._treeContainer = this._panel.querySelector(".gpx-tree"); this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); + this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON; + this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON; this._panel .querySelector(".gpx-panel__close") .addEventListener("click", () => this._togglePanel(false)); - this._setupDropzone(); - this._setupNewFolderForm(); + onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); + + this._setupToolbarActions(); this._setupRootDropTarget(); + this._setupMapDropTarget(); this._loadTree(); - return this._button; - } - - onRemove() { - this._button.remove(); + sidebar.appendChild(this._container); } _togglePanel(force) { - this._panel.hidden = force === undefined ? !this._panel.hidden : !force; + const open = force === undefined ? this._panel.hidden : force; + this._panel.hidden = !open; + this._icon.classList.toggle("active", open); + if (open) notifySidePanelOpen(this._panel); } - _setupDropzone() { - const dropzone = this._panel.querySelector(".gpx-dropzone"); - const fileInput = this._panel.querySelector(".gpx-dropzone__input"); - - dropzone.addEventListener("click", () => fileInput.click()); + // Both toolbar actions are icon buttons now (matching OsmAnd's tracks + // panel) rather than an always-visible dropzone/form: upload triggers the + // native file picker, new folder prompts for a path. + _setupToolbarActions() { + const fileInput = this._panel.querySelector(".gpx-file-input"); fileInput.addEventListener("change", () => { this._uploadFiles(fileInput.files, ""); fileInput.value = ""; }); - dropzone.addEventListener("dragover", (event) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dropzone.classList.add("gpx-drop-hover"); - }); - dropzone.addEventListener("dragleave", () => dropzone.classList.remove("gpx-drop-hover")); - dropzone.addEventListener("drop", (event) => { - event.preventDefault(); - dropzone.classList.remove("gpx-drop-hover"); - if (event.dataTransfer.files?.length > 0) this._uploadFiles(event.dataTransfer.files, ""); - }); - } - - _setupNewFolderForm() { - const form = this._panel.querySelector(".gpx-new-folder"); - form.addEventListener("submit", async (event) => { - event.preventDefault(); - const input = form.querySelector("input"); - const path = input.value.trim(); - if (!path) return; + this._panel.querySelector('[data-action="upload"]').addEventListener("click", () => fileInput.click()); + this._panel.querySelector('[data-action="new-folder"]').addEventListener("click", async () => { + const path = window.prompt("New folder name (e.g. Hiking/2026):"); + if (!path || !path.trim()) return; const response = await fetch(FOLDER_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `path=${encodeURIComponent(path)}`, + body: `path=${encodeURIComponent(path.trim())}`, }); if (response.ok) { const { tree } = await response.json(); this._renderTree(tree); - input.value = ""; } }); } - // Dropping a dragged file anywhere in the tree that isn't a folder (e.g. - // blank space, or onto another file) moves it back to the top level — - // folder drop targets call stopPropagation() so this only fires then. + // The tree itself is the drop target for both an OS file drag (upload to + // the top level) and, for anywhere that isn't a folder (blank space, or + // onto another file — folder drop targets call stopPropagation() so this + // only fires then), an internal file drag (move back to the top level). _setupRootDropTarget() { this._treeContainer.addEventListener("dragover", (event) => { - if (!event.dataTransfer.types.includes(MOVE_MIME)) return; + if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return; event.preventDefault(); this._treeContainer.classList.add("gpx-drop-hover"); }); @@ -119,11 +107,36 @@ export class GpxPanelControl { this._treeContainer.addEventListener("drop", (event) => { event.preventDefault(); this._treeContainer.classList.remove("gpx-drop-hover"); + if (event.dataTransfer.files?.length > 0) { + this._uploadFiles(event.dataTransfer.files, ""); + return; + } const from = event.dataTransfer.getData(MOVE_MIME); if (from) this._moveFile(from, ""); }); } + // Dropping a GPX file straight onto the map (not just the panel) uploads + // it to the top level too. + _setupMapDropTarget() { + const mapContainer = this._map.getContainer(); + mapContainer.addEventListener("dragover", (event) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + mapContainer.classList.add("gpx-drop-hover"); + }); + mapContainer.addEventListener("dragleave", (event) => { + if (event.target === mapContainer) mapContainer.classList.remove("gpx-drop-hover"); + }); + mapContainer.addEventListener("drop", (event) => { + mapContainer.classList.remove("gpx-drop-hover"); + if (event.dataTransfer.files?.length > 0) { + event.preventDefault(); + this._uploadFiles(event.dataTransfer.files, ""); + } + }); + } + async _loadTree() { const response = await fetch(TREE_URL); const tree = await response.json(); diff --git a/public/js/icons.js b/public/js/icons.js new file mode 100644 index 0000000..9a6d5bd --- /dev/null +++ b/public/js/icons.js @@ -0,0 +1,39 @@ +// Small hand-authored monoline SVG icons for the sidebar, so the app +// doesn't need to vendor an icon font/library for four glyphs. + +export const LAYERS_ICON = ` + + + +`; + +export const GPX_ICON = ` + + + + +`; + +export const RADAR_ICON = ` + + +`; + +export const GNSS_ICON = ` + + + + +`; + +export const NEW_FOLDER_ICON = ` + + + +`; + +export const UPLOAD_ICON = ` + + + +`; diff --git a/public/js/map.js b/public/js/map.js index 201cabb..0efb06e 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -6,10 +6,13 @@ import { } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; import { GpxPanelControl } from "/js/gpx.js"; +import { GnssControl } from "/js/gnss.js"; +import { onlineStyle, offlineTheme } from "/js/osmand-style.js"; +import { LAYERS_ICON } from "/js/icons.js"; +import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; const CENTER = [17.1077, 48.1486]; const ZOOM = 13; -const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty"; const protocol = new pmtiles.Protocol(); addProtocol("pmtiles", protocol.tile); @@ -17,7 +20,7 @@ addProtocol("pmtiles", protocol.tile); // No label layers here (see docs/architecture.adoc): rendering text needs a // glyphs server, which the offline style deliberately does not depend on. function offlineStyle() { - const theme = protomaps_themes_base.namedTheme("light"); + const theme = offlineTheme(protomaps_themes_base.namedTheme("light")); const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`; return { @@ -33,31 +36,46 @@ function offlineStyle() { }; } +// A sidebar icon that opens a full-height side panel (like the GPX one) +// with the Online/Offline choice — modelled after OsmAnd's "Configure map" +// panel, rather than a small dropdown flyout. class LayerToggleControl { constructor(onSelect) { this._onSelect = onSelect; } - onAdd() { + mount(sidebar) { this._container = document.createElement("div"); - this._container.className = "maplibregl-ctrl maplibregl-ctrl-group layer-toggle"; - this._container.innerHTML = ` - - - `; - this._container.addEventListener("click", (event) => { - const button = event.target.closest("button[data-style]"); - if (button) this._onSelect(button.dataset.style); + this._container.className = "sidebar-item"; + this._container.innerHTML = ``; + this._icon = this._container.querySelector(".sidebar-icon"); + this._icon.addEventListener("click", () => this._togglePanel()); + + this._panel = document.getElementById("basemap-panel"); + this._panel + .querySelector(".basemap-panel__close") + .addEventListener("click", () => this._togglePanel(false)); + this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => { + button.addEventListener("click", () => { + this._onSelect(button.dataset.style); + this._togglePanel(false); + }); }); - return this._container; + + onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); + + sidebar.appendChild(this._container); } - onRemove() { - this._container.remove(); + _togglePanel(force) { + const open = force === undefined ? this._panel.hidden : force; + this._panel.hidden = !open; + this._icon.classList.toggle("active", open); + if (open) notifySidePanelOpen(this._panel); } setActive(name) { - this._container.querySelectorAll("button").forEach((button) => { + this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => { button.classList.toggle("active", button.dataset.style === name); }); } @@ -65,30 +83,34 @@ class LayerToggleControl { const map = new MaplibreMap({ container: "map", - style: ONLINE_STYLE, + style: await onlineStyle(), center: CENTER, zoom: ZOOM, }); -map.addControl(new NavigationControl(), "top-right"); +map.addControl(new NavigationControl(), "bottom-right"); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showAccuracyCircle: true, }), - "top-right", + "bottom-right", ); -map.addControl(new RadarControl(map), "top-right"); + +const sidebar = document.getElementById("sidebar"); const layerToggle = new LayerToggleControl(applyStyle); -map.addControl(layerToggle, "top-left"); +layerToggle.mount(sidebar); layerToggle.setActive("online"); -map.addControl(new GpxPanelControl(map), "top-left"); -function applyStyle(name) { +new GpxPanelControl(map).mount(sidebar); +new RadarControl(map).mount(sidebar); +new GnssControl(map).mount(sidebar); + +async function applyStyle(name) { layerToggle.setActive(name); - map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE); + map.setStyle(name === "offline" ? offlineStyle() : await onlineStyle()); } // If a local PMTiles file is present, prefer it as the default style — its diff --git a/public/js/osmand-style.js b/public/js/osmand-style.js new file mode 100644 index 0000000..d18385f --- /dev/null +++ b/public/js/osmand-style.js @@ -0,0 +1,160 @@ +// Approximates the look of OsmAnd's own map render (osmand.net/map — muted +// earth tones, cream background, orange roads) without depending on their +// tile server: OsmAnd's terms restrict tile use to "personal, non-commercial +// use within OsmAnd applications and platforms", which this app isn't. +// +// One shared palette drives two independent recolorings so Online and +// Offline look consistent: OpenFreeMap's "liberty" vector style (paint +// colors patched in place, see recolorLayer) for Online, and a custom +// protomaps-themes-base Theme (see offlineTheme) for Offline. + +const PALETTE = { + background: "#f1ecd7", + water: "#aad3df", + wood: "#c8dfb0", + park: "#d3e8b8", + landuse: "#ece4c6", + building: "#d9cead", + buildingOutline: "#c7bb92", + roadMajor: "#eb9d43", + roadMajorCasing: "#c9822c", + roadMinor: "#fdf8ee", + roadMinorCasing: "#cfc09a", + boundary: "#a98fc2", +}; + +const LIBERTY_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty"; +let cachedOnlineStyle = null; + +// Fetches OpenFreeMap's "liberty" style and recolors it in place. Cached — +// the recoloring is pure, and re-fetching on every Online/Offline toggle +// would be wasteful. +export async function onlineStyle() { + if (cachedOnlineStyle) return cachedOnlineStyle; + + const response = await fetch(LIBERTY_STYLE_URL); + const style = await response.json(); + style.layers = style.layers.map(recolorLayer); + cachedOnlineStyle = style; + return style; +} + +// Overrides the color roles that define the "look" on top of a +// protomaps-themes-base Theme (e.g. namedTheme("light")), leaving roles it +// doesn't set (labels, rail, tunnels, POI colors) at their defaults. +export function offlineTheme(baseTheme) { + return { + ...baseTheme, + background: PALETTE.background, + earth: PALETTE.background, + water: PALETTE.water, + wood_a: PALETTE.wood, + wood_b: PALETTE.wood, + park_a: PALETTE.park, + park_b: PALETTE.park, + scrub_a: PALETTE.park, + scrub_b: PALETTE.park, + pedestrian: PALETTE.landuse, + industrial: PALETTE.landuse, + school: PALETTE.landuse, + hospital: PALETTE.landuse, + buildings: PALETTE.building, + highway_casing_early: PALETTE.roadMajorCasing, + highway_casing_late: PALETTE.roadMajorCasing, + highway: PALETTE.roadMajor, + major_casing_early: PALETTE.roadMajorCasing, + major_casing_late: PALETTE.roadMajorCasing, + major: PALETTE.roadMajor, + link_casing: PALETTE.roadMajorCasing, + link: PALETTE.roadMajor, + minor_casing: PALETTE.roadMinorCasing, + minor_service_casing: PALETTE.roadMinorCasing, + minor_a: PALETTE.roadMinor, + minor_b: PALETTE.roadMinor, + other: PALETTE.roadMinor, + minor_service: PALETTE.roadMinor, + boundaries: PALETTE.boundary, + }; +} + +const FILL_PROP = /^(background-color|fill-color|line-color|fill-extrusion-color)$/; +const OUTLINE_PROP = /outline-color$/; + +const FILL_COLOR = { + background: PALETTE.background, + water: PALETTE.water, + wood: PALETTE.wood, + park: PALETTE.park, + landuse: PALETTE.landuse, + building: PALETTE.building, + roadMajor: PALETTE.roadMajor, + roadMinor: PALETTE.roadMinor, + boundary: PALETTE.boundary, +}; + +const OUTLINE_COLOR = { + building: PALETTE.buildingOutline, + roadMajor: PALETTE.roadMajorCasing, + roadMinor: PALETTE.roadMinorCasing, + park: "#bcdb9a", +}; + +// Maps a "liberty" style layer id to one of the categories above, based on +// the id naming conventions OpenFreeMap's style uses (verified against its +// actual layer list — see docs/architecture.adoc). Returns null for +// anything left at its original color: labels/icons (all "symbol" layers, +// filtered by type below before this even runs), railways, and minor +// terrain fills (ice/wetland/sand) that aren't part of OsmAnd's signature +// look. +function categoryFor(layerId) { + if (layerId === "background") return "background"; + if (/rail/.test(layerId)) return null; + if (/^water/.test(layerId)) return "water"; + if (/^landcover_wood/.test(layerId)) return "wood"; + if (/^(park|landcover_grass)/.test(layerId)) return "park"; + if (/^landuse/.test(layerId)) return "landuse"; + if (/^building/.test(layerId)) return "building"; + if (/^(road|bridge|tunnel)_(motorway|trunk_primary)/.test(layerId)) return "roadMajor"; + if (/^(road|bridge|tunnel)_/.test(layerId)) return "roadMinor"; + if (/^boundary/.test(layerId)) return "boundary"; + return null; +} + +const RECOLORABLE_TYPES = new Set(["background", "fill", "line", "fill-extrusion"]); + +function recolorLayer(layer) { + if (!RECOLORABLE_TYPES.has(layer.type) || !layer.paint) return layer; + + const category = categoryFor(layer.id); + if (!category) return layer; + + const paint = { ...layer.paint }; + for (const [prop, value] of Object.entries(paint)) { + if (OUTLINE_PROP.test(prop) && OUTLINE_COLOR[category]) { + paint[prop] = replaceColors(value, OUTLINE_COLOR[category]); + } else if (FILL_PROP.test(prop)) { + paint[prop] = replaceColors(value, FILL_COLOR[category]); + } + } + + return { ...layer, paint }; +} + +const COLOR_PATTERN = /^#[0-9a-f]{3,8}$/i; +const COLOR_FN_PATTERN = /^(rgb|hsl)a?\(/i; + +// Walks a paint value — a flat color string, or a MapLibre expression like +// ["interpolate", ["linear"], ["zoom"], 5, "hsl(26,87%,62%)", 6, "#fc8"] — +// and swaps every color-shaped string for `newColor`, leaving operators, +// zoom/get references and numbers untouched. Generic across expression +// types (interpolate/match/case/step/...) since it never inspects the +// expression's operator, only whether each leaf looks like a color. +function replaceColors(value, newColor) { + if (typeof value === "string") { + return COLOR_PATTERN.test(value) || COLOR_FN_PATTERN.test(value) ? newColor : value; + } + if (Array.isArray(value)) { + return value.map((item) => replaceColors(item, newColor)); + } + return value; +} diff --git a/public/js/panels.js b/public/js/panels.js new file mode 100644 index 0000000..2617fbb --- /dev/null +++ b/public/js/panels.js @@ -0,0 +1,17 @@ +// Keeps at most one full-height side panel (base map, GPX, ...) open at a +// time — they all dock to the same spot next to the sidebar, so two open +// together would just stack on top of each other. +const OPEN_EVENT = "side-panel-open"; + +export function notifySidePanelOpen(panel) { + document.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: panel })); +} + +export function onlyOneSidePanelOpen(panel, onClosedByOther) { + document.addEventListener(OPEN_EVENT, (event) => { + if (event.detail !== panel && !panel.hidden) { + panel.hidden = true; + onClosedByOther(); + } + }); +} diff --git a/public/js/radar.js b/public/js/radar.js index d5d32ae..d3f8fb9 100644 --- a/public/js/radar.js +++ b/public/js/radar.js @@ -1,3 +1,5 @@ +import { RADAR_ICON } from "/js/icons.js"; + const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; // Matches the default window used by github.com/jpettitt/weather-radar-card @@ -9,10 +11,10 @@ const OPACITY = 0.7; const ATTRIBUTION = 'Weather data © RainViewer'; -// A MapLibre IControl: a "Radar" checkbox that fetches RainViewer's recent -// precipitation frames on demand and animates them as a raster overlay, with -// play/pause and a scrub slider. No API key needed — see -// docs/architecture.adoc for RainViewer's usage terms. +// A sidebar icon that fetches RainViewer's recent precipitation frames on +// demand and animates them as a raster overlay, with play/pause and a scrub +// slider in a flyout. No API key needed — see docs/architecture.adoc for +// RainViewer's usage terms. export class RadarControl { constructor(map) { this._map = map; @@ -29,45 +31,39 @@ export class RadarControl { }); } - onAdd() { + mount(sidebar) { this._container = document.createElement("div"); - this._container.className = "maplibregl-ctrl maplibregl-ctrl-group radar-control"; + this._container.className = "sidebar-item"; this._container.innerHTML = ` - -