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
+5
-3
@@ -10,9 +10,11 @@ The project's shape is still open — this is the initial scaffold.
|
|||||||
== Status
|
== Status
|
||||||
|
|
||||||
Early scaffold. The web application serves a vector map (MapLibre GL
|
Early scaffold. The web application serves a vector map (MapLibre GL
|
||||||
JS), with an online style and an optional offline style backed by a
|
JS), styled to resemble https://osmand.net/map[OsmAnd's own map], with
|
||||||
local PMTiles file, an animated RainViewer radar overlay, and GPX
|
an online style and an optional offline style backed by a local
|
||||||
upload/display (points and tracks, organized in folders).
|
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
|
== Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ require "sinatra/base"
|
|||||||
require "sinatra/json"
|
require "sinatra/json"
|
||||||
require_relative "lib/maptool/gpx"
|
require_relative "lib/maptool/gpx"
|
||||||
require_relative "lib/maptool/gpx_store"
|
require_relative "lib/maptool/gpx_store"
|
||||||
|
require_relative "lib/maptool/gpsd_client"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
class App < Sinatra::Base
|
class App < Sinatra::Base
|
||||||
@@ -11,6 +12,34 @@ module Maptool
|
|||||||
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
|
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
|
||||||
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
|
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
|
get "/" do
|
||||||
erb :index
|
erb :index
|
||||||
end
|
end
|
||||||
@@ -109,5 +138,13 @@ module Maptool
|
|||||||
|
|
||||||
json path: path, tree: GPX_STORE.tree
|
json path: path, tree: GPX_STORE.tree
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
@@ -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
|
supplied locally (see `docs/development.adoc`) and stays gitignored
|
||||||
because such extracts are typically large binary files.
|
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
|
== Weather radar overlay
|
||||||
|
|
||||||
`public/js/radar.js` adds a "Radar" map control that overlays recent
|
`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
|
matched URL can never break out of the `href="..."` attribute. Every
|
||||||
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
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
|
== Networking
|
||||||
|
|
||||||
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
|
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
|
Once the file is in place, `GET /api/offline-tiles` reports
|
||||||
`available: true` and the map defaults to the offline style.
|
`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.
|
||||||
@@ -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
|
||||||
+227
-82
@@ -1,50 +1,129 @@
|
|||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#map {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-toggle {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-toggle button {
|
.topbar {
|
||||||
/* Override maplibregl-ctrl-group's 29x29px square icon-button sizing —
|
flex: 0 0 auto;
|
||||||
these are text buttons. */
|
display: flex;
|
||||||
width: auto;
|
align-items: center;
|
||||||
height: auto;
|
height: 48px;
|
||||||
border: none;
|
padding: 0 16px;
|
||||||
background: #fff;
|
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;
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-toggle button.active {
|
.sidebar-flyout__option:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-flyout__option.active {
|
||||||
background: #dbe9ff;
|
background: #dbe9ff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-control {
|
#map {
|
||||||
display: flex;
|
flex: 1;
|
||||||
flex-direction: column;
|
min-width: 0;
|
||||||
gap: 4px;
|
position: relative;
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radar-control__toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-control__playback {
|
.radar-control__playback {
|
||||||
@@ -53,14 +132,7 @@ html, body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-control__playback[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radar-control__play {
|
.radar-control__play {
|
||||||
/* Override maplibregl-ctrl-group's 29x29px square icon-button sizing. */
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
@@ -77,31 +149,90 @@ html, body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gpx-toggle button {
|
.basemap-panel {
|
||||||
/* Override maplibregl-ctrl-group's 29x29px square icon-button sizing —
|
position: fixed;
|
||||||
this is a text button. */
|
top: 0;
|
||||||
width: auto;
|
left: 52px;
|
||||||
height: auto;
|
width: 260px;
|
||||||
border: none;
|
max-width: 90vw;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 6px 10px;
|
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
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;
|
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 {
|
.gpx-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 52px;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #fff;
|
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;
|
font-size: 13px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
@@ -118,6 +249,34 @@ html, body {
|
|||||||
border-bottom: 1px solid #eee;
|
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 {
|
.gpx-panel__close {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -127,49 +286,11 @@ html, body {
|
|||||||
cursor: pointer;
|
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 {
|
.gpx-upload__status {
|
||||||
margin: 0 12px 10px;
|
margin: 0 12px 10px;
|
||||||
color: #666;
|
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 {
|
.gpx-tree {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -252,6 +373,16 @@ html, body {
|
|||||||
outline-offset: -2px;
|
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 {
|
.maplibregl-popup-content p {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
@@ -260,3 +391,17 @@ html, body {
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-size: 12px;
|
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);
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<button type="button" class="sidebar-icon" title="GNSS" aria-label="GNSS">${GNSS_ICON}</button>
|
||||||
|
<div class="sidebar-flyout" hidden>
|
||||||
|
<span class="gnss-control__status"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
+63
-50
@@ -1,4 +1,6 @@
|
|||||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
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 TREE_URL = "/api/gpx/tree";
|
||||||
const UPLOAD_URL = "/api/gpx";
|
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.
|
// one — both land on the same drop targets, told apart by this.
|
||||||
const MOVE_MIME = "application/x-gpx-path";
|
const MOVE_MIME = "application/x-gpx-path";
|
||||||
|
|
||||||
// A MapLibre IControl: a "GPX" button that opens a side panel for
|
// A sidebar icon that opens a side panel for uploading GPX files (via
|
||||||
// uploading GPX files (via drag & drop, stored server-side in a folder
|
// drag & drop, stored server-side in a folder hierarchy the user can add to
|
||||||
// hierarchy the user can add to and reorganize by dragging), and toggling
|
// and reorganize by dragging), and toggling their tracks/waypoints on and
|
||||||
// their tracks/waypoints on and off the map.
|
// off the map.
|
||||||
export class GpxPanelControl {
|
export class GpxPanelControl {
|
||||||
constructor(map) {
|
constructor(map) {
|
||||||
this._map = map;
|
this._map = map;
|
||||||
@@ -28,88 +30,74 @@ export class GpxPanelControl {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd() {
|
mount(sidebar) {
|
||||||
this._button = document.createElement("div");
|
this._container = document.createElement("div");
|
||||||
this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle";
|
this._container.className = "sidebar-item";
|
||||||
this._button.innerHTML = `<button type="button">GPX</button>`;
|
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="GPX" aria-label="GPX">${GPX_ICON}</button>`;
|
||||||
this._button
|
this._icon = this._container.querySelector(".sidebar-icon");
|
||||||
.querySelector("button")
|
this._icon.addEventListener("click", () => this._togglePanel());
|
||||||
.addEventListener("click", () => this._togglePanel());
|
|
||||||
|
|
||||||
this._panel = document.getElementById("gpx-panel");
|
this._panel = document.getElementById("gpx-panel");
|
||||||
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
||||||
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
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
|
this._panel
|
||||||
.querySelector(".gpx-panel__close")
|
.querySelector(".gpx-panel__close")
|
||||||
.addEventListener("click", () => this._togglePanel(false));
|
.addEventListener("click", () => this._togglePanel(false));
|
||||||
|
|
||||||
this._setupDropzone();
|
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
|
||||||
this._setupNewFolderForm();
|
|
||||||
|
this._setupToolbarActions();
|
||||||
this._setupRootDropTarget();
|
this._setupRootDropTarget();
|
||||||
|
this._setupMapDropTarget();
|
||||||
|
|
||||||
this._loadTree();
|
this._loadTree();
|
||||||
|
|
||||||
return this._button;
|
sidebar.appendChild(this._container);
|
||||||
}
|
|
||||||
|
|
||||||
onRemove() {
|
|
||||||
this._button.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_togglePanel(force) {
|
_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() {
|
// Both toolbar actions are icon buttons now (matching OsmAnd's tracks
|
||||||
const dropzone = this._panel.querySelector(".gpx-dropzone");
|
// panel) rather than an always-visible dropzone/form: upload triggers the
|
||||||
const fileInput = this._panel.querySelector(".gpx-dropzone__input");
|
// native file picker, new folder prompts for a path.
|
||||||
|
_setupToolbarActions() {
|
||||||
dropzone.addEventListener("click", () => fileInput.click());
|
const fileInput = this._panel.querySelector(".gpx-file-input");
|
||||||
fileInput.addEventListener("change", () => {
|
fileInput.addEventListener("change", () => {
|
||||||
this._uploadFiles(fileInput.files, "");
|
this._uploadFiles(fileInput.files, "");
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
dropzone.addEventListener("dragover", (event) => {
|
this._panel.querySelector('[data-action="upload"]').addEventListener("click", () => fileInput.click());
|
||||||
if (!event.dataTransfer.types.includes("Files")) return;
|
this._panel.querySelector('[data-action="new-folder"]').addEventListener("click", async () => {
|
||||||
event.preventDefault();
|
const path = window.prompt("New folder name (e.g. Hiking/2026):");
|
||||||
dropzone.classList.add("gpx-drop-hover");
|
if (!path || !path.trim()) return;
|
||||||
});
|
|
||||||
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;
|
|
||||||
|
|
||||||
const response = await fetch(FOLDER_URL, {
|
const response = await fetch(FOLDER_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: `path=${encodeURIComponent(path)}`,
|
body: `path=${encodeURIComponent(path.trim())}`,
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const { tree } = await response.json();
|
const { tree } = await response.json();
|
||||||
this._renderTree(tree);
|
this._renderTree(tree);
|
||||||
input.value = "";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropping a dragged file anywhere in the tree that isn't a folder (e.g.
|
// The tree itself is the drop target for both an OS file drag (upload to
|
||||||
// blank space, or onto another file) moves it back to the top level —
|
// the top level) and, for anywhere that isn't a folder (blank space, or
|
||||||
// folder drop targets call stopPropagation() so this only fires then.
|
// onto another file — folder drop targets call stopPropagation() so this
|
||||||
|
// only fires then), an internal file drag (move back to the top level).
|
||||||
_setupRootDropTarget() {
|
_setupRootDropTarget() {
|
||||||
this._treeContainer.addEventListener("dragover", (event) => {
|
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();
|
event.preventDefault();
|
||||||
this._treeContainer.classList.add("gpx-drop-hover");
|
this._treeContainer.classList.add("gpx-drop-hover");
|
||||||
});
|
});
|
||||||
@@ -119,11 +107,36 @@ export class GpxPanelControl {
|
|||||||
this._treeContainer.addEventListener("drop", (event) => {
|
this._treeContainer.addEventListener("drop", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this._treeContainer.classList.remove("gpx-drop-hover");
|
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);
|
const from = event.dataTransfer.getData(MOVE_MIME);
|
||||||
if (from) this._moveFile(from, "");
|
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() {
|
async _loadTree() {
|
||||||
const response = await fetch(TREE_URL);
|
const response = await fetch(TREE_URL);
|
||||||
const tree = await response.json();
|
const tree = await response.json();
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<path d="M12 3 2 9l10 6 10-6-10-6z"/>
|
||||||
|
<path d="M2 14l10 6 10-6"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export const GPX_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="5" cy="6" r="2"/>
|
||||||
|
<circle cx="19" cy="18" r="2"/>
|
||||||
|
<path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export const RADAR_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<path d="M7 18a4 4 0 1 1 .7-7.94A5 5 0 0 1 17 9a4 4 0 0 1 0 8H7z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export const GNSS_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="5.5"/>
|
||||||
|
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/>
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export const NEW_FOLDER_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||||
|
<path d="M12 11v5M9.5 13.5h5"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export const UPLOAD_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 15V4M8 8l4-4 4 4"/>
|
||||||
|
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
|
||||||
|
</svg>`;
|
||||||
+45
-23
@@ -6,10 +6,13 @@ import {
|
|||||||
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
import { RadarControl } from "/js/radar.js";
|
import { RadarControl } from "/js/radar.js";
|
||||||
import { GpxPanelControl } from "/js/gpx.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 CENTER = [17.1077, 48.1486];
|
||||||
const ZOOM = 13;
|
const ZOOM = 13;
|
||||||
const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
|
||||||
|
|
||||||
const protocol = new pmtiles.Protocol();
|
const protocol = new pmtiles.Protocol();
|
||||||
addProtocol("pmtiles", protocol.tile);
|
addProtocol("pmtiles", protocol.tile);
|
||||||
@@ -17,7 +20,7 @@ addProtocol("pmtiles", protocol.tile);
|
|||||||
// No label layers here (see docs/architecture.adoc): rendering text needs a
|
// No label layers here (see docs/architecture.adoc): rendering text needs a
|
||||||
// glyphs server, which the offline style deliberately does not depend on.
|
// glyphs server, which the offline style deliberately does not depend on.
|
||||||
function offlineStyle() {
|
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`;
|
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
|
||||||
|
|
||||||
return {
|
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 {
|
class LayerToggleControl {
|
||||||
constructor(onSelect) {
|
constructor(onSelect) {
|
||||||
this._onSelect = onSelect;
|
this._onSelect = onSelect;
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd() {
|
mount(sidebar) {
|
||||||
this._container = document.createElement("div");
|
this._container = document.createElement("div");
|
||||||
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group layer-toggle";
|
this._container.className = "sidebar-item";
|
||||||
this._container.innerHTML = `
|
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Base map" aria-label="Base map">${LAYERS_ICON}</button>`;
|
||||||
<button type="button" data-style="online">Online</button>
|
this._icon = this._container.querySelector(".sidebar-icon");
|
||||||
<button type="button" data-style="offline">Offline</button>
|
this._icon.addEventListener("click", () => this._togglePanel());
|
||||||
`;
|
|
||||||
this._container.addEventListener("click", (event) => {
|
this._panel = document.getElementById("basemap-panel");
|
||||||
const button = event.target.closest("button[data-style]");
|
this._panel
|
||||||
if (button) this._onSelect(button.dataset.style);
|
.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() {
|
_togglePanel(force) {
|
||||||
this._container.remove();
|
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) {
|
setActive(name) {
|
||||||
this._container.querySelectorAll("button").forEach((button) => {
|
this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => {
|
||||||
button.classList.toggle("active", button.dataset.style === name);
|
button.classList.toggle("active", button.dataset.style === name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,30 +83,34 @@ class LayerToggleControl {
|
|||||||
|
|
||||||
const map = new MaplibreMap({
|
const map = new MaplibreMap({
|
||||||
container: "map",
|
container: "map",
|
||||||
style: ONLINE_STYLE,
|
style: await onlineStyle(),
|
||||||
center: CENTER,
|
center: CENTER,
|
||||||
zoom: ZOOM,
|
zoom: ZOOM,
|
||||||
});
|
});
|
||||||
|
|
||||||
map.addControl(new NavigationControl(), "top-right");
|
map.addControl(new NavigationControl(), "bottom-right");
|
||||||
map.addControl(
|
map.addControl(
|
||||||
new GeolocateControl({
|
new GeolocateControl({
|
||||||
positionOptions: { enableHighAccuracy: true },
|
positionOptions: { enableHighAccuracy: true },
|
||||||
trackUserLocation: true,
|
trackUserLocation: true,
|
||||||
showAccuracyCircle: true,
|
showAccuracyCircle: true,
|
||||||
}),
|
}),
|
||||||
"top-right",
|
"bottom-right",
|
||||||
);
|
);
|
||||||
map.addControl(new RadarControl(map), "top-right");
|
|
||||||
|
const sidebar = document.getElementById("sidebar");
|
||||||
|
|
||||||
const layerToggle = new LayerToggleControl(applyStyle);
|
const layerToggle = new LayerToggleControl(applyStyle);
|
||||||
map.addControl(layerToggle, "top-left");
|
layerToggle.mount(sidebar);
|
||||||
layerToggle.setActive("online");
|
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);
|
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
|
// If a local PMTiles file is present, prefer it as the default style — its
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+27
-31
@@ -1,3 +1,5 @@
|
|||||||
|
import { RADAR_ICON } from "/js/icons.js";
|
||||||
|
|
||||||
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
|
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
|
||||||
|
|
||||||
// Matches the default window used by github.com/jpettitt/weather-radar-card
|
// Matches the default window used by github.com/jpettitt/weather-radar-card
|
||||||
@@ -9,10 +11,10 @@ const OPACITY = 0.7;
|
|||||||
const ATTRIBUTION =
|
const ATTRIBUTION =
|
||||||
'Weather data © <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
|
'Weather data © <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
|
||||||
|
|
||||||
// A MapLibre IControl: a "Radar" checkbox that fetches RainViewer's recent
|
// A sidebar icon that fetches RainViewer's recent precipitation frames on
|
||||||
// precipitation frames on demand and animates them as a raster overlay, with
|
// demand and animates them as a raster overlay, with play/pause and a scrub
|
||||||
// play/pause and a scrub slider. No API key needed — see
|
// slider in a flyout. No API key needed — see docs/architecture.adoc for
|
||||||
// docs/architecture.adoc for RainViewer's usage terms.
|
// RainViewer's usage terms.
|
||||||
export class RadarControl {
|
export class RadarControl {
|
||||||
constructor(map) {
|
constructor(map) {
|
||||||
this._map = map;
|
this._map = map;
|
||||||
@@ -29,45 +31,39 @@ export class RadarControl {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd() {
|
mount(sidebar) {
|
||||||
this._container = document.createElement("div");
|
this._container = document.createElement("div");
|
||||||
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group radar-control";
|
this._container.className = "sidebar-item";
|
||||||
this._container.innerHTML = `
|
this._container.innerHTML = `
|
||||||
<label class="radar-control__toggle">
|
<button type="button" class="sidebar-icon" title="Radar" aria-label="Radar">${RADAR_ICON}</button>
|
||||||
<input type="checkbox" class="radar-control__checkbox">
|
<div class="sidebar-flyout" hidden>
|
||||||
Radar
|
<div class="radar-control__playback">
|
||||||
</label>
|
<button type="button" class="radar-control__play" aria-label="Play">▶</button>
|
||||||
<div class="radar-control__playback" hidden>
|
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
|
||||||
<button type="button" class="radar-control__play" aria-label="Play">▶</button>
|
<span class="radar-control__time"></span>
|
||||||
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
|
</div>
|
||||||
<span class="radar-control__time"></span>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this._checkbox = this._container.querySelector(".radar-control__checkbox");
|
this._icon = this._container.querySelector(".sidebar-icon");
|
||||||
this._playback = this._container.querySelector(".radar-control__playback");
|
this._flyout = this._container.querySelector(".sidebar-flyout");
|
||||||
this._playButton = this._container.querySelector(".radar-control__play");
|
this._playButton = this._container.querySelector(".radar-control__play");
|
||||||
this._scrub = this._container.querySelector(".radar-control__scrub");
|
this._scrub = this._container.querySelector(".radar-control__scrub");
|
||||||
this._time = this._container.querySelector(".radar-control__time");
|
this._time = this._container.querySelector(".radar-control__time");
|
||||||
|
|
||||||
this._checkbox.addEventListener("change", () => this._setEnabled(this._checkbox.checked));
|
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
|
||||||
this._playButton.addEventListener("click", () => this._togglePlayback());
|
this._playButton.addEventListener("click", () => this._togglePlayback());
|
||||||
this._scrub.addEventListener("input", () => {
|
this._scrub.addEventListener("input", () => {
|
||||||
this._stopAnimation();
|
this._stopAnimation();
|
||||||
this._showFrame(Number(this._scrub.value));
|
this._showFrame(Number(this._scrub.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
return this._container;
|
sidebar.appendChild(this._container);
|
||||||
}
|
|
||||||
|
|
||||||
onRemove() {
|
|
||||||
this._stopAnimation();
|
|
||||||
this._removeLayers();
|
|
||||||
this._container.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _setEnabled(enabled) {
|
async _setEnabled(enabled) {
|
||||||
this._enabled = enabled;
|
this._enabled = enabled;
|
||||||
|
this._icon.classList.toggle("active", enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
this._stopAnimation();
|
this._stopAnimation();
|
||||||
this._removeLayers();
|
this._removeLayers();
|
||||||
@@ -76,20 +72,20 @@ export class RadarControl {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._checkbox.disabled = true;
|
this._icon.disabled = true;
|
||||||
let frames;
|
let frames;
|
||||||
try {
|
try {
|
||||||
frames = await fetchFrames();
|
frames = await fetchFrames();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._enabled = false;
|
this._enabled = false;
|
||||||
this._checkbox.checked = false;
|
this._icon.classList.remove("active");
|
||||||
this._checkbox.disabled = false;
|
this._icon.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._checkbox.disabled = false;
|
this._icon.disabled = false;
|
||||||
|
|
||||||
// The checkbox may have been switched off again while the fetch was in
|
// The icon may have been clicked again (disabling) while the fetch was
|
||||||
// flight.
|
// in flight.
|
||||||
if (!this._enabled || !frames.length) return;
|
if (!this._enabled || !frames.length) return;
|
||||||
|
|
||||||
this._frames = frames;
|
this._frames = frames;
|
||||||
@@ -168,7 +164,7 @@ export class RadarControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_updateFrameUI() {
|
_updateFrameUI() {
|
||||||
this._playback.hidden = this._frames.length === 0;
|
this._flyout.hidden = this._frames.length === 0;
|
||||||
this._scrub.max = String(Math.max(0, this._frames.length - 1));
|
this._scrub.max = String(Math.max(0, this._frames.length - 1));
|
||||||
this._scrub.value = String(this._frameIndex);
|
this._scrub.value = String(this._frameIndex);
|
||||||
const frame = this._frames[this._frameIndex];
|
const frame = this._frames[this._frameIndex];
|
||||||
|
|||||||
+33
-11
@@ -1,21 +1,43 @@
|
|||||||
<div id="map"></div>
|
<div id="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="topbar__brand">MapTool</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="app-body">
|
||||||
|
<nav id="sidebar" class="sidebar"></nav>
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="basemap-panel" class="basemap-panel" hidden>
|
||||||
|
<div class="basemap-panel__header">
|
||||||
|
<strong>Base map</strong>
|
||||||
|
<button type="button" class="basemap-panel__close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="basemap-panel__list">
|
||||||
|
<button type="button" class="basemap-panel__option" data-style="online">
|
||||||
|
<span>Online</span>
|
||||||
|
<span class="basemap-panel__check">✓</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="basemap-panel__option" data-style="offline">
|
||||||
|
<span>Offline</span>
|
||||||
|
<span class="basemap-panel__check">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="gpx-panel" class="gpx-panel" hidden>
|
<div id="gpx-panel" class="gpx-panel" hidden>
|
||||||
<div class="gpx-panel__header">
|
<div class="gpx-panel__header">
|
||||||
<strong>GPX</strong>
|
<strong>GPX</strong>
|
||||||
<button type="button" class="gpx-panel__close" aria-label="Close">×</button>
|
<div class="gpx-panel__actions">
|
||||||
|
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
|
||||||
|
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX file" aria-label="Upload GPX file"></button>
|
||||||
|
<button type="button" class="gpx-panel__close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gpx-dropzone">
|
<input type="file" class="gpx-file-input" accept=".gpx" multiple hidden>
|
||||||
Drop .gpx files here, or click to browse
|
|
||||||
<input type="file" class="gpx-dropzone__input" accept=".gpx" multiple hidden>
|
|
||||||
</div>
|
|
||||||
<p class="gpx-upload__status" hidden></p>
|
<p class="gpx-upload__status" hidden></p>
|
||||||
|
|
||||||
<form class="gpx-new-folder">
|
|
||||||
<input type="text" name="path" placeholder="new folder path (e.g. Hiking/2026)">
|
|
||||||
<button type="submit">+ folder</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="gpx-tree"></div>
|
<div class="gpx-tree"></div>
|
||||||
</div>
|
</div>
|
||||||
Reference in new issue
Block a user