Files
maptool/app.rb
T
randy 87df90f75c 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.
2026-08-25 19:39:13 +02:00

151 lines
4.4 KiB
Ruby

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
set :public_folder, File.join(__dir__, "public")
set :views, File.join(__dir__, "views")
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
get "/api/health" do
json status: "ok"
end
get "/api/offline-tiles" do
json available: File.exist?(OFFLINE_PMTILES_PATH)
end
# Served as a plain range-request-capable file, not decoded server-side:
# pmtiles.js reads the archive's directory and fetches only the byte
# ranges it needs for the tiles currently on screen.
get "/tiles/offline.pmtiles" do
halt 404 unless File.exist?(OFFLINE_PMTILES_PATH)
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
end
get "/api/gpx/tree" do
json GPX_STORE.tree
end
post "/api/gpx" do
file = params[:file]
halt 400, json(error: "file is required") unless file && file[:tempfile]
begin
path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid folder")
end
json path: path, tree: GPX_STORE.tree
end
get "/api/gpx/file/*" do
begin
path = GPX_STORE.resolve_file(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
end
halt 404 unless File.file?(path)
begin
json Gpx.to_geojson(path)
rescue Gpx::ParseError
halt 422, json(error: "could not parse GPX file")
end
end
post "/api/gpx/folder" do
begin
GPX_STORE.create_folder(params[:path].to_s)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
end
json tree: GPX_STORE.tree
end
delete "/api/gpx/file/*" do
begin
GPX_STORE.delete_file(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
rescue Errno::ENOENT
halt 404
end
json tree: GPX_STORE.tree
end
delete "/api/gpx/folder/*" do
begin
GPX_STORE.delete_folder(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
rescue Errno::ENOENT
halt 404
end
json tree: GPX_STORE.tree
end
post "/api/gpx/move" do
begin
path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404
end
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