Compare commits
3
Commits
b4f205667b
...
24b6180c03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b6180c03 | ||
|
|
12635a48c3 | ||
|
|
cd92dadf6a |
No files matched your search
@@ -191,6 +191,10 @@ 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.
|
||||
get "/api/gpx/file/*" do
|
||||
begin
|
||||
body = GPX_STORE.geojson(params[:splat].first)
|
||||
@@ -203,6 +207,15 @@ module Maptool
|
||||
end
|
||||
|
||||
content_type :json
|
||||
if params[:download]
|
||||
# Matches the tree's own display name for a cached .gpx/.fit
|
||||
# (public/js/gpx.js's _renderFile) — "ride.gpx" downloads as
|
||||
# "ride.gpx.geojson", not "ride.geojson", so the two never disagree.
|
||||
# A .geojson source is already named right and is left alone.
|
||||
original_name = File.basename(params[:splat].first)
|
||||
download_name = original_name.downcase.end_with?(".geojson") ? original_name : "#{original_name}.geojson"
|
||||
attachment download_name
|
||||
end
|
||||
body
|
||||
end
|
||||
|
||||
|
||||
+48
-4
@@ -172,6 +172,29 @@ from an unmerged upstream branch and patched for a MapLibre API break
|
||||
(it targeted MapLibre ~2.4's public `map.transform`, since removed) —
|
||||
see `docs/third-party-notices.adoc` for the exact patch.
|
||||
|
||||
=== Measuring distance and area
|
||||
|
||||
`MeasureControl` (`public/js/measure.js`) opens a small floating
|
||||
panel next to its ruler button (mirroring NPS's own Area/Distance
|
||||
ruler) with Distance/Area mode buttons, a live result, and Clear —
|
||||
its buttons needed `.measure-panel <selector>` (not just the bare
|
||||
class) to outrank `.maplibregl-ctrl-group button`'s own 29×29px/
|
||||
zero-padding rule, which otherwise clips "Distance"/"Area" into
|
||||
overlapping text (the container is a `maplibregl-ctrl-group` for the
|
||||
ruler button's own sizing, and that rule reaches every button nested
|
||||
inside it, panel included). Clicking the map adds a vertex; distance
|
||||
sums haversine segment lengths, area uses the spherical-excess
|
||||
polygon formula (Chamberlain & Duquette — the same one turf.js's
|
||||
`area()` uses) rather than a flat shoelace, since a flat approximation
|
||||
drifts at the scale a park boundary can reach. In distance mode, each
|
||||
individual segment also gets its own label at its midpoint (a `symbol`
|
||||
layer keyed off a `role: "segment-label"` property, filtered apart
|
||||
from the draggable vertex points, which share the same source but
|
||||
`role: "vertex"`) — the running total in the panel doesn't say how
|
||||
long any one leg was on its own. Area mode has no equivalent yet;
|
||||
a polygon's edges are its perimeter, not a path, so the same treatment
|
||||
may not even read the same way there.
|
||||
|
||||
=== Debug inspector (Alt+click)
|
||||
|
||||
Alt+click anywhere on the map opens a popup listing every rendered
|
||||
@@ -282,10 +305,31 @@ multi-second hang on a checkbox click reads as broken in a way the
|
||||
same wait during an upload doesn't. A file that fails to parse still
|
||||
saves fine either way; the error just surfaces on first view instead.
|
||||
A `.geojson` upload skips this path entirely — `geojson` reads and
|
||||
returns it directly, no cache file, no `Gpx`/`Fit` involved; the file
|
||||
tree's cached-file icon (`GEOJSON_ICON`, `public/js/icons.js`) always
|
||||
shows for one, since there's no conversion step it could ever be
|
||||
waiting on. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
returns it directly, no cache file, no `Gpx`/`Fit` involved.
|
||||
`GpxStore#save` converting eagerly means `cached` is `true` for
|
||||
almost everything in the tree — the one exception is a `.gpx`/`.fit`
|
||||
dropped straight into `data/gpx/` on the filesystem, bypassing the
|
||||
app, and not yet opened. Badging the common case would just be noise,
|
||||
so the file tree's icon (`PENDING_ICON`, `public/js/icons.js`) marks
|
||||
that exception instead of the (near-universal) ready state.
|
||||
+
|
||||
Once a `.gpx`/`.fit` is cached, the tree shows it under its converted
|
||||
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>`
|
||||
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>`
|
||||
|
||||
+36
-2
@@ -360,6 +360,12 @@ html, body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gpx-tree__file-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gpx-tree__kind-icon {
|
||||
@@ -373,6 +379,22 @@ html, body {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.gpx-tree__download {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
color: #999;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.gpx-tree__download svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.gpx-tree__download:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.gpx-tree__delete {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
@@ -515,7 +537,16 @@ html, body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.measure-panel__mode {
|
||||
/* ".measure-panel " prefix (not just ".measure-panel__mode") to outrank
|
||||
".maplibregl-ctrl-group button" (higher specificity, since these buttons
|
||||
sit inside a maplibregl-ctrl-group container for the ruler icon's own
|
||||
sake) — its fixed 29x29 square + zero padding otherwise wins, clipping
|
||||
"Distance"/"Area" into overlapping text instead of sizing to fit it. */
|
||||
.measure-panel .measure-panel__mode {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
@@ -540,7 +571,10 @@ html, body {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.measure-panel__clear {
|
||||
.measure-panel .measure-panel__clear {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
|
||||
+35
-8
@@ -6,7 +6,8 @@ import {
|
||||
WAYPOINT_ICON,
|
||||
TRACK_ICON,
|
||||
ROUTE_ICON,
|
||||
GEOJSON_ICON,
|
||||
PENDING_ICON,
|
||||
DOWNLOAD_ICON,
|
||||
MAP_MARKER_ICON,
|
||||
POI_ICONS,
|
||||
PARK4NIGHT_TAGS,
|
||||
@@ -271,15 +272,29 @@ export class GpxPanelControl {
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = this._visible.has(file.path);
|
||||
checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox));
|
||||
const name = document.createElement("span");
|
||||
// Once a .gpx/.fit is cached, its converted GeoJSON is the thing that's
|
||||
// actually shown on the map from here on (see GpxStore#geojson) — the
|
||||
// name reflects that (matching the real "<path>.geojson" cache file on
|
||||
// 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.
|
||||
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.textContent = file.name;
|
||||
name.title = file.name;
|
||||
name.href = `${gpxFileUrl(file.path)}?download=1`;
|
||||
name.textContent = displayName;
|
||||
name.title = `Download ${displayName}`;
|
||||
label.appendChild(checkbox);
|
||||
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
|
||||
if (file.cached) label.appendChild(this._cachedIcon());
|
||||
if (!file.cached) label.appendChild(this._pendingIcon());
|
||||
label.appendChild(name);
|
||||
row.appendChild(label);
|
||||
row.appendChild(this._downloadButton(file));
|
||||
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
|
||||
|
||||
return row;
|
||||
@@ -301,14 +316,26 @@ export class GpxPanelControl {
|
||||
return icon;
|
||||
}
|
||||
|
||||
_cachedIcon() {
|
||||
_pendingIcon() {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "gpx-tree__kind-icon";
|
||||
icon.innerHTML = GEOJSON_ICON;
|
||||
icon.title = "Ready to view instantly (GeoJSON cached)";
|
||||
icon.innerHTML = PENDING_ICON;
|
||||
icon.title = "Not yet converted — dropped straight into data/gpx/, not uploaded through the app";
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Same destination as clicking the file's name (gpxFileUrl + ?download=1)
|
||||
// — a second, more obviously clickable entry point for the same action.
|
||||
_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.innerHTML = DOWNLOAD_ICON;
|
||||
return link;
|
||||
}
|
||||
|
||||
_deleteButton(onClick) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
|
||||
+19
-7
@@ -54,14 +54,26 @@ export const WAYPOINT_ICON = `
|
||||
|
||||
export const TRACK_ICON = GPX_ICON;
|
||||
|
||||
// Shown next to a file whose GeoJSON conversion is already cached on disk
|
||||
// (GpxStore#geojson) — ready to view instantly instead of paying REXML's
|
||||
// parse cost on click. Curly braces read as "structured data ready", the
|
||||
// same shorthand most editors/tools use for JSON.
|
||||
export const GEOJSON_ICON = `
|
||||
// Shown next to a file whose GeoJSON conversion is NOT yet cached on disk
|
||||
// (GpxStore#geojson) — the uncommon case, since GpxStore#save converts
|
||||
// eagerly at upload time; this only happens for a .gpx/.fit dropped
|
||||
// straight into data/gpx/ on the filesystem, bypassing the app, and not
|
||||
// yet opened. Everything uploaded normally is cached almost immediately,
|
||||
// so badging the common case would just be noise — this badges the
|
||||
// exception instead.
|
||||
export const PENDING_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 4c-1.5 0-2 .8-2 2v3c0 1-.5 1.5-1.5 1.5 1 0 1.5.5 1.5 1.5v3c0 1.2.5 2 2 2"/>
|
||||
<path d="M15 4c1.5 0 2 .8 2 2v3c0 1 .5 1.5 1.5 1.5-1 0-1.5.5-1.5 1.5v3c0 1.2-.5 2-2 2"/>
|
||||
<circle cx="12" cy="12" r="8"/>
|
||||
<path d="M12 8v4l3 2"/>
|
||||
</svg>`;
|
||||
|
||||
// A small download button next to each tree file's delete button — the
|
||||
// name link (see gpx.js's _renderFile) does the same thing, but isn't
|
||||
// obviously clickable at a glance, so this is the discoverable version.
|
||||
export const DOWNLOAD_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4"/>
|
||||
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>`;
|
||||
|
||||
// Same shape as TRACK_ICON, dashed — a route is planned rather than
|
||||
|
||||
+46
-4
@@ -4,6 +4,7 @@ const SOURCE_ID = "measure";
|
||||
const LINE_LAYER_ID = "measure-line";
|
||||
const FILL_LAYER_ID = "measure-fill";
|
||||
const POINT_LAYER_ID = "measure-points";
|
||||
const SEGMENT_LABEL_LAYER_ID = "measure-segment-labels";
|
||||
const EARTH_RADIUS_M = 6378137; // WGS84 equatorial radius, matches turf.js's area formula
|
||||
|
||||
// A distance/area measuring tool, matching the ruler button in NPS's own
|
||||
@@ -118,15 +119,31 @@ export class MeasureControl {
|
||||
id: POINT_LAYER_ID,
|
||||
type: "circle",
|
||||
source: SOURCE_ID,
|
||||
filter: ["==", "$type", "Point"],
|
||||
filter: ["all", ["==", "$type", "Point"], ["==", ["get", "role"], "vertex"]],
|
||||
paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 },
|
||||
});
|
||||
// Distance mode only (see _render) — each segment's own length, not
|
||||
// just the running total, matching how a typical measure tool
|
||||
// (Google Maps, QGIS) labels every leg of a multi-point line.
|
||||
this._map.addLayer({
|
||||
id: SEGMENT_LABEL_LAYER_ID,
|
||||
type: "symbol",
|
||||
source: SOURCE_ID,
|
||||
filter: ["==", ["get", "role"], "segment-label"],
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11,
|
||||
"text-allow-overlap": true,
|
||||
},
|
||||
paint: { "text-color": "#1e5fbf", "text-halo-color": "#fff", "text-halo-width": 1.5 },
|
||||
});
|
||||
|
||||
this._render();
|
||||
}
|
||||
|
||||
_removeLayers() {
|
||||
[FILL_LAYER_ID, LINE_LAYER_ID, POINT_LAYER_ID].forEach((id) => {
|
||||
[FILL_LAYER_ID, LINE_LAYER_ID, POINT_LAYER_ID, SEGMENT_LABEL_LAYER_ID].forEach((id) => {
|
||||
if (this._map.getLayer(id)) this._map.removeLayer(id);
|
||||
});
|
||||
if (this._map.getSource(SOURCE_ID)) this._map.removeSource(SOURCE_ID);
|
||||
@@ -136,13 +153,31 @@ export class MeasureControl {
|
||||
const source = this._map.getSource(SOURCE_ID);
|
||||
if (!source) return;
|
||||
|
||||
const pointFeatures = this._points.map((coord) => ({ type: "Feature", geometry: { type: "Point", coordinates: coord } }));
|
||||
const pointFeatures = this._points.map((coord) => ({
|
||||
type: "Feature",
|
||||
properties: { role: "vertex" },
|
||||
geometry: { type: "Point", coordinates: coord },
|
||||
}));
|
||||
const shapeFeature =
|
||||
this._mode === "area"
|
||||
? this._points.length >= 3 && { type: "Feature", geometry: { type: "Polygon", coordinates: [[...this._points, this._points[0]]] } }
|
||||
: this._points.length >= 2 && { type: "Feature", geometry: { type: "LineString", coordinates: this._points } };
|
||||
// Area mode has no equivalent yet — only asked for distance's own
|
||||
// per-leg labels, and a polygon's edges read differently (perimeter
|
||||
// segments, not a path) if this ever extends there.
|
||||
const segmentLabels =
|
||||
this._mode === "distance"
|
||||
? this._points.slice(1).map((point, i) => ({
|
||||
type: "Feature",
|
||||
properties: { role: "segment-label", label: formatDistance(haversine(this._points[i], point)) },
|
||||
geometry: { type: "Point", coordinates: midpoint(this._points[i], point) },
|
||||
}))
|
||||
: [];
|
||||
|
||||
source.setData({ type: "FeatureCollection", features: shapeFeature ? [shapeFeature, ...pointFeatures] : pointFeatures });
|
||||
source.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [...(shapeFeature ? [shapeFeature] : []), ...pointFeatures, ...segmentLabels],
|
||||
});
|
||||
|
||||
this._result.textContent = this._mode === "area" ? formatArea(polygonArea(this._points)) : formatDistance(lineLength(this._points));
|
||||
}
|
||||
@@ -166,6 +201,13 @@ function lineLength(points) {
|
||||
return total;
|
||||
}
|
||||
|
||||
// A plain coordinate average, not the geodesic midpoint — this only
|
||||
// positions a text label, and the two differ by nothing worth computing a
|
||||
// great-circle formula for at the segment lengths this tool measures.
|
||||
function midpoint([lng1, lat1], [lng2, lat2]) {
|
||||
return [(lng1 + lng2) / 2, (lat1 + lat2) / 2];
|
||||
}
|
||||
|
||||
// Spherical polygon area (Chamberlain & Duquette, 2007) — the same formula
|
||||
// turf.js's area() uses. A flat shoelace formula would drift noticeably at
|
||||
// the scale a park boundary can reach; this accounts for the sphere.
|
||||
|
||||
@@ -72,6 +72,24 @@ class AppTest < Minitest::Test
|
||||
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
|
||||
end
|
||||
|
||||
def test_download_of_a_converted_gpx_names_it_gpx_geojson
|
||||
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")
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
|
||||
end
|
||||
|
||||
def test_download_of_a_native_geojson_keeps_its_own_name
|
||||
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")
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
||||
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