?download=... on GET /api/gpx/file now requires login, same as
upload/move/delete/create-folder -- plain viewing (no ?download)
stays public. Added ?download=original alongside the existing
GeoJSON export, serving the untouched, as-uploaded file directly via
send_file -- the file name link still downloads GeoJSON, a new
button next to delete downloads the original.
Since both are real <a href> links (so right-click/middle-click still
work), a logged-out click can't just be left as a plain navigation --
it would land on a raw JSON error page instead of being sent to log
in. _download (gpx.js) fetches instead, reuses the existing
redirectIfUnauthorized 401 check, and only then turns the response
into a blob and clicks a throwaway <a download> to trigger the save.
Verified in a real browser: an anonymous click on either download
correctly redirects through /login to the real OAuth login page
instead of showing raw JSON.
ONLINE_STYLE_PATCH mirrors OFFLINE_THEME's color choices onto
OpenFreeMap Liberty's OpenMapTiles layer ids (a separate hand-built
table -- the two schemas share no layer names). loadOnlineStyle
fetches Liberty's style.json once, patches it in place, and the
result replaces the plain URL everywhere ONLINE_STYLE is used --
MapLibre accepts a style object anywhere it accepts a style URL, so
applyStyle needed no change.
Runs before the map (and the rest of the page) is constructed, so
there's no flash of Liberty's own colors first -- but with a 3s
timeout and a catch-all fallback to the plain URL, since this app is
meant to keep working offline in the field and blocking startup on a
remote style server would undermine that.
Verified in a real browser: online map now visually matches the
offline theme (cream ground, brown/khaki land cover, green parks,
muted roads), no console errors, Online/Offline toggle still works.
The measure panel's mode buttons sit inside a maplibregl-ctrl-group
container (for the ruler button's own 29x29 sizing) — its global
"button" rule reaches them too, clipping "Distance"/"Area" into a
fixed 29px square with zero padding instead of sizing to fit the
text. Fixed by upping specificity (".measure-panel .measure-panel__mode")
rather than fighting the cascade with !important.
In distance mode, each segment between two clicked points now shows
its own length at its midpoint, not just the running total in the
panel — a symbol layer keyed off the existing source, filtered apart
from the vertex circles by a new "role" property.
Verified both in a real browser (panel no longer overlaps; two
638m segments rendered and labeled correctly summing to the panel's
1.28km total).
GpxStore#save converts eagerly, so "cached" is true for nearly every
file in the tree already — badging that was just noise. Flipped to
PENDING_ICON, shown only for the rare case: a .gpx/.fit dropped
straight into data/gpx/ on the filesystem, bypassing the app, and not
yet opened.
Also add an explicit download button next to delete (DOWNLOAD_ICON) —
the file name itself has been a download link since the last commit,
but isn't obviously clickable at a glance.
Once a .gpx/.fit is cached, the tree shows it under its converted name
("ride.gpx" -> "ride.gpx.geojson", matching the real cache file next
to it on disk) instead of still reading as un-converted -- the
GeoJSON is what's actually on the map from that point on, and the
original is never deleted, just not re-shown as itself. The name is
now a real link (GET /api/gpx/file/<path>?download=1) instead of
plain text, the only way to get that converted form out of the app.
Verified in a real browser that an <a> nested in a <label> claims its
own click rather than the label forwarding it to the checkbox, so
downloading and toggling visibility don't fight over the same click.
A .geojson file needs no GPX/FIT conversion — it's already the shape
gpx.js expects — so GpxStore#geojson now just reads and returns it
as-is, no cache sidecar. save/tree/the upload dropzone and its zip
handling all pick it up through the existing TRACK_EXTENSIONS list.
Also fixes a bug the new "cached" tree icon would have shipped with:
a GPX/FIT's own .geojson cache sidecar (e.g. "ride.gpx.geojson") also
matches the new .geojson branch in list_children, which would have
listed it as a second, phantom tree entry — excluded explicitly, with
a regression test.
GpxStore#tree now reports a "cached" flag per file (reusing the same
mtime freshness check geojson() already does); the file tree shows a
small curly-braces icon next to any file whose conversion is already
on disk, so it's visible which files will toggle on instantly versus
which still need to parse.
A newer p4n2gpx run (Ruby rewrite, ~/test/mapping/p4n2gpx) started
emitting one plain GPX <link href> per park4night listing photo,
which this app parsed but never displayed. Gpx.waypoint_photos
collects them into a photos property; the waypoint popup renders them
as a row of thumbnails above the description, each opening full-size
in a new tab. Absent on older exports and plain GPS waypoints, so the
row just doesn't render for those.
GpxStore#save now calls geojson() itself right after writing the
file, so the (potentially tens-of-seconds, for a large POI export)
conversion cost is paid during the upload — covered by the frontend's
existing "Uploading…" status — instead of on whichever checkbox
toggle happens to be first, which otherwise reads as a silent hang
indistinguishable from broken. An unparseable file still saves fine;
the error just surfaces on first view same as before.
Home now fitBounds()s a country-wide bbox instead of resetting to
CENTER/ZOOM's Bratislava point, since Home resetting into the same
city you'd already be looking at wasn't useful.
The overview map is NPS's own overview plugin (npmap5-plugins, still
on an unmerged branch), vendored and converted from its UMD dist
build to a plain ES module. Patched one MapLibre API break found while
verifying it: the plugin reads map.transform (public in the MapLibre
~2.4 it targets) to relax the inset's polar pan limit; MapLibre 6 (this
app's vendored version) no longer exposes .transform at all, so it
threw on load. Guarded to skip that cosmetic adjustment when absent
instead.
A several-thousand-waypoint park4night area export re-parses the
whole file with REXML on every toggle — ~2.4s for a 5k-waypoint/3.6MB
sample, so tens of seconds for a 50k-waypoint one, likely the actual
cause behind a "Could not load" report on a 41.5MB export. MapLibre
already clusters the waypoint layer client-side, so the bottleneck was
never rendering — cache the GeoJSON conversion alongside the source
file (invalidated by mtime) instead of a bigger bbox/tiling rework.
A ruler button in the top-left control stack, matching NPS's own
Area/Distance ruler. Click to add vertices; distance sums haversine
segment lengths, area uses the spherical-excess polygon formula
(same one turf.js's area() uses) rather than a flat shoelace, since a
flat approximation drifts at park-boundary scale. SI units only
(m/km, m²/km²) — no imperial toggle.
Matches the small numbered square NPS keeps next to its own zoom
buttons — a passive display, not a button, so it's plain markup with
no click behavior.
GeolocateControl was alone at bottom-right while Home/zoom/base-map were
grouped top-left — split across two corners instead of one stack like
the National Park Service's own map controls. Moved it in, between zoom
and the base-map button.
NPMap4 has infoboxControl: a generic "click anything, show every raw
property" debug view, kept separate from a dataset's own curated popup.
Not portable code (Leaflet), but the concept is: Alt+click anywhere now
opens a popup listing every rendered feature at that point via
map.queryRenderedFeatures — base map OSM tags included, not just this
app's own GPX/FIT/radar layers — with a raw key/value table per feature.
No dedicated UI for it on purpose: a keyboard-modified click needs no
button, toggle state, or cursor change of its own, and it's fine for it to
coexist with a layer's own click handler firing on the same click (e.g. a
GPX waypoint's normal popup) since this is a power-user/debugging
affordance, not a replacement for those. Feature properties can come from
an uploaded GPX/FIT file (untrusted input) via this app's own layers, so
every value is escaped the same way gpx.js's popups already are.
A CLI tool for pulling ArcGIS/ESRI REST data into GeoJSON/GeoPackage -
different category from the browser plugins/icons elsewhere in this doc.
No current use case (we have no ArcGIS data source to query) and,
unlike everything else surveyed so far, no LICENSE file at all.
v4 is one mature, accumulated framework; npmap5 is a still-in-progress
rewrite split into small, independently-versioned (mostly pre-1.0)
plugins, several of which aren't merged yet. Worth knowing so we don't
assume v5 already has everything v4 does.
Home was alone at top-left and zoom (NavigationControl) was down at
bottom-right with GeolocateControl — split across two corners instead of
grouped together like the National Park Service's own map controls
(https://www.nps.gov/, screenshot: Home, zoom in/out, base map, all
stacked in one place).
Moved NavigationControl up to join Home (showCompass: false — this map
never rotates, so a bearing-reset button would be dead weight), and added
MapLayersButton, a small map-corner control that's a second entry point
into the same Online/Offline panel LayerToggleControl already manages in
the page's sidebar (both go through the same toggle() now, so there's one
source of truth for whether the panel's open). GeolocateControl stays
alone at bottom-right — it's a different action (go to my actual location)
from Home (reset to the app's default view), which was worth clarifying
since the two are easy to conflate.
The previous pass applied Park Tiles' real park/protected-area green
(#b3e378) to wood_a/wood_b/scrub_a/scrub_b as well as park_a/park_b —
but in Park Tiles' own Imagery style, that green is specifically the
protected-area/administrative color; actual vegetation there is just the
aerial photo underneath, which this offline style has no fill equivalent
of. Applying it everywhere made the whole map read as green instead of
"brown with green parks popping out", which is the actual character of
their palette. Forests/scrub now render in muted brown/khaki instead,
leaving the vivid green for park_a/park_b alone.
Noted for completeness in docs/nps-resources.adoc — both proprietary/
licensed fonts, unlike the SIL OFL Noto Sans already vendored, so not
adoptable. The editorial style guide is NPS's own writing/terminology
conventions, not a design or code resource, included only since it was
shared alongside the typography notes.
A reference doc (docs/nps-resources.adoc) covering everything looked at
while pulling icons/controls/theme colors from NPS's mapping ecosystem:
what's actually in use (symbol-library, home-button, the Park Tiles-inspired
offline palette), what was considered and deliberately skipped (svg-icons,
markdown-handlebars, the unmerged overview/carto/wms branches) and why, the
Park Tiles cartography research (Harpers Ferry Center lineage, the four
style variants, why the actual tile service is off-limits via Mapbox
Atlas licensing, the public style JSON and Mapbox Studio style IDs found),
and the still-open upstream PR revising artwork for three icons we use.
Worth having as a knowledge base rather than re-deriving any of this from
scratch next time — NPS's tooling has repeatedly turned out to be a better
source than building something from nothing.
Extended OFFLINE_THEME's color-role overrides to lean into the warm,
Harpers-Ferry-Center-derived look of the National Park Service's own "Park
Tiles" cartography, rather than the cooler default protomaps-themes-base
palette. A handful of values (background, the park/protected-area green, the
two road-importance tiers, the park-boundary line color) are the genuine
paint colors pulled from a public Park Tiles style JSON — the "Imagery"
variant is the only one of NPS's four styles served from a plain,
unauthenticated URL; "Standard"/"Slate"/"Light" resolve through Mapbox
Studio's private API with an access token, which isn't something to
piggyback on even though it's technically visible in their own JS bundle.
The rest of the palette (forest/scrub greens, water, sand, buildings) is
reasoned to match that same family rather than sourced.
Recreating the color choices is fine — that's not the kind of thing a data
license restricts. What stays off-limits is their actual tile service: its
OSM/terrain layers are licensed through Mapbox Atlas, a commercial,
non-sublicensable data service scoped to NPS's own use.
Two things pulled in from github.com/nationalparkservice/npmap5-plugins:
- A "Home" button (public/vendor/npmap-home-button/, NPS's own MapLibre
control) pinned to the app's CENTER/ZOOM, sitting top-left on the map. Now
that the URL hash permalink can open the map at any view, "current view"
and "the default view" aren't the same thing anymore, so a quick way back
to the default is worth having.
- npmap5-plugins bundles a newer, actively-maintained version of the same
NPS symbol-library already used for POI_ICONS (confirmed by its
maintainer to be the designated replacement for the older standalone
repo: nationalparkservice/npmap5-plugins#4), with a much larger icon
vocabulary and a simpler CC0 (public domain) license. All 25 icons
already in use came back byte-identical from the new source, so this
just repoints their attribution — but it also has icons the old repo
didn't: `forest` (POI_ICONS) is now the official "wilderness" pictogram
instead of hand-drawn, and three of the five previously icon-less
Services/Activities tags (wifi, mobile data coverage, winter/snow
camping) now have real icons too. Only bakery and vehicle wash still
have no NPS equivalent.
The Online/Offline sidebar icon was always shown, even on an install with
no data/offline.pmtiles — offering a choice that can't actually work.
LayerToggleControl#remove() removes the icon (and hides its panel) once the
existing /api/offline-tiles check confirms there's nothing to switch to; a
transient failure of that same-origin check leaves the switch as is rather
than guessing, since that's the request itself failing, not evidence
offline tiles are unavailable.
The gray color from the previous commit landed on .gpx-popup__meta (the
whole row, label and value both) instead of just .gpx-popup__meta-label —
only the label was supposed to be muted, the value should read as normal
text same as the description above it.
While in there: Services/Activities were showing park4night's own raw,
comma-separated codes ("animaux, point_eau, wc_public"), which don't mean
anything to a reader on their own. PARK4NIGHT_TAGS (icons.js) maps the
codes p4n2gpx's exports actually use to a human label and, where the
National Park Service's icon set (already used for POI_ICONS) has a
reasonable match, a small icon — formatTags (gpx.js) renders each as an
icon+label tag instead of raw text. A code not in that table just falls
back to its own raw text, so nothing is ever hidden.
Panning/zooming now updates #map=<zoom>/<lat>/<lon> in the URL, and loading
a URL with one already opens at that view instead of the default center —
so copying the URL bar shares the exact spot someone's looking at, and a
page reload doesn't lose the current view either.
This is MapLibre GL JS's own built-in Hash control (hash: "map" on the Map
constructor) — it already does exactly this, replaceState and all, no
custom code needed.
Secondary metadata, not something worth visually competing with the actual
description — dropped the bold label weight and colored the whole meta
block the same muted gray already used for category/time elsewhere in the
popup.
A park4night <desc> packs a free-text narrative and several lines of
structured data (address, rating, services, activities, the listing URL)
into one field, separated only by newlines. Rendered as a single paragraph
it read as a wall of run-together text — no visual hierarchy between the
actual description and what's really key-value metadata.
formatDescription now recognizes "Label: value" lines (Rating/Services/
Activities) and bare-URL lines, and gives each its own row instead of
burying them in the narrative paragraph: a bordered meta block with bold
labels, and an isolated link line. Anything that doesn't match — the real
narrative text, or a plain GPX file's <desc> with no such structure — stays
exactly as it rendered before, so this only adds structure, never removes
or reorders text.
The badge and category glyphs were registered as SDF images (a single alpha
shape MapLibre tints at render time via icon-color) purely so one shared
sprite could be recolored per category. But SDF rendering assumes the
source actually encodes a distance field — a smooth multi-pixel gradient at
every edge — and these were just normally antialiased rasters (a sharp ~1px
transition). MapLibre's SDF antialiasing pass softened every edge as a
result, visibly blurrier than the base map's own icons, worst on the small
category glyph.
The glyph color never varies (always white), so it never needed runtime
tinting at all: _registerCategoryGlyphs now bakes #fff directly into the
raster and registers one plain image per category. The badge color does
vary (arbitrary per-file hex), but a real POI export only has a handful of
distinct category colors, so _registerBadgeColors rasterizes one sharp
image per distinct color actually present in the points being shown,
cached by color, instead of one shared image tinted at render time.
The National Park Service frames its own map symbols (the icons already
vendored in for POI categories) in a rounded square, not a pin — pairing a
pin shape with NPS-style pictograms looked like two mismatched icon
conventions. Switched the marker to a rounded square to match.
This also simplifies the placement code: a pin has to anchor at its tip, so
the category glyph on top needed an offset computed from the pin/glyph
geometry to land in the pin's head. A square has no tip — both the badge and
the glyph layer just center directly on the coordinate with no offset math
at all.
Colored circles for waypoints looked too similar to a base map style's own
small circular POI icons, so uploaded POI data blended into the map instead
of standing out. Switched the marker shape to a pin/teardrop (reusing the
existing WAYPOINT_ICON path, filled instead of stroked) — no base map style
uses that shape for its own points, so it reads immediately as "your data".
Anchored at its tip, the standard map-pin convention, rather than centered
on the coordinate.
Also swapped the hand-drawn category glyphs for the National Park Service's
own map symbols (github.com/nationalparkservice/symbol-library, BSD
licensed, forked from Mapbox's Maki) for the six categories it covers —
professional, purpose-built pictograms beat ad-hoc "fuel pump"/"picnic
table" line art. `forest` has no NPS equivalent and stays hand-drawn.
The glyph now sits in the pin's head rather than centered on a flat dot,
which needed real geometry (both layers anchor to the same point, so the
offset is the gap between where the pin's head-center ends up and where the
glyph's own visual center already sits) rather than the old anchor="center"
zero-offset placement.
A park4night/OsmAnd POI export (e.g. a p4n2gpx area export) tags every
waypoint with a category and a per-category color/icon in its <extensions>
block, all of which was previously thrown away — every waypoint rendered as
the same plain red dot, unreadable once a file has a few thousand of them.
Maptool::Gpx now captures category/color/icon (waypoint_style), matched by
namespace URI rather than local element name: a p4n2gpx file carries both
<osmand:icon> and <locus:icon> side by side, and REXML strips prefixes from
Element#name, so both report as just "icon" — matching on that alone was
silently picking the (useless) Locus drawable path instead of the OsmAnd
category key. Regression test added for this.
On the map: waypoints are colored by category (falling back to the plain
track color for anything uncategorized), a small hand-drawn icon set
(public/js/icons.js's POI_ICONS, rasterized to SDF sprites at runtime) draws
a glyph on top for the categories it covers, the points source is clustered
so a dense export collapses into count bubbles instead of a solid blob, and
a legend in the side panel lists whichever categories are currently visible.
Splitting each file's GeoJSON into a lines source and a points source
(rather than one mixed source with a geometry-type filter per layer) was
needed for clustering, since MapLibre's cluster support only understands a
source made entirely of points.
GPX tracks were silently failing to render for large real-world files:
point_extensions used REXML's "extensions//*" XPath, and the // descendant
axis costs low-single-digit milliseconds per call regardless of subtree
size. Run once per point, that's negligible on small files but ~30s on a
real 8000-point track — long enough to hit a proxy timeout in production,
so the file just never appeared with no error. Replaced with plain
recursive child traversal (~40x faster on the same file, confirmed
29.9s -> 3.16s); test_parsing_many_points_with_extensions_stays_fast
guards against reintroducing it.
Also adds support for <rte> routes (not just <trk> tracks and <wpt>
waypoints), sharing the track/route conversion logic via a new
line_feature helper. Routes get their own "route" kind and a dashed
ROUTE_ICON in the file tree so the type is visible at a glance.
_addLayers handed MapLibre a URL and let it fetch the GeoJSON
internally — a 422 (a file REXML/Fit can't parse) just meant the
layer never appeared, with nothing telling the user why. It now
fetches the file itself first, alerts with the actual error and
un-checks the box on failure.
TrackStats.build_profile now also computes elapsed_secs per point
(relative to the first known timestamp), alongside the existing
cumulative distance. Point x-positions stay distance-based either
way — only the shared axis strip's 5 tick labels (and the tooltip)
switch between the two, so toggling is a cheap re-render rather than
rebuilding the charts. The toggle only appears when a track actually
has elapsed-time data to switch to.
Plain HTML overlays, not SVG text: the chart's viewBox scales width
and height independently (preserveAspectRatio="none", for a
responsive width at a fixed pixel height), so text drawn inside that
coordinate space would get stretched by the mismatched factor.
Track properties gain activity_type (FIT: sport enum from the
session/sport message, Garmin's own table; GPX: <trk><type> passed
through as-is) alongside the existing distance/elevation/duration.
Clicking a track line now opens a bottom panel with a chart per
metric the track actually has (elevation always; speed/cadence/heart
rate only when present — FIT's record fields or GPX's device
extensions). TrackStats.build_profile computes cumulative distance
the same way for both formats so they're directly comparable. One
pointermove listener drives a synchronized crosshair across every
chart, a single tooltip listing all series, and a map marker at the
hovered point's position — hovering the chart shows where on the
track that moment was.
`bundle exec rake test` (minitest, a default gem but pinned in
Gemfile for reproducibility) now covers what ad-hoc scripts kept
re-verifying by hand during development: Gpx/Fit/TrackStats parsing,
GpxStore/LiveTrackStore file/TTL logic, LiveTrackClient's payload
parsing, and app.rb's routes end to end via Rack::MockRequest.
MAPTOOL_DATA_DIR (new, app.rb) lets the suite point GPX_STORE/
LIVETRACK_STORE at a throwaway directory instead of real data/ —
without it every run would leave uploaded test files behind. Each
lib file's old `if __FILE__ == $0` self-check is now redundant with
real tests covering the same ground, so removed rather than kept as
a second copy of the same assertions. Wired into ci.yml so it isn't
just a local convenience.
Maptool::TrackStats computes these once, shared by Gpx and Fit so
both track feature shapes carry the same properties regardless of
source format. Hovering a track line (not clicking — these describe
the whole line, not one point) shows them in a popup, same mechanism
GPX waypoints already use.
Maptool::Fit reads Garmin's binary FIT format directly rather than
adding a gem — the one Ruby option depends on activesupport, unmaintained
since 2022, for a single-purpose binary parser. Only "record" messages
are read (position + altitude; everything else is skipped by byte
length, never interpreted); field numbers and scale/offset are
Garmin's own (fit-python-sdk's profile.py), and the framing plus all
extracted values were checked byte-for-byte against 5 real device
files from python-fitparse's test suite before this shipped.
A .zip upload (Garmin's bulk/FIT export format) is expanded into its
.gpx/.fit entries via rubyzip — that one's an actual dependency, since
ZIP's own format isn't the kind of narrow, single-purpose parsing FIT
above is.
Opening a session that's still recording (active in its summary) now
re-fetches every 30s and updates the map in place, instead of freezing
at whatever the track looked like when first opened. Stops on its own
once the session ends, or immediately if switched away from.
A session's own end timestamp is absent until the activity is
actually stopped, so its presence/absence is the status signal — no
new data needed, just reading what was already there. Shown as a
badge next to the session in the list and in its map hover popup.
The track endpoint now merges the session summary into the marker's
GeoJSON properties (already computed for storage, just wasn't passed
through), and the marker gets the same hover-popup treatment GPX
points already have.
Fetching a session's track now also grabs its summary (name, activity
type, distance, duration) from the same page and stores it — sessions
the panel has actually shown replace their raw UUID with that summary,
and are kept indefinitely regardless of age. A session nobody ever
opened (no summary) is more likely test/dead data than something worth
keeping, so it's dropped automatically after 48h.
Clicking a LiveTrack session now fetches and draws its track (line +
latest position). Garmin's own API 403s any plain server-side request
(Cloudflare bot protection), so LiveTrackClient fetches the session
page instead and pulls the track out of the server-rendered data
embedded in it. The webhook now captures the session's token too,
since the id alone isn't enough to fetch anything.
Browsers (and a caching proxy in front of production) were holding
onto /js and /css for hours, so a deploy could go unnoticed by an
already-open tab. A boot-time version query string on the entry
script/stylesheet, plus an import map remapping map.js's internal
module imports to the same versioned URLs, fixes that without editing
every JS file's import statements.
A Gmail-watching Apps Script (documented in docs/deployment.adoc) posts
the session ID from each LiveTrack email to a new webhook, which stores
it in data/livetrack.json; a new sidebar panel lists what's been
received.
Runs renovate/renovate against this Gitea instance on the native
deploy runner, opening PRs for outdated Bundler dependencies instead
of relying on manually noticing (as happened with puma/mustermann
earlier).
MAPTOOL_ALLOWED_GITEA_USER now accepts a comma-separated list of
Gitea usernames instead of just one, for when more than one person
needs upload/delete/move access.
Upload/delete/move/create-folder now require logging in via Gitea
OAuth2 (only MAPTOOL_ALLOWED_GITEA_USER is granted write access);
reading stays public. Deploy secrets (session secret, OAuth2 client
credentials, allowed user) are written to .env from Gitea Actions
repo secrets on every deploy instead of living on the server by hand.
Also switch the container's bind address to "[::]" (dual-stack) so
the app is reachable over IPv6, not just IPv4.
The Docker-based runner's own container is too minimal for host-mode
jobs (no rsync, docker CLI, or Node.js) and has no reliable path to
the host filesystem or systemctl otherwise. Register a separate,
native runner (label deploy:host) just for deployment, and point the
deploy job at it.
Host-mode jobs run directly in the runner container instead of a
spawned per-job container, so JS actions like actions/checkout@v4
have no Node.js runtime to execute with. Replace it with a plain git
fetch/checkout in the deploy job.
Containerize the app (Dockerfile, docker-compose.yaml) and add a CI
deploy job that syncs the checkout to /srv/docker/maptool and runs
docker compose directly, using the runner's new "host" execution
label to reach the host's Docker daemon and filesystem.
Omitting ruby-version entirely triggers its actual auto-detection from
.ruby-version — "file" was a mistaken guess and failed with "Unknown
engine file".
Ruby syntax check, a require_relative "app" smoke load, and JS syntax
check on every push/PR. No test suite exists yet, so these are cheap
syntax/load checks rather than behavioral tests.
Remove osmand-style.js (the per-layer color-patching approach) and its
docs entirely — back to the plain OpenFreeMap "liberty" style online,
and protomaps-themes-base's stock theme offline as the base.
Offline maps previously had no text labels at all: the
protomaps-themes-base layers() call never passed a lang option, which
is required to include its label layers. Fixed, backed by vendored
Noto Sans Regular/Italic/Medium glyph PBFs (ranges 0-255 and 256-511
only — Basic Latin + Latin-1 Supplement + Latin Extended-A, enough for
accented Central European place names; other scripts won't render
offline). Verified against the actual offline.pmtiles vector layer
schema that the name/name:en/script fields this expects are present.
Also nudge a handful of OFFLINE_THEME color roles (background, water,
wood, buildings, the two road-importance tiers) towards the online
style's actual palette (sampled from its style JSON) so switching
Online/Offline doesn't look like two different apps — deliberately
partial, not the removed full recolor.
Each tree entry now shows a small waypoint/track icon based on a
cheap substring check for <wpt>/<trk> (Maptool::Gpx.kinds) rather than
a full REXML parse, since re-parsing a several-thousand-waypoint file
on every tree fetch would make the panel noticeably slower to open.
Also fix a real drop-target bug: .gpx-tree had no flex:1, so a
short/empty tree left blank panel space below it that wasn't part of
the drag & drop target — dropping a file there silently did nothing.
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.
Uploading is now drag & drop (or click to browse) instead of a form;
folders can be created any time, not just during upload; files can be
dragged onto a folder to move them, or dropped elsewhere in the tree
to move back to the top level; files and folders can be deleted. Both
drag sources (an OS file drag and dragging an existing tree entry)
land on the same drop targets, told apart by a custom drag payload
type versus the browser's own "Files" type.
Also sort folders before files in the tree (previously interleaved
alphabetically).
Upload GPX files into a folder hierarchy (traversal-safe path
resolution against the store root), browse them in a tree panel, and
toggle each one's waypoints/tracks on the map. Waypoints render as
clickable points showing name, description and time; bare URLs in the
description are turned into safe links. GPX layers survive switching
between the Online/Offline base map, same as the radar overlay.
Adds rexml as an explicit dependency — it moved to a bundled gem in
this Ruby version and isn't picked up by Bundler otherwise.
Replace Leaflet/raster tiles with MapLibre GL JS: an online OpenFreeMap
style and an offline style built from a local PMTiles archive via
pmtiles.js and protomaps-themes-base (no glyphs/labels offline by
design, see docs/architecture.adoc). Frontend libraries are vendored
locally rather than loaded from a CDN so the app keeps working with no
internet access.
Also add an animated RainViewer precipitation radar overlay (toggle,
play/pause, scrub slider), matching the default time window of the
weather-radar-card Home Assistant integration.