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
|
json paths: paths, tree: GPX_STORE.tree
|
||||||
end
|
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
|
get "/api/gpx/file/*" do
|
||||||
begin
|
begin
|
||||||
body = GPX_STORE.geojson(params[:splat].first)
|
body = GPX_STORE.geojson(params[:splat].first)
|
||||||
@@ -203,6 +207,15 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
content_type :json
|
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
|
body
|
||||||
end
|
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) —
|
(it targeted MapLibre ~2.4's public `map.transform`, since removed) —
|
||||||
see `docs/third-party-notices.adoc` for the exact patch.
|
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)
|
=== Debug inspector (Alt+click)
|
||||||
|
|
||||||
Alt+click anywhere on the map opens a popup listing every rendered
|
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
|
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.
|
saves fine either way; the error just surfaces on first view instead.
|
||||||
A `.geojson` upload skips this path entirely — `geojson` reads and
|
A `.geojson` upload skips this path entirely — `geojson` reads and
|
||||||
returns it directly, no cache file, no `Gpx`/`Fit` involved; the file
|
returns it directly, no cache file, no `Gpx`/`Fit` involved.
|
||||||
tree's cached-file icon (`GEOJSON_ICON`, `public/js/icons.js`) always
|
`GpxStore#save` converting eagerly means `cached` is `true` for
|
||||||
shows for one, since there's no conversion step it could ever be
|
almost everything in the tree — the one exception is a `.gpx`/`.fit`
|
||||||
waiting on. `Gpx` parses the GPX with REXML: `<wpt>`
|
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`
|
waypoints become `Point` features (with `name`, `desc` and `time`
|
||||||
carried through as properties), each `<trk>` becomes one
|
carried through as properties), each `<trk>` becomes one
|
||||||
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
|
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
|
||||||
|
|||||||
+36
-2
@@ -360,6 +360,12 @@ html, body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-tree__file-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gpx-tree__kind-icon {
|
.gpx-tree__kind-icon {
|
||||||
@@ -373,6 +379,22 @@ html, body {
|
|||||||
height: 14px;
|
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 {
|
.gpx-tree__delete {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -515,7 +537,16 @@ html, body {
|
|||||||
display: none;
|
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;
|
padding: 4px 8px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -540,7 +571,10 @@ html, body {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.measure-panel__clear {
|
.measure-panel .measure-panel__clear {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
+35
-8
@@ -6,7 +6,8 @@ import {
|
|||||||
WAYPOINT_ICON,
|
WAYPOINT_ICON,
|
||||||
TRACK_ICON,
|
TRACK_ICON,
|
||||||
ROUTE_ICON,
|
ROUTE_ICON,
|
||||||
GEOJSON_ICON,
|
PENDING_ICON,
|
||||||
|
DOWNLOAD_ICON,
|
||||||
MAP_MARKER_ICON,
|
MAP_MARKER_ICON,
|
||||||
POI_ICONS,
|
POI_ICONS,
|
||||||
PARK4NIGHT_TAGS,
|
PARK4NIGHT_TAGS,
|
||||||
@@ -271,15 +272,29 @@ export class GpxPanelControl {
|
|||||||
checkbox.type = "checkbox";
|
checkbox.type = "checkbox";
|
||||||
checkbox.checked = this._visible.has(file.path);
|
checkbox.checked = this._visible.has(file.path);
|
||||||
checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox));
|
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.className = "gpx-tree__file-name";
|
||||||
name.textContent = file.name;
|
name.href = `${gpxFileUrl(file.path)}?download=1`;
|
||||||
name.title = file.name;
|
name.textContent = displayName;
|
||||||
|
name.title = `Download ${displayName}`;
|
||||||
label.appendChild(checkbox);
|
label.appendChild(checkbox);
|
||||||
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
|
(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);
|
label.appendChild(name);
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
|
row.appendChild(this._downloadButton(file));
|
||||||
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
|
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -301,14 +316,26 @@ export class GpxPanelControl {
|
|||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedIcon() {
|
_pendingIcon() {
|
||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "gpx-tree__kind-icon";
|
icon.className = "gpx-tree__kind-icon";
|
||||||
icon.innerHTML = GEOJSON_ICON;
|
icon.innerHTML = PENDING_ICON;
|
||||||
icon.title = "Ready to view instantly (GeoJSON cached)";
|
icon.title = "Not yet converted — dropped straight into data/gpx/, not uploaded through the app";
|
||||||
return icon;
|
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) {
|
_deleteButton(onClick) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
|
|||||||
+19
-7
@@ -54,14 +54,26 @@ export const WAYPOINT_ICON = `
|
|||||||
|
|
||||||
export const TRACK_ICON = GPX_ICON;
|
export const TRACK_ICON = GPX_ICON;
|
||||||
|
|
||||||
// Shown next to a file whose GeoJSON conversion is already cached on disk
|
// Shown next to a file whose GeoJSON conversion is NOT yet cached on disk
|
||||||
// (GpxStore#geojson) — ready to view instantly instead of paying REXML's
|
// (GpxStore#geojson) — the uncommon case, since GpxStore#save converts
|
||||||
// parse cost on click. Curly braces read as "structured data ready", the
|
// eagerly at upload time; this only happens for a .gpx/.fit dropped
|
||||||
// same shorthand most editors/tools use for JSON.
|
// straight into data/gpx/ on the filesystem, bypassing the app, and not
|
||||||
export const GEOJSON_ICON = `
|
// 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">
|
<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"/>
|
<circle cx="12" cy="12" r="8"/>
|
||||||
<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"/>
|
<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>`;
|
</svg>`;
|
||||||
|
|
||||||
// Same shape as TRACK_ICON, dashed — a route is planned rather than
|
// 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 LINE_LAYER_ID = "measure-line";
|
||||||
const FILL_LAYER_ID = "measure-fill";
|
const FILL_LAYER_ID = "measure-fill";
|
||||||
const POINT_LAYER_ID = "measure-points";
|
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
|
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
|
// A distance/area measuring tool, matching the ruler button in NPS's own
|
||||||
@@ -118,15 +119,31 @@ export class MeasureControl {
|
|||||||
id: POINT_LAYER_ID,
|
id: POINT_LAYER_ID,
|
||||||
type: "circle",
|
type: "circle",
|
||||||
source: SOURCE_ID,
|
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 },
|
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();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeLayers() {
|
_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.getLayer(id)) this._map.removeLayer(id);
|
||||||
});
|
});
|
||||||
if (this._map.getSource(SOURCE_ID)) this._map.removeSource(SOURCE_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);
|
const source = this._map.getSource(SOURCE_ID);
|
||||||
if (!source) return;
|
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 =
|
const shapeFeature =
|
||||||
this._mode === "area"
|
this._mode === "area"
|
||||||
? this._points.length >= 3 && { type: "Feature", geometry: { type: "Polygon", coordinates: [[...this._points, this._points[0]]] } }
|
? 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 } };
|
: 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));
|
this._result.textContent = this._mode === "area" ? formatArea(polygonArea(this._points)) : formatDistance(lineLength(this._points));
|
||||||
}
|
}
|
||||||
@@ -166,6 +201,13 @@ function lineLength(points) {
|
|||||||
return total;
|
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
|
// Spherical polygon area (Chamberlain & Duquette, 2007) — the same formula
|
||||||
// turf.js's area() uses. A flat shoelace formula would drift noticeably at
|
// 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.
|
// 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"]
|
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
|
||||||
end
|
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
|
def test_unparseable_gpx_returns_422
|
||||||
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user