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:
randy committed 2026-08-25 14:05:39 +02:00
1 parent 16e06b2ffe
commit 4e0d2abbd0
13 files changed
+629 -2

No files matched your search

+4
View File
@@ -9,6 +9,10 @@
# see docs/development.adoc) # see docs/development.adoc)
/data/*.pmtiles /data/*.pmtiles
# User-uploaded GPX files
/data/gpx/*
!/data/gpx/.gitkeep
# Editor / OS # Editor / OS
.DS_Store .DS_Store
*.swp *.swp
+1
View File
@@ -3,6 +3,7 @@ source "https://rubygems.org"
gem "sinatra", "~> 4.2" gem "sinatra", "~> 4.2"
gem "sinatra-contrib", "~> 4.2" gem "sinatra-contrib", "~> 4.2"
gem "puma", "~> 8.0" gem "puma", "~> 8.0"
gem "rexml", "~> 3.4"
group :development do group :development do
gem "rerun" gem "rerun"
+3
View File
@@ -35,6 +35,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
rerun (0.14.0) rerun (0.14.0)
listen (~> 3.0) listen (~> 3.0)
rexml (3.4.4)
sinatra (4.2.1) sinatra (4.2.1)
logger (>= 1.6.0) logger (>= 1.6.0)
mustermann (~> 3.0) mustermann (~> 3.0)
@@ -65,6 +66,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
puma (~> 8.0) puma (~> 8.0)
rerun rerun
rexml (~> 3.4)
sinatra (~> 4.2) sinatra (~> 4.2)
sinatra-contrib (~> 4.2) sinatra-contrib (~> 4.2)
@@ -93,6 +95,7 @@ CHECKSUMS
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
+2 -1
View File
@@ -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 Early scaffold. The web application serves a vector map (MapLibre GL
JS), with an online style and an optional offline style backed by a 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 == Requirements
+35
View File
@@ -1,5 +1,7 @@
require "sinatra/base" require "sinatra/base"
require "sinatra/json" require "sinatra/json"
require_relative "lib/maptool/gpx"
require_relative "lib/maptool/gpx_store"
module Maptool module Maptool
class App < Sinatra::Base class App < Sinatra::Base
@@ -7,6 +9,7 @@ module Maptool
set :views, File.join(__dir__, "views") set :views, File.join(__dir__, "views")
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
get "/" do get "/" do
erb :index erb :index
@@ -28,5 +31,37 @@ module Maptool
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
end 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
end end
View File
Whitespace-only changes.
+51 -1
View File
@@ -30,7 +30,11 @@ opened in a browser.
`docs/third-party-notices.adoc`. `docs/third-party-notices.adoc`.
| `data/` | `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/` | `docs/`
| Project documentation, in AsciiDoc. | 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 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 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 == Networking
The Sinatra server only binds to `127.0.0.1`. It is not meant to be The Sinatra server only binds to `127.0.0.1`. It is not meant to be
+67
View File
@@ -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
+87
View File
@@ -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
+117
View File
@@ -76,3 +76,120 @@ html, body {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; 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;
}
+244
View File
@@ -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)}`;
}
+2
View File
@@ -4,6 +4,7 @@ import {
addProtocol, addProtocol,
} from "/vendor/maplibre-gl/maplibre-gl.mjs"; } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { RadarControl } from "/js/radar.js"; import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
const CENTER = [17.1077, 48.1486]; const CENTER = [17.1077, 48.1486];
const ZOOM = 13; const ZOOM = 13;
@@ -74,6 +75,7 @@ map.addControl(new RadarControl(map), "top-right");
const layerToggle = new LayerToggleControl(applyStyle); const layerToggle = new LayerToggleControl(applyStyle);
map.addControl(layerToggle, "top-left"); map.addControl(layerToggle, "top-left");
layerToggle.setActive("online"); layerToggle.setActive("online");
map.addControl(new GpxPanelControl(map), "top-left");
function applyStyle(name) { function applyStyle(name) {
layerToggle.setActive(name); layerToggle.setActive(name);
+16
View File
@@ -1 +1,17 @@
<div id="map"></div> <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">&times;</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>