Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f205667b | ||
|
|
44d235bbcf | ||
|
|
dd0ad6d845 | ||
|
|
dd272ffb8a |
No files matched your search
@@ -169,8 +169,8 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
# A .zip (Garmin's bulk/FIT export format) is expanded into its
|
# A .zip (Garmin's bulk/FIT export format) is expanded into its
|
||||||
# .gpx/.fit entries instead of being stored itself — nothing downstream
|
# .gpx/.fit/.geojson entries instead of being stored itself — nothing
|
||||||
# (tree listing, map rendering) knows what to do with a zip.
|
# downstream (tree listing, map rendering) knows what to do with a zip.
|
||||||
post "/api/gpx" do
|
post "/api/gpx" do
|
||||||
require_login!
|
require_login!
|
||||||
file = params[:file]
|
file = params[:file]
|
||||||
|
|||||||
+39
-13
@@ -221,11 +221,11 @@ This is a live, online-only overlay — it always needs internet access
|
|||||||
and is unrelated to the offline PMTiles basemap above. Per RainViewer's
|
and is unrelated to the offline PMTiles basemap above. Per RainViewer's
|
||||||
terms, the API is free for personal/non-commercial use.
|
terms, the API is free for personal/non-commercial use.
|
||||||
|
|
||||||
== GPX/FIT tracks and waypoints
|
== GPX/FIT/GeoJSON tracks and waypoints
|
||||||
|
|
||||||
The "Tracks" map control (`public/js/gpx.js`) opens a side panel to
|
The "Tracks" map control (`public/js/gpx.js`) opens a side panel to
|
||||||
upload GPX or FIT files by dragging them in, organize them into
|
upload GPX, FIT or GeoJSON files by dragging them in, organize them
|
||||||
folders (also by dragging), and toggle each one on the map
|
into folders (also by dragging), and toggle each one on the map
|
||||||
individually.
|
individually.
|
||||||
|
|
||||||
Storage:: Files are stored as-is under `data/gpx/`, in a
|
Storage:: Files are stored as-is under `data/gpx/`, in a
|
||||||
@@ -243,13 +243,18 @@ which the panel renders recursively (folders as `<details>`, files as
|
|||||||
a checkbox, both with a delete button).
|
a checkbox, both with a delete button).
|
||||||
|
|
||||||
Drag & drop:: The dropzone at the top of the panel accepts one or more
|
Drag & drop:: The dropzone at the top of the panel accepts one or more
|
||||||
`.gpx`/`.fit` files, or a `.zip` (`POST /api/gpx`, multipart) into the
|
`.gpx`/`.fit`/`.geojson` files, or a `.zip` (`POST /api/gpx`,
|
||||||
top level, or click it to browse instead. A `.zip` — Garmin's bulk/FIT
|
multipart) into the top level, or click it to browse instead. A
|
||||||
export format — isn't stored itself: `save_zip_entries` (`app.rb`)
|
`.geojson` file needs no format conversion — it's already the shape
|
||||||
expands it with `rubyzip` and saves each `.gpx`/`.fit` entry it
|
`gpx.js` expects — so `GpxStore#geojson` just serves it straight from
|
||||||
contains individually (flattened — any folder structure inside the
|
disk (see "Rendering" below); useful for a file already in this app's
|
||||||
zip is discarded), skipping anything else (readme files, etc.) it
|
own schema, e.g. one exported elsewhere in that shape, or a previous
|
||||||
might hold. An already-uploaded file (a
|
`GET /api/gpx/file` response saved and re-uploaded. A `.zip` —
|
||||||
|
Garmin's bulk/FIT export format — isn't stored itself:
|
||||||
|
`save_zip_entries` (`app.rb`) expands it with `rubyzip` and saves each
|
||||||
|
`.gpx`/`.fit`/`.geojson` entry it contains individually (flattened —
|
||||||
|
any folder structure inside the zip is discarded), skipping anything
|
||||||
|
else (readme files, etc.) it might hold. An already-uploaded file (a
|
||||||
`.gpx-tree__file` row, `draggable`) can be dragged onto a folder to
|
`.gpx-tree__file` row, `draggable`) can be dragged onto a folder to
|
||||||
move it there (`POST /api/gpx/move`), or dropped anywhere else in the
|
move it there (`POST /api/gpx/move`), or dropped anywhere else in the
|
||||||
tree to move it back to the top level; an OS file can also be dropped
|
tree to move it back to the top level; an OS file can also be dropped
|
||||||
@@ -269,8 +274,18 @@ know which format it got. `GpxStore#geojson` caches that conversion
|
|||||||
alongside the source file (`<path>.geojson`, invalidated by mtime) —
|
alongside the source file (`<path>.geojson`, invalidated by mtime) —
|
||||||
REXML parsing a several-thousand-waypoint POI export (a park4night
|
REXML parsing a several-thousand-waypoint POI export (a park4night
|
||||||
area export, say) can take tens of seconds, and without a cache that
|
area export, say) can take tens of seconds, and without a cache that
|
||||||
cost repeats on every single toggle rather than just the first one
|
cost repeats on every single toggle. `GpxStore#save` also calls
|
||||||
after a save. `Gpx` parses the GPX with REXML: `<wpt>`
|
`geojson` itself right after writing the file, so that cost is paid
|
||||||
|
once during the upload (covered by the frontend's own "Uploading…"
|
||||||
|
status) instead of on whichever toggle happens to be first — a silent
|
||||||
|
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>`
|
||||||
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>`
|
||||||
@@ -409,7 +424,8 @@ Like the radar overlay, GPX layers are removed by `map.setStyle()`
|
|||||||
whichever files were checked.
|
whichever files were checked.
|
||||||
|
|
||||||
Waypoint popups:: Clicking a waypoint badge opens a MapLibre popup
|
Waypoint popups:: Clicking a waypoint badge opens a MapLibre popup
|
||||||
with its name, category (if any), description and time. Click/hover handling is
|
with its name, category (if any), photos (if any), description and
|
||||||
|
time. Click/hover handling is
|
||||||
registered once per file (via `map.on(type, layerId, handler)`, a
|
registered once per file (via `map.on(type, layerId, handler)`, a
|
||||||
delegated listener that survives `setStyle()`) rather than on every
|
delegated listener that survives `setStyle()`) rather than on every
|
||||||
`style.load` re-add, which would otherwise stack duplicate handlers
|
`style.load` re-add, which would otherwise stack duplicate handlers
|
||||||
@@ -436,6 +452,16 @@ before, one paragraph with line breaks. So this only ever adds
|
|||||||
structure on top of recognized lines; it never loses or hides text from
|
structure on top of recognized lines; it never loses or hides text from
|
||||||
files that don't have any.
|
files that don't have any.
|
||||||
+
|
+
|
||||||
|
A newer p4n2gpx export also emits one plain GPX `<link href>` per
|
||||||
|
listing photo (not a vendor extension like `waypoint_style`'s
|
||||||
|
`<osmand:*>` — `<link>` is base GPX 1.1, any number allowed per
|
||||||
|
`<wpt>`/`<trk>`/`<rte>`). `Gpx.waypoint_photos` collects their `href`s
|
||||||
|
into a `photos` property; `formatPhotos` renders them as a row of
|
||||||
|
64px thumbnails above the description, each linking to the full-size
|
||||||
|
image in a new tab. A plain GPS waypoint or an older export has none,
|
||||||
|
so the row just doesn't render — same "only ever adds, never assumes"
|
||||||
|
approach as the description formatting above.
|
||||||
|
+
|
||||||
`Services`/`Activities` get one further step (`formatMetaRow`,
|
`Services`/`Activities` get one further step (`formatMetaRow`,
|
||||||
`formatTags`): the value is park4night's own comma-separated codes
|
`formatTags`): the value is park4night's own comma-separated codes
|
||||||
(`animaux, point_eau, wc_public`), which on their own told a reader
|
(`animaux, point_eau, wc_public`), which on their own told a reader
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module Maptool
|
||||||
|
# A directly-uploaded .geojson file needs no conversion — GpxStore serves
|
||||||
|
# it straight from disk (see GpxStore#geojson) — so unlike Gpx/Fit there's
|
||||||
|
# no to_geojson here. Only kind-detection lives here, for the tree's badge
|
||||||
|
# icons, mirroring Gpx.kinds' cheap substring-search rather than a full
|
||||||
|
# JSON parse: a POI export can be tens of MB, geojson or not, and a full
|
||||||
|
# parse just to answer "does this have any points/lines" would cost the
|
||||||
|
# same either way.
|
||||||
|
module Geojson
|
||||||
|
def self.kinds(path)
|
||||||
|
content = File.read(path)
|
||||||
|
kinds = []
|
||||||
|
kinds << "waypoint" if content.include?('"Point"')
|
||||||
|
kinds << "track" if content.include?('"LineString"') # matches MultiLineString too
|
||||||
|
kinds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -51,11 +51,21 @@ module Maptool
|
|||||||
"category" => child_text(wpt, "type"),
|
"category" => child_text(wpt, "type"),
|
||||||
"color" => ext[:color],
|
"color" => ext[:color],
|
||||||
"icon" => ext[:icon],
|
"icon" => ext[:icon],
|
||||||
|
"photos" => waypoint_photos(wpt),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
private_class_method :waypoint_feature
|
private_class_method :waypoint_feature
|
||||||
|
|
||||||
|
# <link> is plain GPX 1.1 (a wpt/trk/rte can carry any number of them),
|
||||||
|
# not a vendor extension like waypoint_style's <osmand:*> — a newer
|
||||||
|
# p4n2gpx export started emitting one per park4night listing photo. A
|
||||||
|
# plain GPS waypoint has none, so this is almost always empty.
|
||||||
|
def self.waypoint_photos(wpt)
|
||||||
|
wpt.get_elements("link").filter_map { |link| link.attributes["href"] }
|
||||||
|
end
|
||||||
|
private_class_method :waypoint_photos
|
||||||
|
|
||||||
OSMAND_NAMESPACE = "https://osmand.net"
|
OSMAND_NAMESPACE = "https://osmand.net"
|
||||||
private_constant :OSMAND_NAMESPACE
|
private_constant :OSMAND_NAMESPACE
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ require "fileutils"
|
|||||||
require "json"
|
require "json"
|
||||||
require_relative "gpx"
|
require_relative "gpx"
|
||||||
require_relative "fit"
|
require_relative "fit"
|
||||||
|
require_relative "geojson"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
# Filesystem-backed store for uploaded GPX/FIT files, organized in
|
# Filesystem-backed store for uploaded GPX/FIT files, organized in
|
||||||
@@ -22,7 +23,10 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Nested { type:, name:, path:, children: } hash describing every
|
# Nested { type:, name:, path:, children: } hash describing every
|
||||||
# folder and .gpx/.fit file under the store root.
|
# folder and .gpx/.fit file under the store root. Each file also
|
||||||
|
# carries "cached" — whether its GeoJSON conversion is ready on disk
|
||||||
|
# (see `geojson` below) — so the tree UI can show a file's ready to
|
||||||
|
# view instantly, without a round trip just to find out.
|
||||||
def tree
|
def tree
|
||||||
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
|
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
|
||||||
end
|
end
|
||||||
@@ -30,12 +34,26 @@ module Maptool
|
|||||||
# Saves `io`'s contents as a .gpx/.fit file under `folder` (created if
|
# Saves `io`'s contents as a .gpx/.fit file under `folder` (created if
|
||||||
# needed), deriving a safe filename from `filename`. Returns the
|
# needed), deriving a safe filename from `filename`. Returns the
|
||||||
# stored file's path relative to the store root.
|
# stored file's path relative to the store root.
|
||||||
|
#
|
||||||
|
# Converts to GeoJSON and caches it right away (see `geojson` below)
|
||||||
|
# rather than waiting for the first time someone checks the file's box —
|
||||||
|
# a several-thousand-waypoint POI export is slow enough to parse that
|
||||||
|
# doing it lazily meant the *first* toggle silently hung (indistinguishable
|
||||||
|
# from broken), instead of the upload's own "Uploading…" status covering
|
||||||
|
# that wait like it already does for the upload itself. A file that fails
|
||||||
|
# to parse still saves fine — same as before, it just surfaces the error
|
||||||
|
# on first view instead of here.
|
||||||
def save(folder:, filename:, io:)
|
def save(folder:, filename:, io:)
|
||||||
dir = resolve_dir(folder.to_s)
|
dir = resolve_dir(folder.to_s)
|
||||||
FileUtils.mkdir_p(dir)
|
FileUtils.mkdir_p(dir)
|
||||||
name = unique_filename(dir, safe_filename(filename))
|
name = unique_filename(dir, safe_filename(filename))
|
||||||
File.binwrite(File.join(dir, name), io.read)
|
File.binwrite(File.join(dir, name), io.read)
|
||||||
relative = folder.to_s.empty? ? name : "#{folder}/#{name}"
|
relative = folder.to_s.empty? ? name : "#{folder}/#{name}"
|
||||||
|
begin
|
||||||
|
geojson(relative)
|
||||||
|
rescue Gpx::ParseError, Fit::ParseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
relative
|
relative
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -93,12 +111,17 @@ module Maptool
|
|||||||
# can take tens of seconds, every single time the file is toggled
|
# can take tens of seconds, every single time the file is toggled
|
||||||
# visible. Cached alongside the source file and invalidated by mtime,
|
# visible. Cached alongside the source file and invalidated by mtime,
|
||||||
# so only the first read after a save (or edit) pays that cost.
|
# so only the first read after a save (or edit) pays that cost.
|
||||||
|
#
|
||||||
|
# A directly-uploaded .geojson file skips all of this — it's already in
|
||||||
|
# the shape gpx.js expects, so there's nothing to convert or cache;
|
||||||
|
# it's just read and served as-is.
|
||||||
def geojson(relative_path)
|
def geojson(relative_path)
|
||||||
path = resolve_file(relative_path)
|
path = resolve_file(relative_path)
|
||||||
raise Errno::ENOENT, path unless File.file?(path)
|
raise Errno::ENOENT, path unless File.file?(path)
|
||||||
|
return File.read(path) if path.downcase.end_with?(".geojson")
|
||||||
|
|
||||||
cache = geojson_cache_path(path)
|
cache = geojson_cache_path(path)
|
||||||
if !File.file?(cache) || File.mtime(cache) < File.mtime(path)
|
unless geojson_cached?(path)
|
||||||
data = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
|
data = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
|
||||||
File.write(cache, JSON.generate(data))
|
File.write(cache, JSON.generate(data))
|
||||||
end
|
end
|
||||||
@@ -122,7 +145,16 @@ module Maptool
|
|||||||
"#{path}.geojson"
|
"#{path}.geojson"
|
||||||
end
|
end
|
||||||
|
|
||||||
TRACK_EXTENSIONS = %w[.gpx .fit].freeze
|
# A .geojson source needs no separate cache file — it already is the
|
||||||
|
# target format, so it's always "ready".
|
||||||
|
def geojson_cached?(path)
|
||||||
|
return true if path.downcase.end_with?(".geojson")
|
||||||
|
|
||||||
|
cache = geojson_cache_path(path)
|
||||||
|
File.file?(cache) && File.mtime(cache) >= File.mtime(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
TRACK_EXTENSIONS = %w[.gpx .fit .geojson].freeze
|
||||||
|
|
||||||
def safe_filename(filename)
|
def safe_filename(filename)
|
||||||
base = File.basename(filename.to_s)
|
base = File.basename(filename.to_s)
|
||||||
@@ -151,9 +183,15 @@ module Maptool
|
|||||||
if File.directory?(full)
|
if File.directory?(full)
|
||||||
{ "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) }
|
{ "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) }
|
||||||
elsif entry.downcase.end_with?(".gpx")
|
elsif entry.downcase.end_with?(".gpx")
|
||||||
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full) }
|
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full), "cached" => geojson_cached?(full) }
|
||||||
elsif entry.downcase.end_with?(".fit")
|
elsif entry.downcase.end_with?(".fit")
|
||||||
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full) }
|
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full), "cached" => geojson_cached?(full) }
|
||||||
|
# A GPX/FIT's own cache sidecar (e.g. "ride.gpx.geojson", written by
|
||||||
|
# `geojson` above) also ends in ".geojson" — excluded here so it
|
||||||
|
# doesn't show up as a second, phantom tree entry next to the file
|
||||||
|
# it was derived from.
|
||||||
|
elsif entry.downcase.end_with?(".geojson") && !entry.downcase.end_with?(".gpx.geojson", ".fit.geojson")
|
||||||
|
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Geojson.kinds(full), "cached" => true }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -582,6 +582,21 @@ html, body {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpx-popup__photos {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-popup__photo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.gpx-popup__meta {
|
.gpx-popup__meta {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
|||||||
+34
-4
@@ -6,6 +6,7 @@ import {
|
|||||||
WAYPOINT_ICON,
|
WAYPOINT_ICON,
|
||||||
TRACK_ICON,
|
TRACK_ICON,
|
||||||
ROUTE_ICON,
|
ROUTE_ICON,
|
||||||
|
GEOJSON_ICON,
|
||||||
MAP_MARKER_ICON,
|
MAP_MARKER_ICON,
|
||||||
POI_ICONS,
|
POI_ICONS,
|
||||||
PARK4NIGHT_TAGS,
|
PARK4NIGHT_TAGS,
|
||||||
@@ -276,6 +277,7 @@ export class GpxPanelControl {
|
|||||||
name.title = file.name;
|
name.title = file.name;
|
||||||
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());
|
||||||
label.appendChild(name);
|
label.appendChild(name);
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
|
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
|
||||||
@@ -299,6 +301,14 @@ export class GpxPanelControl {
|
|||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_cachedIcon() {
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "gpx-tree__kind-icon";
|
||||||
|
icon.innerHTML = GEOJSON_ICON;
|
||||||
|
icon.title = "Ready to view instantly (GeoJSON cached)";
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
_deleteButton(onClick) {
|
_deleteButton(onClick) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
@@ -610,9 +620,12 @@ export class GpxPanelControl {
|
|||||||
|
|
||||||
async _uploadFiles(fileList, folder) {
|
async _uploadFiles(fileList, folder) {
|
||||||
// .zip: Garmin's bulk/FIT export format — the server expands it into
|
// .zip: Garmin's bulk/FIT export format — the server expands it into
|
||||||
// its .gpx/.fit entries, so it's accepted here even though the tree
|
// its .gpx/.fit/.geojson entries, so it's accepted here even though the
|
||||||
// never shows a .zip itself.
|
// tree never shows a .zip itself. .geojson needs no server-side
|
||||||
const files = [...fileList].filter((file) => /\.(gpx|fit|zip)$/i.test(file.name));
|
// conversion (GpxStore#geojson serves it as-is) — accepted here so a
|
||||||
|
// file already in this app's own shape (e.g. a previous /api/gpx/file
|
||||||
|
// response, saved and re-uploaded) can skip GPX/FIT entirely.
|
||||||
|
const files = [...fileList].filter((file) => /\.(gpx|fit|geojson|zip)$/i.test(file.name));
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`);
|
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`);
|
||||||
@@ -754,10 +767,11 @@ function gpxFileUrl(path) {
|
|||||||
|
|
||||||
// properties come straight from an uploaded GPX file — untrusted input —
|
// properties come straight from an uploaded GPX file — untrusted input —
|
||||||
// so every value is escaped before landing in the popup's HTML.
|
// so every value is escaped before landing in the popup's HTML.
|
||||||
function popupHtml({ name, description, time, category }) {
|
function popupHtml({ name, description, time, category, photos }) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
||||||
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
|
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
|
||||||
|
if (photos?.length > 0) parts.push(formatPhotos(photos));
|
||||||
if (description) parts.push(formatDescription(description));
|
if (description) parts.push(formatDescription(description));
|
||||||
if (time) {
|
if (time) {
|
||||||
const parsed = new Date(time);
|
const parsed = new Date(time);
|
||||||
@@ -808,6 +822,22 @@ function escapeHtml(text) {
|
|||||||
const DESCRIPTION_META_LINE = /^(Rating|Services|Activities):\s*(.+)$/i;
|
const DESCRIPTION_META_LINE = /^(Rating|Services|Activities):\s*(.+)$/i;
|
||||||
const DESCRIPTION_LINK_LINE = /^https?:\/\/\S+$/;
|
const DESCRIPTION_LINK_LINE = /^https?:\/\/\S+$/;
|
||||||
|
|
||||||
|
// <link> photo URLs (a newer p4n2gpx export adds one per park4night listing
|
||||||
|
// photo — see lib/maptool/gpx.rb's waypoint_photos) as a row of thumbnails,
|
||||||
|
// each opening the full-size image in a new tab. Untrusted URLs from an
|
||||||
|
// uploaded file, same as everywhere else in this popup — escaped for use as
|
||||||
|
// both an href and an img src.
|
||||||
|
function formatPhotos(photos) {
|
||||||
|
const thumbs = photos
|
||||||
|
.map((url) => escapeHtml(url))
|
||||||
|
.map(
|
||||||
|
(url) =>
|
||||||
|
`<a href="${url}" target="_blank" rel="noopener noreferrer"><img class="gpx-popup__photo" src="${url}" loading="lazy" alt=""></a>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `<div class="gpx-popup__photos">${thumbs}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDescription(description) {
|
function formatDescription(description) {
|
||||||
const body = [];
|
const body = [];
|
||||||
const meta = [];
|
const meta = [];
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ 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
|
||||||
|
// (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 = `
|
||||||
|
<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"/>
|
||||||
|
</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
|
||||||
// recorded, the same visual convention most map apps use for the
|
// recorded, the same visual convention most map apps use for the
|
||||||
// distinction.
|
// distinction.
|
||||||
|
|||||||
Vendored
+5
@@ -20,4 +20,9 @@
|
|||||||
<locus:icon xmlns:locus="http://www.locusmap.eu">res/drawable-hdpi/ic_parking_small.png</locus:icon>
|
<locus:icon xmlns:locus="http://www.locusmap.eu">res/drawable-hdpi/ic_parking_small.png</locus:icon>
|
||||||
</extensions>
|
</extensions>
|
||||||
</wpt>
|
</wpt>
|
||||||
|
<wpt lat="48.103" lon="17.203">
|
||||||
|
<name>With photos</name>
|
||||||
|
<link href="https://cdn3.park4night.com/lieu/1_100/1_gd.jpg"><text>Photo</text></link>
|
||||||
|
<link href="https://cdn3.park4night.com/lieu/1_100/2_gd.jpg"><text>Photo</text></link>
|
||||||
|
</wpt>
|
||||||
</gpx>
|
</gpx>
|
||||||
@@ -60,6 +60,18 @@ class AppTest < Minitest::Test
|
|||||||
assert_operator track["properties"]["distance_meters"], :>, 0
|
assert_operator track["properties"]["distance_meters"], :>, 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_geojson_upload_is_served_back_unchanged
|
||||||
|
content = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
|
||||||
|
response = post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
||||||
|
|
||||||
|
assert_equal 200, response.status
|
||||||
|
assert_equal ["spots.geojson"], json_body(response)["paths"]
|
||||||
|
|
||||||
|
file_response = request.get("/api/gpx/file/spots.geojson")
|
||||||
|
assert_equal 200, file_response.status
|
||||||
|
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ class GpxTest < Minitest::Test
|
|||||||
assert_nil plain["properties"]["icon"]
|
assert_nil plain["properties"]["icon"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_waypoint_captures_link_hrefs_as_photos
|
||||||
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
||||||
|
with_photos = geojson["features"].find { |f| f["properties"]["name"] == "With photos" }
|
||||||
|
plain = geojson["features"].find { |f| f["properties"]["name"] == "Plain waypoint" }
|
||||||
|
|
||||||
|
assert_equal(
|
||||||
|
["https://cdn3.park4night.com/lieu/1_100/1_gd.jpg", "https://cdn3.park4night.com/lieu/1_100/2_gd.jpg"],
|
||||||
|
with_photos["properties"]["photos"]
|
||||||
|
)
|
||||||
|
assert_empty plain["properties"]["photos"]
|
||||||
|
end
|
||||||
|
|
||||||
def test_kinds_detects_routes
|
def test_kinds_detects_routes
|
||||||
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
|
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
|
||||||
end
|
end
|
||||||
|
|||||||
+69
-6
@@ -68,20 +68,26 @@ class GpxStoreTest < Minitest::Test
|
|||||||
assert_empty @store.tree["children"].reject { |c| c["name"] == "archive" }
|
assert_empty @store.tree["children"].reject { |c| c["name"] == "archive" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_geojson_is_cached_alongside_the_source_file
|
def test_save_eagerly_caches_the_geojson_conversion
|
||||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||||
cache_path = @store.resolve_file(path) + ".geojson"
|
cache_path = @store.resolve_file(path) + ".geojson"
|
||||||
refute File.file?(cache_path)
|
|
||||||
|
|
||||||
first = @store.geojson(path)
|
|
||||||
assert File.file?(cache_path)
|
assert File.file?(cache_path)
|
||||||
|
|
||||||
# Corrupt the cache directly to prove the second call reused it instead
|
first = @store.geojson(path)
|
||||||
# of re-parsing the (still-valid) source file.
|
|
||||||
|
# Corrupt the cache directly to prove the call above reused it instead of
|
||||||
|
# re-parsing the (still-valid) source file.
|
||||||
File.write(cache_path, first.sub("waypoint", "cached-waypoint"))
|
File.write(cache_path, first.sub("waypoint", "cached-waypoint"))
|
||||||
assert_includes @store.geojson(path), "cached-waypoint"
|
assert_includes @store.geojson(path), "cached-waypoint"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_save_of_an_unparseable_file_still_succeeds
|
||||||
|
path = @store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all"))
|
||||||
|
|
||||||
|
refute File.file?(@store.resolve_file(path) + ".geojson")
|
||||||
|
assert_raises(Maptool::Gpx::ParseError) { @store.geojson(path) }
|
||||||
|
end
|
||||||
|
|
||||||
def test_geojson_cache_is_invalidated_when_the_file_is_resaved
|
def test_geojson_cache_is_invalidated_when_the_file_is_resaved
|
||||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||||
@store.geojson(path)
|
@store.geojson(path)
|
||||||
@@ -115,6 +121,59 @@ class GpxStoreTest < Minitest::Test
|
|||||||
assert_raises(Errno::ENOENT) { @store.geojson("nope.gpx") }
|
assert_raises(Errno::ENOENT) { @store.geojson("nope.gpx") }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_tree_reports_cached_true_after_a_successful_save
|
||||||
|
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||||
|
|
||||||
|
file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" }
|
||||||
|
assert file["cached"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_tree_reports_cached_false_for_an_unparseable_file
|
||||||
|
@store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all"))
|
||||||
|
|
||||||
|
file = @store.tree["children"].find { |c| c["name"] == "bad.gpx" }
|
||||||
|
refute file["cached"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_tree_reports_cached_false_once_the_source_is_resaved
|
||||||
|
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||||
|
FileUtils.touch(@store.resolve_file(path) + ".geojson", mtime: Time.now - 10)
|
||||||
|
File.write(@store.resolve_file(path), sample_gpx)
|
||||||
|
FileUtils.touch(@store.resolve_file(path), mtime: Time.now)
|
||||||
|
|
||||||
|
file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" }
|
||||||
|
refute file["cached"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_geojson_extension_is_preserved_not_forced_to_gpx
|
||||||
|
path = @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson))
|
||||||
|
assert_equal "spots.geojson", path
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_uploaded_geojson_is_served_as_is_with_no_cache_file
|
||||||
|
path = @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson))
|
||||||
|
|
||||||
|
assert_equal sample_geojson, @store.geojson(path)
|
||||||
|
refute File.file?(@store.resolve_file(path) + ".geojson"), "a .geojson source needs no separate cache sidecar"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_uploaded_geojson_is_always_reported_cached
|
||||||
|
@store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson))
|
||||||
|
|
||||||
|
file = @store.tree["children"].find { |c| c["name"] == "spots.geojson" }
|
||||||
|
assert file["cached"]
|
||||||
|
assert_equal ["waypoint"], file["kinds"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# A GPX/FIT's own GeoJSON cache sidecar (e.g. "ride.gpx.geojson") also
|
||||||
|
# ends in ".geojson" — regression test for it not showing up as its own
|
||||||
|
# second, phantom tree entry alongside "ride.gpx".
|
||||||
|
def test_geojson_cache_sidecar_is_not_listed_as_its_own_tree_entry
|
||||||
|
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||||
|
|
||||||
|
assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def sample_gpx
|
def sample_gpx
|
||||||
@@ -122,4 +181,8 @@ class GpxStoreTest < Minitest::Test
|
|||||||
<gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx>
|
<gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx>
|
||||||
GPX
|
GPX
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sample_geojson
|
||||||
|
'{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
+2
-2
@@ -48,12 +48,12 @@
|
|||||||
<strong>Tracks</strong>
|
<strong>Tracks</strong>
|
||||||
<div class="gpx-panel__actions">
|
<div class="gpx-panel__actions">
|
||||||
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
|
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
|
||||||
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT file" aria-label="Upload GPX/FIT file"></button>
|
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT/GeoJSON file" aria-label="Upload GPX/FIT/GeoJSON file"></button>
|
||||||
<button type="button" class="gpx-panel__close" aria-label="Close">×</button>
|
<button type="button" class="gpx-panel__close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.zip" multiple hidden>
|
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.geojson,.zip" multiple hidden>
|
||||||
<p class="gpx-upload__status" hidden></p>
|
<p class="gpx-upload__status" hidden></p>
|
||||||
|
|
||||||
<div class="gpx-tree"></div>
|
<div class="gpx-tree"></div>
|
||||||
|
|||||||
Reference in new issue
Block a user