Add GPX upload with hierarchical folders and map display
Upload GPX files into a folder hierarchy (traversal-safe path resolution against the store root), browse them in a tree panel, and toggle each one's waypoints/tracks on the map. Waypoints render as clickable points showing name, description and time; bare URLs in the description are turned into safe links. GPX layers survive switching between the Online/Offline base map, same as the radar overlay. Adds rexml as an explicit dependency — it moved to a bundled gem in this Ruby version and isn't picked up by Bundler otherwise.
This commit is contained in:
1 parent
16e06b2ffe
commit
4e0d2abbd0
13 files changed
+629
-2
No files matched your search
@@ -9,6 +9,10 @@
|
||||
# see docs/development.adoc)
|
||||
/data/*.pmtiles
|
||||
|
||||
# User-uploaded GPX files
|
||||
/data/gpx/*
|
||||
!/data/gpx/.gitkeep
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
@@ -3,6 +3,7 @@ source "https://rubygems.org"
|
||||
gem "sinatra", "~> 4.2"
|
||||
gem "sinatra-contrib", "~> 4.2"
|
||||
gem "puma", "~> 8.0"
|
||||
gem "rexml", "~> 3.4"
|
||||
|
||||
group :development do
|
||||
gem "rerun"
|
||||
|
||||
@@ -35,6 +35,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
rerun (0.14.0)
|
||||
listen (~> 3.0)
|
||||
rexml (3.4.4)
|
||||
sinatra (4.2.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
@@ -65,6 +66,7 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
puma (~> 8.0)
|
||||
rerun
|
||||
rexml (~> 3.4)
|
||||
sinatra (~> 4.2)
|
||||
sinatra-contrib (~> 4.2)
|
||||
|
||||
@@ -93,6 +95,7 @@ CHECKSUMS
|
||||
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
|
||||
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
|
||||
rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
||||
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
|
||||
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ The project's shape is still open — this is the initial scaffold.
|
||||
|
||||
Early scaffold. The web application serves a vector map (MapLibre GL
|
||||
JS), with an online style and an optional offline style backed by a
|
||||
local PMTiles file, plus an animated RainViewer radar overlay.
|
||||
local PMTiles file, an animated RainViewer radar overlay, and GPX
|
||||
upload/display (points and tracks, organized in folders).
|
||||
|
||||
== Requirements
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
require "sinatra/base"
|
||||
require "sinatra/json"
|
||||
require_relative "lib/maptool/gpx"
|
||||
require_relative "lib/maptool/gpx_store"
|
||||
|
||||
module Maptool
|
||||
class App < Sinatra::Base
|
||||
@@ -7,6 +9,7 @@ module Maptool
|
||||
set :views, File.join(__dir__, "views")
|
||||
|
||||
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
|
||||
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
|
||||
|
||||
get "/" do
|
||||
erb :index
|
||||
@@ -28,5 +31,37 @@ module Maptool
|
||||
|
||||
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
|
||||
end
|
||||
|
||||
get "/api/gpx/tree" do
|
||||
json GPX_STORE.tree
|
||||
end
|
||||
|
||||
post "/api/gpx" do
|
||||
file = params[:file]
|
||||
halt 400, json(error: "file is required") unless file && file[:tempfile]
|
||||
|
||||
begin
|
||||
path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])
|
||||
rescue GpxStore::InvalidPath
|
||||
halt 400, json(error: "invalid folder")
|
||||
end
|
||||
|
||||
json path: path, tree: GPX_STORE.tree
|
||||
end
|
||||
|
||||
get "/api/gpx/file/*" do
|
||||
begin
|
||||
path = GPX_STORE.resolve_file(params[:splat].first)
|
||||
rescue GpxStore::InvalidPath
|
||||
halt 400
|
||||
end
|
||||
halt 404 unless File.file?(path)
|
||||
|
||||
begin
|
||||
json Gpx.to_geojson(path)
|
||||
rescue Gpx::ParseError
|
||||
halt 422, json(error: "could not parse GPX file")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Whitespace-only changes.
+51
-1
@@ -30,7 +30,11 @@ opened in a browser.
|
||||
`docs/third-party-notices.adoc`.
|
||||
|
||||
| `data/`
|
||||
| Local, gitignored data files, e.g. `offline.pmtiles`.
|
||||
| Local, gitignored data files, e.g. `offline.pmtiles` and uploaded
|
||||
GPX files under `data/gpx/`.
|
||||
|
||||
| `lib/maptool/`
|
||||
| Application code beyond routing: the GPX parser and file store.
|
||||
|
||||
| `docs/`
|
||||
| Project documentation, in AsciiDoc.
|
||||
@@ -110,6 +114,52 @@ This is a live, online-only overlay — it always needs internet access
|
||||
and is unrelated to the offline PMTiles basemap above. Per RainViewer's
|
||||
terms, the API is free for personal/non-commercial use.
|
||||
|
||||
== GPX tracks and waypoints
|
||||
|
||||
The "GPX" map control (`public/js/gpx.js`) opens a side panel to upload
|
||||
GPX files and toggle them on the map, individually.
|
||||
|
||||
Storage:: Uploaded files are stored as-is under `data/gpx/`, in
|
||||
whatever folder hierarchy the user types into the upload form (e.g.
|
||||
`Hiking/2026`) — `Maptool::GpxStore`
|
||||
(`lib/maptool/gpx_store.rb`) creates folders as needed. Every path
|
||||
coming from the client (upload folder, file path in a URL) is
|
||||
resolved with `File.expand_path` and checked against the store root
|
||||
before touching the filesystem, so `../..`-style path traversal can't
|
||||
escape `data/gpx/`.
|
||||
`GET /api/gpx/tree` returns the resulting folder/file tree as JSON,
|
||||
which the panel renders recursively (folders as `<details>`, files as
|
||||
a checkbox).
|
||||
|
||||
Rendering:: Checking a file fetches `GET
|
||||
/api/gpx/file/<path>` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses
|
||||
the GPX with REXML and converts it to a GeoJSON `FeatureCollection`:
|
||||
`<wpt>` waypoints become `Point` features (with `name`, `desc` and
|
||||
`time` carried through as properties), each `<trk>` becomes one
|
||||
`MultiLineString` feature (one line per `<trkseg>`). MapLibre's
|
||||
`geometry-type` expression treats `MultiLineString` the same as
|
||||
`LineString`, so a single GeoJSON source per file with a `line` layer
|
||||
and a `circle` layer (each filtered by geometry type) is enough to
|
||||
render both waypoints and tracks. `<rte>` routes are not handled.
|
||||
|
||||
Like the radar overlay, GPX layers are removed by `map.setStyle()`
|
||||
(switching Online/Offline) and get re-added on `style.load` for
|
||||
whichever files were checked.
|
||||
|
||||
Waypoint popups:: Clicking a waypoint circle opens a MapLibre popup
|
||||
with its name, description and time. Click/hover handling is
|
||||
registered once per file (via `map.on(type, layerId, handler)`, a
|
||||
delegated listener that survives `setStyle()`) rather than on every
|
||||
`style.load` re-add, which would otherwise stack duplicate handlers
|
||||
and pop up several copies per click. Waypoint text comes straight from
|
||||
an uploaded file — untrusted input — so `public/js/gpx.js`'s
|
||||
`linkify()` turns bare URLs in the description into `<a>` links by
|
||||
splitting the text on a URL pattern and escaping every piece
|
||||
(including the URL itself, as both `href` and link text) before
|
||||
assembling any HTML; the URL pattern excludes quote characters, so a
|
||||
matched URL can never break out of the `href="..."` attribute. Every
|
||||
other value is HTML-escaped before being handed to `Popup#setHTML`.
|
||||
|
||||
== Networking
|
||||
|
||||
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
require "rexml/document"
|
||||
|
||||
module Maptool
|
||||
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
|
||||
# become Point features, tracks (<trk>) become MultiLineString features
|
||||
# (one line per <trkseg>). Routes (<rte>) are not handled — GPX in
|
||||
# practice is either points or recorded tracks for this app.
|
||||
module Gpx
|
||||
class ParseError < StandardError; end
|
||||
|
||||
def self.to_geojson(path)
|
||||
root = REXML::Document.new(File.read(path)).root
|
||||
raise ParseError, "not a GPX file" unless root && root.name == "gpx"
|
||||
|
||||
features = []
|
||||
root.each_element("wpt") { |wpt| features << waypoint_feature(wpt) }
|
||||
root.each_element("trk") { |trk| features << track_feature(trk) }
|
||||
|
||||
{ "type" => "FeatureCollection", "features" => features }
|
||||
rescue REXML::ParseException => e
|
||||
raise ParseError, e.message
|
||||
end
|
||||
|
||||
def self.waypoint_feature(wpt)
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) },
|
||||
"properties" => {
|
||||
"kind" => "waypoint",
|
||||
"name" => child_text(wpt, "name"),
|
||||
"description" => child_text(wpt, "desc"),
|
||||
"time" => child_text(wpt, "time"),
|
||||
},
|
||||
}
|
||||
end
|
||||
private_class_method :waypoint_feature
|
||||
|
||||
def self.track_feature(trk)
|
||||
lines = []
|
||||
trk.each_element("trkseg") do |seg|
|
||||
points = []
|
||||
seg.each_element("trkpt") { |pt| points << coordinate(pt) }
|
||||
lines << points unless points.empty?
|
||||
end
|
||||
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name") },
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
def self.coordinate(point_el)
|
||||
lat = point_el.attributes["lat"].to_f
|
||||
lon = point_el.attributes["lon"].to_f
|
||||
ele = child_text(point_el, "ele")
|
||||
ele ? [lon, lat, ele.to_f] : [lon, lat]
|
||||
end
|
||||
private_class_method :coordinate
|
||||
|
||||
def self.child_text(el, name)
|
||||
el.elements[name]&.text&.strip
|
||||
end
|
||||
private_class_method :child_text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
require "fileutils"
|
||||
|
||||
module Maptool
|
||||
# Filesystem-backed store for uploaded GPX files, organized in
|
||||
# user-chosen folders (arbitrary nesting). All paths coming from the
|
||||
# outside (folder names, file paths in URLs) are resolved and checked
|
||||
# against the store root before touching the filesystem, so path
|
||||
# traversal (e.g. "../../etc") can't escape it.
|
||||
class GpxStore
|
||||
class InvalidPath < StandardError; end
|
||||
|
||||
def initialize(root)
|
||||
@root = File.expand_path(root)
|
||||
FileUtils.mkdir_p(@root)
|
||||
end
|
||||
|
||||
# Nested { type:, name:, path:, children: } hash describing every
|
||||
# folder and .gpx file under the store root.
|
||||
def tree
|
||||
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
|
||||
end
|
||||
|
||||
# Saves `io`'s contents as a .gpx file under `folder` (created if
|
||||
# needed), deriving a safe filename from `filename`. Returns the
|
||||
# stored file's path relative to the store root.
|
||||
def save(folder:, filename:, io:)
|
||||
dir = resolve_dir(folder.to_s)
|
||||
FileUtils.mkdir_p(dir)
|
||||
name = unique_filename(dir, safe_filename(filename))
|
||||
File.binwrite(File.join(dir, name), io.read)
|
||||
relative = folder.to_s.empty? ? name : "#{folder}/#{name}"
|
||||
relative
|
||||
end
|
||||
|
||||
# Absolute filesystem path for a store-relative file path, after
|
||||
# confirming it stays within the store root.
|
||||
def resolve_file(relative_path)
|
||||
path = File.expand_path(File.join(@root, relative_path.to_s))
|
||||
raise InvalidPath unless within_root?(path)
|
||||
|
||||
path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_dir(folder)
|
||||
path = File.expand_path(File.join(@root, folder))
|
||||
raise InvalidPath unless within_root?(path)
|
||||
|
||||
path
|
||||
end
|
||||
|
||||
def within_root?(path)
|
||||
path == @root || path.start_with?(@root + File::SEPARATOR)
|
||||
end
|
||||
|
||||
def safe_filename(filename)
|
||||
base = File.basename(filename.to_s)
|
||||
base = "upload.gpx" if base.empty? || base == "." || base == ".."
|
||||
base = "#{base}.gpx" unless base.downcase.end_with?(".gpx")
|
||||
base
|
||||
end
|
||||
|
||||
def unique_filename(dir, name)
|
||||
return name unless File.exist?(File.join(dir, name))
|
||||
|
||||
ext = File.extname(name)
|
||||
stem = name[0...-ext.length]
|
||||
n = 1
|
||||
n += 1 while File.exist?(File.join(dir, "#{stem}-#{n}#{ext}"))
|
||||
"#{stem}-#{n}#{ext}"
|
||||
end
|
||||
|
||||
def list_children(dir, relative)
|
||||
Dir.children(dir).sort.filter_map do |entry|
|
||||
full = File.join(dir, entry)
|
||||
rel = relative.empty? ? entry : "#{relative}/#{entry}"
|
||||
|
||||
if File.directory?(full)
|
||||
{ "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) }
|
||||
elsif entry.downcase.end_with?(".gpx")
|
||||
{ "type" => "file", "name" => entry, "path" => rel }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -76,3 +76,120 @@ html, body {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gpx-toggle button {
|
||||
/* Override maplibregl-ctrl-group's 29x29px square icon-button sizing —
|
||||
this is a text button. */
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gpx-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.gpx-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gpx-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.gpx-panel__close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gpx-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.gpx-upload input {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gpx-upload button {
|
||||
align-self: flex-start;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gpx-upload__status {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.gpx-tree {
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.gpx-tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.gpx-tree > ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.gpx-tree summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gpx-tree__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gpx-tree__empty {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.gpx-popup__time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
|
||||
const TREE_URL = "/api/gpx/tree";
|
||||
const UPLOAD_URL = "/api/gpx";
|
||||
const TRACK_COLOR = "#e63946";
|
||||
|
||||
// A MapLibre IControl: a "GPX" button that opens a side panel for
|
||||
// uploading GPX files (stored server-side in a folder hierarchy) and
|
||||
// toggling their tracks/waypoints on and off the map.
|
||||
export class GpxPanelControl {
|
||||
constructor(map) {
|
||||
this._map = map;
|
||||
this._visible = new Set(); // file paths currently shown on the map
|
||||
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
||||
|
||||
// setStyle() (switching the Online/Offline base map) tears down all
|
||||
// custom sources and layers — put visible GPX layers back afterwards.
|
||||
map.on("style.load", () => {
|
||||
this._visible.forEach((path) => this._addLayers(path));
|
||||
});
|
||||
}
|
||||
|
||||
onAdd() {
|
||||
this._button = document.createElement("div");
|
||||
this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle";
|
||||
this._button.innerHTML = `<button type="button">GPX</button>`;
|
||||
this._button
|
||||
.querySelector("button")
|
||||
.addEventListener("click", () => this._togglePanel());
|
||||
|
||||
this._panel = document.getElementById("gpx-panel");
|
||||
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
||||
this._uploadForm = this._panel.querySelector(".gpx-upload");
|
||||
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
||||
this._panel
|
||||
.querySelector(".gpx-panel__close")
|
||||
.addEventListener("click", () => this._togglePanel(false));
|
||||
this._uploadForm.addEventListener("submit", (event) => this._handleUpload(event));
|
||||
|
||||
this._loadTree();
|
||||
|
||||
return this._button;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this._button.remove();
|
||||
}
|
||||
|
||||
_togglePanel(force) {
|
||||
this._panel.hidden = force === undefined ? !this._panel.hidden : !force;
|
||||
}
|
||||
|
||||
async _loadTree() {
|
||||
const response = await fetch(TREE_URL);
|
||||
const tree = await response.json();
|
||||
this._renderTree(tree);
|
||||
}
|
||||
|
||||
_renderTree(node) {
|
||||
this._treeContainer.innerHTML = "";
|
||||
this._treeContainer.appendChild(this._renderChildren(node.children));
|
||||
}
|
||||
|
||||
_renderChildren(children) {
|
||||
const ul = document.createElement("ul");
|
||||
if (children.length === 0) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "gpx-tree__empty";
|
||||
li.textContent = "No files yet";
|
||||
ul.appendChild(li);
|
||||
return ul;
|
||||
}
|
||||
|
||||
children.forEach((child) => {
|
||||
const li = document.createElement("li");
|
||||
if (child.type === "folder") {
|
||||
const details = document.createElement("details");
|
||||
details.open = true;
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = child.name;
|
||||
details.appendChild(summary);
|
||||
details.appendChild(this._renderChildren(child.children));
|
||||
li.appendChild(details);
|
||||
} else {
|
||||
const label = document.createElement("label");
|
||||
label.className = "gpx-tree__file";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = this._visible.has(child.path);
|
||||
checkbox.addEventListener("change", () => this._toggleFile(child.path, checkbox.checked));
|
||||
label.appendChild(checkbox);
|
||||
label.append(` ${child.name}`);
|
||||
li.appendChild(label);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
_toggleFile(path, visible) {
|
||||
if (visible) {
|
||||
this._visible.add(path);
|
||||
this._addLayers(path);
|
||||
} else {
|
||||
this._visible.delete(path);
|
||||
this._removeLayers(path);
|
||||
}
|
||||
}
|
||||
|
||||
_addLayers(path) {
|
||||
const id = sourceId(path);
|
||||
if (this._map.getSource(id)) return;
|
||||
|
||||
this._map.addSource(id, { type: "geojson", data: gpxFileUrl(path) });
|
||||
this._map.addLayer({
|
||||
id: `${id}-line`,
|
||||
type: "line",
|
||||
source: id,
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
layout: { "line-cap": "round", "line-join": "round" },
|
||||
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: `${id}-points`,
|
||||
type: "circle",
|
||||
source: id,
|
||||
filter: ["==", ["geometry-type"], "Point"],
|
||||
paint: {
|
||||
"circle-radius": 5,
|
||||
"circle-color": TRACK_COLOR,
|
||||
"circle-stroke-color": "#fff",
|
||||
"circle-stroke-width": 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
this._registerPointInteractivity(`${id}-points`);
|
||||
}
|
||||
|
||||
// Registered once per layer id, ever — map.on(type, layerId, fn) is a
|
||||
// delegated listener that lives on the map itself and keeps matching
|
||||
// that layer id after setStyle() removes and (via _addLayers) re-adds
|
||||
// it, so re-registering on every re-add would stack duplicate popups.
|
||||
_registerPointInteractivity(layerId) {
|
||||
if (this._interactiveLayers.has(layerId)) return;
|
||||
this._interactiveLayers.add(layerId);
|
||||
|
||||
this._map.on("click", layerId, (event) => {
|
||||
const feature = event.features[0];
|
||||
if (!feature) return;
|
||||
new Popup({ closeButton: true, maxWidth: "260px" })
|
||||
.setLngLat(event.lngLat)
|
||||
.setHTML(popupHtml(feature.properties))
|
||||
.addTo(this._map);
|
||||
});
|
||||
this._map.on("mouseenter", layerId, () => {
|
||||
this._map.getCanvas().style.cursor = "pointer";
|
||||
});
|
||||
this._map.on("mouseleave", layerId, () => {
|
||||
this._map.getCanvas().style.cursor = "";
|
||||
});
|
||||
}
|
||||
|
||||
_removeLayers(path) {
|
||||
const id = sourceId(path);
|
||||
[`${id}-line`, `${id}-points`].forEach((layerId) => {
|
||||
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
|
||||
});
|
||||
if (this._map.getSource(id)) this._map.removeSource(id);
|
||||
}
|
||||
|
||||
async _handleUpload(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(this._uploadForm);
|
||||
this._setStatus("Uploading…");
|
||||
|
||||
try {
|
||||
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
|
||||
if (!response.ok) throw new Error("upload failed");
|
||||
const { tree } = await response.json();
|
||||
this._renderTree(tree);
|
||||
this._uploadForm.reset();
|
||||
this._setStatus("Uploaded.");
|
||||
} catch (err) {
|
||||
this._setStatus("Upload failed.");
|
||||
}
|
||||
}
|
||||
|
||||
_setStatus(text) {
|
||||
this._uploadStatus.hidden = false;
|
||||
this._uploadStatus.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceId(path) {
|
||||
return `gpx-${path}`;
|
||||
}
|
||||
|
||||
function gpxFileUrl(path) {
|
||||
return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
// properties come straight from an uploaded GPX file — untrusted input —
|
||||
// so every value is escaped before landing in the popup's HTML.
|
||||
function popupHtml({ name, description, time }) {
|
||||
const parts = [];
|
||||
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
||||
if (description) parts.push(`<p>${linkify(description).replace(/\n/g, "<br>")}</p>`);
|
||||
if (time) {
|
||||
const parsed = new Date(time);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Turns bare URLs in untrusted text into safe <a> links: the string is
|
||||
// split on the URL pattern and every piece is escaped — including the URL
|
||||
// itself, both as href and as link text — before any HTML is assembled, so
|
||||
// nothing bypasses escaping. The URL pattern excludes quote characters, so
|
||||
// a matched URL can never break out of the href="..." attribute.
|
||||
const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
function linkify(text) {
|
||||
return text
|
||||
.split(URL_PATTERN)
|
||||
.map((part, i) => (i % 2 === 1 ? urlToLink(part) : escapeHtml(part)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function urlToLink(url) {
|
||||
// Keep trailing sentence punctuation (e.g. "...place/1234.") out of the
|
||||
// link itself.
|
||||
const [, core, trailing] = url.match(/^(.*?)([.,;:!?]*)$/);
|
||||
const href = escapeHtml(core);
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>${escapeHtml(trailing)}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addProtocol,
|
||||
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
import { RadarControl } from "/js/radar.js";
|
||||
import { GpxPanelControl } from "/js/gpx.js";
|
||||
|
||||
const CENTER = [17.1077, 48.1486];
|
||||
const ZOOM = 13;
|
||||
@@ -74,6 +75,7 @@ map.addControl(new RadarControl(map), "top-right");
|
||||
const layerToggle = new LayerToggleControl(applyStyle);
|
||||
map.addControl(layerToggle, "top-left");
|
||||
layerToggle.setActive("online");
|
||||
map.addControl(new GpxPanelControl(map), "top-left");
|
||||
|
||||
function applyStyle(name) {
|
||||
layerToggle.setActive(name);
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="gpx-panel" class="gpx-panel" hidden>
|
||||
<div class="gpx-panel__header">
|
||||
<strong>GPX</strong>
|
||||
<button type="button" class="gpx-panel__close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<form class="gpx-upload">
|
||||
<input type="file" name="file" accept=".gpx" required>
|
||||
<input type="text" name="folder" placeholder="folder/subfolder (optional)">
|
||||
<button type="submit">Upload</button>
|
||||
<p class="gpx-upload__status" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="gpx-tree"></div>
|
||||
</div>
|
||||
Reference in new issue
Block a user