Compare commits
2
Commits
24b6180c03
...
eca8519152
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca8519152 | ||
|
|
ceaaa0bb92 |
No files matched your search
@@ -191,11 +191,29 @@ module Maptool
|
||||
json paths: paths, tree: GPX_STORE.tree
|
||||
end
|
||||
|
||||
# ?download=1 exports the file's GeoJSON as an attachment instead of
|
||||
# rendering it on the map — the only way to get a .gpx/.fit's converted
|
||||
# form out of the app, since GpxStore#geojson's cache is otherwise an
|
||||
# internal implementation detail with no filename of its own to expose.
|
||||
# ?download=geojson exports the file's GeoJSON as an attachment instead
|
||||
# of rendering it on the map — the only way to get a .gpx/.fit's
|
||||
# converted form out of the app, since GpxStore#geojson's cache is
|
||||
# otherwise an internal implementation detail with no filename of its
|
||||
# own to expose. ?download=original exports the untouched, as-uploaded
|
||||
# file instead (a .geojson source's "original" is that same file, so
|
||||
# the two downloads are identical there — harmless, not special-cased).
|
||||
# Both gated the same as the other write/manage actions below (upload,
|
||||
# move, create folder) — viewing a file on the map stays open to
|
||||
# anyone, exporting it as a file does not.
|
||||
get "/api/gpx/file/*" do
|
||||
require_login! if params[:download]
|
||||
|
||||
if params[:download] == "original"
|
||||
begin
|
||||
path = GPX_STORE.resolve_file(params[:splat].first)
|
||||
rescue GpxStore::InvalidPath
|
||||
halt 400
|
||||
end
|
||||
halt 404 unless File.file?(path)
|
||||
return send_file(path, filename: File.basename(path), disposition: "attachment")
|
||||
end
|
||||
|
||||
begin
|
||||
body = GPX_STORE.geojson(params[:splat].first)
|
||||
rescue GpxStore::InvalidPath
|
||||
|
||||
+52
-13
@@ -121,6 +121,31 @@ No PMTiles file ships with the repository — it is expected to be
|
||||
supplied locally (see `docs/development.adoc`) and stays gitignored
|
||||
because such extracts are typically large binary files.
|
||||
|
||||
The Online map (OpenFreeMap Liberty) gets the same treatment via
|
||||
`ONLINE_STYLE_PATCH`, a separate hand-built table — Liberty is an
|
||||
OpenMapTiles-schema style, with its own layer ids (`landcover_wood`,
|
||||
`road_secondary_tertiary`, `boundary_2`, ...) that don't correspond to
|
||||
protomaps' theme roles, so the two palettes can't share one object,
|
||||
only the same underlying color choices. `loadOnlineStyle`
|
||||
(`public/js/map.js`) fetches Liberty's `style.json` once, patches it
|
||||
in place, and uses the resulting object everywhere `ONLINE_STYLE` used
|
||||
to be a plain URL string — MapLibre accepts a style object anywhere it
|
||||
accepts a style URL, so `applyStyle`'s `map.setStyle(... : ONLINE_STYLE)`
|
||||
needed no change. That fetch runs before the map (and the rest of the
|
||||
page) is constructed — a top-level `await` — so there's no flash of
|
||||
Liberty's own colors before the patch lands; a 3s timeout and a
|
||||
catch-all fall back to the plain URL (letting MapLibre fetch and
|
||||
render it unpatched) rather than block the whole page's startup on a
|
||||
remote style server, since this app is meant to keep working offline
|
||||
in the field, independent of whether the online map itself loads.
|
||||
Only the layers that dominate the map's overall color read are
|
||||
patched (land cover, water, parks, roads, boundaries, buildings) —
|
||||
same deliberately-partial scope as `OFFLINE_THEME`, not a full
|
||||
per-layer recolor. Road casings are the one addition with no
|
||||
protomaps-role equivalent to reuse, since offline roads don't get a
|
||||
separate outline color there; `#c9a66b` is reasoned to fit the family
|
||||
rather than sourced from anywhere real.
|
||||
|
||||
== Map permalink
|
||||
|
||||
The map's `hash: "map"` option (`public/js/map.js`) turns on MapLibre
|
||||
@@ -318,18 +343,29 @@ name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching
|
||||
the real cache file sitting next to it on disk — since the GeoJSON,
|
||||
not the original, is what's actually rendered on the map from that
|
||||
point on. The original is never deleted or re-parsed once cached, just
|
||||
not re-shown as itself. Two ways to get that converted form out of the
|
||||
app as an actual file (`GET /api/gpx/file/<path>?download=1`) — the
|
||||
cache is otherwise an implementation detail with no name of its own to
|
||||
expose: the file name itself is a real link (an `<a>`, not a `<span>`,
|
||||
specifically so clicking it downloads instead of also toggling the
|
||||
row's checkbox — a native `<a>`/`<button>` nested inside a `<label>`
|
||||
claims its own click instead of the label forwarding it to the
|
||||
associated control, so the two behaviors don't fight over the same
|
||||
click), and a dedicated download button sits next to delete for anyone
|
||||
who wouldn't guess the name itself is clickable. A directly-uploaded
|
||||
`.geojson` keeps its own name (nothing to convert) and downloads
|
||||
unchanged either way. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
not re-shown as itself, so both forms need a way out of the app as an
|
||||
actual file: the file name itself (`GET /api/gpx/file/<path>?download=geojson`)
|
||||
is a real link, not a `<span>` — clicking it downloads the GeoJSON
|
||||
rather than also toggling the row's checkbox, since a native
|
||||
`<a>`/`<button>` nested inside a `<label>` claims its own click
|
||||
instead of the label forwarding it to the associated control — and a
|
||||
second button next to delete (`?download=original`) downloads the
|
||||
untouched, as-uploaded file, since the name alone only covers half of
|
||||
what's now downloadable and isn't an obvious click target regardless.
|
||||
A directly-uploaded `.geojson` keeps its own name and both buttons
|
||||
download the same (identical) content — redundant there, not wrong.
|
||||
Both kinds of download require login, same as upload/move/delete/
|
||||
create-folder — `GET /api/gpx/file` itself stays open to anyone
|
||||
(viewing a file on the map isn't a write action), just not
|
||||
`?download=...` on it. Since both links are real `<a href>`s (so
|
||||
right-click/middle-click still do something sensible), a logged-out
|
||||
click can't just be a plain navigation the way delete/upload already
|
||||
aren't — `_download` (`gpx.js`) fetches instead, checks for a 401 the
|
||||
same way `redirectIfUnauthorized` already does for every other write
|
||||
action, and only then turns the response into a blob and clicks a
|
||||
throwaway `<a download>` to trigger the actual save — a plain
|
||||
navigation would otherwise land on a raw JSON error page instead of
|
||||
being sent to log in. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
waypoints become `Point` features (with `name`, `desc` and `time`
|
||||
carried through as properties), each `<trk>` becomes one
|
||||
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
|
||||
@@ -651,7 +687,10 @@ The GPX store's read endpoints (viewing the map, the folder tree,
|
||||
individual files) are public — anyone with the URL can look. Its write
|
||||
endpoints (`POST /api/gpx`, `POST /api/gpx/folder`, `DELETE
|
||||
/api/gpx/file/*`, `DELETE /api/gpx/folder/*`, `POST /api/gpx/move`)
|
||||
require login, guarded by `require_login!` in `app.rb`.
|
||||
require login, guarded by `require_login!` in `app.rb` — and so does
|
||||
exporting a file (`GET /api/gpx/file/*?download=...`), even though
|
||||
plain viewing of that same URL (no `?download`) doesn't; not a write
|
||||
exactly, but treated the same as the others rather than left open.
|
||||
|
||||
Login delegates to the Gitea instance at `git.priesol.net` via OAuth2
|
||||
(`GET /login` redirects to Gitea's authorize endpoint, `GET
|
||||
|
||||
@@ -24,8 +24,8 @@ checking here before searching from zero next time.
|
||||
| MIT (per `package.json`); the whole `npmap5-plugins` repo is separately
|
||||
public domain as a US government work
|
||||
|
||||
| Park Tiles color palette (offline basemap theme)
|
||||
| `public/js/map.js`'s `OFFLINE_THEME`
|
||||
| Park Tiles color palette (offline + online basemap themes)
|
||||
| `public/js/map.js`'s `OFFLINE_THEME` and `ONLINE_STYLE_PATCH`
|
||||
| Colors only — see <<park-tiles>> for why the actual tile service isn't used
|
||||
|
||||
| `overview` (top-right minimap/inset control)
|
||||
|
||||
+52
-11
@@ -278,17 +278,26 @@ export class GpxPanelControl {
|
||||
// disk) rather than still reading as a plain, un-converted .gpx/.fit.
|
||||
// A direct .geojson upload already ends in .geojson, so it's left
|
||||
// alone. It's an <a>, not a <span>: clicking it downloads that GeoJSON
|
||||
// (?download=1) — the only way to get it out of the app, since the
|
||||
// original .gpx/.fit is never deleted but also never re-shown once
|
||||
// converted. An <a> nested in a <label> doesn't also toggle the
|
||||
// checkbox on click (unlike a plain element), so this doesn't fight
|
||||
// the row's own toggle-on-map behavior.
|
||||
// — the only way to get it out of the app, since the original .gpx/.fit
|
||||
// is never deleted but also never re-shown once converted. An <a>
|
||||
// nested in a <label> doesn't also toggle the checkbox on click (unlike
|
||||
// a plain element), so this doesn't fight the row's own
|
||||
// toggle-on-map behavior. href kept pointing at the real endpoint (so
|
||||
// hover/right-click/middle-click still do something sensible) but the
|
||||
// actual click goes through _download, which — like every other write
|
||||
// action in this file — needs to redirect to /login on a 401 rather
|
||||
// than navigate to a raw JSON error page (this endpoint now requires
|
||||
// login the same as upload/move/delete, see app.rb).
|
||||
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
|
||||
const name = document.createElement("a");
|
||||
name.className = "gpx-tree__file-name";
|
||||
name.href = `${gpxFileUrl(file.path)}?download=1`;
|
||||
name.href = `${gpxFileUrl(file.path)}?download=geojson`;
|
||||
name.textContent = displayName;
|
||||
name.title = `Download ${displayName}`;
|
||||
name.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this._download(name.href, displayName);
|
||||
});
|
||||
label.appendChild(checkbox);
|
||||
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
|
||||
if (!file.cached) label.appendChild(this._pendingIcon());
|
||||
@@ -324,18 +333,50 @@ export class GpxPanelControl {
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Same destination as clicking the file's name (gpxFileUrl + ?download=1)
|
||||
// — a second, more obviously clickable entry point for the same action.
|
||||
// The file's name already downloads its GeoJSON (?download=geojson,
|
||||
// matching what's displayed) — this covers the other half: the
|
||||
// untouched, as-uploaded file, which otherwise has no way out of the
|
||||
// app once it stops being shown as itself. For a native .geojson
|
||||
// upload the two downloads are identical (nothing to convert), so this
|
||||
// isn't hidden for those — just redundant, not wrong.
|
||||
_downloadButton(file) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "gpx-tree__download";
|
||||
link.href = `${gpxFileUrl(file.path)}?download=1`;
|
||||
link.title = "Download GeoJSON";
|
||||
link.setAttribute("aria-label", "Download GeoJSON");
|
||||
link.href = `${gpxFileUrl(file.path)}?download=original`;
|
||||
link.title = "Download original file";
|
||||
link.setAttribute("aria-label", "Download original file");
|
||||
link.innerHTML = DOWNLOAD_ICON;
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this._download(link.href, file.name);
|
||||
});
|
||||
return link;
|
||||
}
|
||||
|
||||
// fetch-based rather than a plain navigation so a 401 (not logged in —
|
||||
// both download kinds require login now, see app.rb) redirects to
|
||||
// /login like every other write action in this file
|
||||
// (redirectIfUnauthorized), instead of navigating to a raw JSON error
|
||||
// page. The filename is already known client-side, so there's no need
|
||||
// to parse it back out of a Content-Disposition header.
|
||||
async _download(url, filename) {
|
||||
const response = await fetch(url);
|
||||
if (redirectIfUnauthorized(response)) return;
|
||||
if (!response.ok) {
|
||||
window.alert("Could not download the file.");
|
||||
return;
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
_deleteButton(onClick) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
|
||||
+107
-1
@@ -19,7 +19,113 @@ import * as maplibregl from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
|
||||
const CENTER = [17.1077, 48.1486];
|
||||
const ZOOM = 13;
|
||||
const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
||||
const ONLINE_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
|
||||
|
||||
// Recolors OpenFreeMap Liberty to the same NPS-inspired palette as
|
||||
// OFFLINE_THEME below — warm cream ground, brown/khaki land cover, green
|
||||
// reserved for parks, muted roads/boundaries — mapped onto Liberty's
|
||||
// OpenMapTiles layer ids instead of protomaps' theme roles (the two
|
||||
// schemas don't share layer names, so this is a separate hand-built
|
||||
// table, not a shared object). Only the layers that dominate the map's
|
||||
// overall color read are touched; labels, halos, rail, and minor
|
||||
// overlays (cemetery/hospital/school/pitch/track shading, building
|
||||
// outlines) are left at Liberty's own defaults — same "not a full
|
||||
// recolor" scope as OFFLINE_THEME. Road casings have no protomaps-role
|
||||
// equivalent to reuse (offline roads don't get a separate outline), so
|
||||
// #c9a66b there is reasoned to fit the family rather than sourced from
|
||||
// anywhere real.
|
||||
const ONLINE_STYLE_PATCH = {
|
||||
background: { "background-color": "#f9f6ef" },
|
||||
landcover_wood: { "fill-color": "#c9bd9c" },
|
||||
landcover_grass: { "fill-color": "#d9cfb0" },
|
||||
landcover_sand: { "fill-color": "#f0e4c4" },
|
||||
water: { "fill-color": "#a3cedf" },
|
||||
park: { "fill-color": "#b3e378" },
|
||||
park_outline: { "line-color": "#546619" },
|
||||
building: { "fill-color": "#ded7cc" },
|
||||
boundary_2: { "line-color": "#546619" },
|
||||
boundary_3: { "line-color": "#546619" },
|
||||
|
||||
// Highway (motorway/trunk/primary), major (secondary/tertiary/link),
|
||||
// minor (residential/service/track), pedestrian — each repeated for its
|
||||
// tunnel_/bridge_ counterpart so a road doesn't change color when it
|
||||
// dips into a tunnel or crosses a bridge. Rail is left alone.
|
||||
road_motorway: { "line-color": "#fdefbe" },
|
||||
road_trunk_primary: { "line-color": "#fdefbe" },
|
||||
road_secondary_tertiary: { "line-color": "#fef7dd" },
|
||||
road_link: { "line-color": "#fef7dd" },
|
||||
road_motorway_link: { "line-color": "#fef7dd" },
|
||||
road_minor: { "line-color": "#fffdf8" },
|
||||
road_service_track: { "line-color": "#fffdf8" },
|
||||
road_path_pedestrian: { "line-color": "#e9e2d8" },
|
||||
road_motorway_casing: { "line-color": "#c9a66b" },
|
||||
road_trunk_primary_casing: { "line-color": "#c9a66b" },
|
||||
road_secondary_tertiary_casing: { "line-color": "#c9a66b" },
|
||||
road_link_casing: { "line-color": "#c9a66b" },
|
||||
road_motorway_link_casing: { "line-color": "#c9a66b" },
|
||||
road_minor_casing: { "line-color": "#c9a66b" },
|
||||
road_service_track_casing: { "line-color": "#c9a66b" },
|
||||
|
||||
tunnel_motorway: { "line-color": "#fdefbe" },
|
||||
tunnel_trunk_primary: { "line-color": "#fdefbe" },
|
||||
tunnel_secondary_tertiary: { "line-color": "#fef7dd" },
|
||||
tunnel_link: { "line-color": "#fef7dd" },
|
||||
tunnel_motorway_link: { "line-color": "#fef7dd" },
|
||||
tunnel_minor: { "line-color": "#fffdf8" },
|
||||
tunnel_service_track: { "line-color": "#fffdf8" },
|
||||
tunnel_path_pedestrian: { "line-color": "#e9e2d8" },
|
||||
tunnel_motorway_casing: { "line-color": "#c9a66b" },
|
||||
tunnel_trunk_primary_casing: { "line-color": "#c9a66b" },
|
||||
tunnel_secondary_tertiary_casing: { "line-color": "#c9a66b" },
|
||||
tunnel_link_casing: { "line-color": "#c9a66b" },
|
||||
tunnel_motorway_link_casing: { "line-color": "#c9a66b" },
|
||||
tunnel_street_casing: { "line-color": "#c9a66b" },
|
||||
|
||||
bridge_motorway: { "line-color": "#fdefbe" },
|
||||
bridge_trunk_primary: { "line-color": "#fdefbe" },
|
||||
bridge_secondary_tertiary: { "line-color": "#fef7dd" },
|
||||
bridge_link: { "line-color": "#fef7dd" },
|
||||
bridge_motorway_link: { "line-color": "#fef7dd" },
|
||||
bridge_street: { "line-color": "#fffdf8" },
|
||||
bridge_service_track: { "line-color": "#fffdf8" },
|
||||
bridge_path_pedestrian: { "line-color": "#e9e2d8" },
|
||||
bridge_motorway_casing: { "line-color": "#c9a66b" },
|
||||
bridge_trunk_primary_casing: { "line-color": "#c9a66b" },
|
||||
bridge_secondary_tertiary_casing: { "line-color": "#c9a66b" },
|
||||
bridge_link_casing: { "line-color": "#c9a66b" },
|
||||
bridge_motorway_link_casing: { "line-color": "#c9a66b" },
|
||||
bridge_street_casing: { "line-color": "#c9a66b" },
|
||||
bridge_path_pedestrian_casing: { "line-color": "#c9a66b" },
|
||||
};
|
||||
|
||||
function patchOnlineStyle(style) {
|
||||
for (const layer of style.layers) {
|
||||
const patch = ONLINE_STYLE_PATCH[layer.id];
|
||||
if (patch) Object.assign((layer.paint ??= {}), patch);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
// Fetched (not just referenced by URL) so its colors can be patched before
|
||||
// the map ever sees it — MapLibre accepts a style object anywhere it'd
|
||||
// accept a style URL, so every other use of ONLINE_STYLE (applyStyle's
|
||||
// map.setStyle) needs no change. This runs before the map — and the rest
|
||||
// of the page's setup — is constructed (see the top-level await below), so
|
||||
// it falls back to the plain URL, letting MapLibre fetch and render it
|
||||
// unpatched, both when the fetch fails outright and when it's just slow
|
||||
// (a 3s timeout): this app is meant to work offline in the field, and
|
||||
// blocking the entire page on a remote style server would undermine that
|
||||
// even though this particular fetch is only for the online map.
|
||||
async function loadOnlineStyle() {
|
||||
try {
|
||||
const response = await fetch(ONLINE_STYLE_URL, { signal: AbortSignal.timeout(3000) });
|
||||
return patchOnlineStyle(await response.json());
|
||||
} catch (err) {
|
||||
return ONLINE_STYLE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
const ONLINE_STYLE = await loadOnlineStyle();
|
||||
|
||||
const protocol = new pmtiles.Protocol();
|
||||
addProtocol("pmtiles", protocol.tile);
|
||||
|
||||
+22
-2
@@ -76,7 +76,7 @@ class AppTest < Minitest::Test
|
||||
content = File.read(fixture_path("sample.gpx"))
|
||||
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
||||
|
||||
response = request.get("/api/gpx/file/ride.gpx?download=1")
|
||||
response = request.get("/api/gpx/file/ride.gpx?download=geojson", **logged_in_env)
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
|
||||
end
|
||||
@@ -85,11 +85,31 @@ class AppTest < Minitest::Test
|
||||
content = '{"type":"FeatureCollection","features":[]}'
|
||||
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
||||
|
||||
response = request.get("/api/gpx/file/spots.geojson?download=1")
|
||||
response = request.get("/api/gpx/file/spots.geojson?download=geojson", **logged_in_env)
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
||||
end
|
||||
|
||||
def test_download_of_the_original_file_serves_it_unconverted
|
||||
content = File.read(fixture_path("sample.gpx"))
|
||||
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
||||
|
||||
response = request.get("/api/gpx/file/ride.gpx?download=original", **logged_in_env)
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx"'
|
||||
assert_equal content, response.body
|
||||
end
|
||||
|
||||
def test_download_requires_login
|
||||
content = File.read(fixture_path("sample.gpx"))
|
||||
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
||||
|
||||
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=geojson").status
|
||||
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=original").status
|
||||
# Plain viewing (no ?download) stays open to anyone.
|
||||
assert_equal 200, request.get("/api/gpx/file/ride.gpx").status
|
||||
end
|
||||
|
||||
def test_unparseable_gpx_returns_422
|
||||
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
||||
|
||||
|
||||
Reference in new issue
Block a user