Redesign UI as an OsmAnd-style sidebar, add live GNSS position via gpsd
Restructure the map controls from MapLibre corner buttons into a left sidebar of icons with flyouts/panels (radar, GPX, GNSS), matching OsmAnd's layout more closely: - Base map and GPX selection are now full-height side panels docked next to the sidebar (not small dropdown flyouts or a right-docked panel), opening/closing exclusively via a small shared event bus (panels.js) so only one is ever open at a time. - The GPX panel's upload/new-folder controls are now icon buttons in the header instead of an always-visible dropzone and form; dragging a GPX file onto the tree, a specific folder, or straight onto the map itself all upload it. - New: Maptool::GpsdClient (lib/maptool/gpsd_client.rb) streams live GNSS fixes from a local gpsd daemon over Server-Sent Events (/api/gnss/stream) to a marker on the map (public/js/gnss.js) — one background gpsd connection shared by every browser tab. - The online map style (public/js/osmand-style.js) is now styled to resemble OsmAnd's own rendering, applied to both the online and offline (PMTiles) styles. Also adds a GeolocateControl (browser geolocation) next to the zoom controls.
This commit is contained in:
1 parent
008e034932
commit
87df90f75c
14 files changed
+939
-200
No files matched your search
+63
-50
@@ -1,4 +1,6 @@
|
||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON } from "/js/icons.js";
|
||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||
|
||||
const TREE_URL = "/api/gpx/tree";
|
||||
const UPLOAD_URL = "/api/gpx";
|
||||
@@ -11,10 +13,10 @@ const TRACK_COLOR = "#e63946";
|
||||
// one — both land on the same drop targets, told apart by this.
|
||||
const MOVE_MIME = "application/x-gpx-path";
|
||||
|
||||
// A MapLibre IControl: a "GPX" button that opens a side panel for
|
||||
// uploading GPX files (via drag & drop, stored server-side in a folder
|
||||
// hierarchy the user can add to and reorganize by dragging), and toggling
|
||||
// their tracks/waypoints on and off the map.
|
||||
// A sidebar icon that opens a side panel for uploading GPX files (via
|
||||
// drag & drop, stored server-side in a folder hierarchy the user can add to
|
||||
// and reorganize by dragging), and toggling their tracks/waypoints on and
|
||||
// off the map.
|
||||
export class GpxPanelControl {
|
||||
constructor(map) {
|
||||
this._map = map;
|
||||
@@ -28,88 +30,74 @@ export class GpxPanelControl {
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
mount(sidebar) {
|
||||
this._container = document.createElement("div");
|
||||
this._container.className = "sidebar-item";
|
||||
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="GPX" aria-label="GPX">${GPX_ICON}</button>`;
|
||||
this._icon = this._container.querySelector(".sidebar-icon");
|
||||
this._icon.addEventListener("click", () => this._togglePanel());
|
||||
|
||||
this._panel = document.getElementById("gpx-panel");
|
||||
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
||||
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
||||
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
|
||||
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
||||
this._panel
|
||||
.querySelector(".gpx-panel__close")
|
||||
.addEventListener("click", () => this._togglePanel(false));
|
||||
|
||||
this._setupDropzone();
|
||||
this._setupNewFolderForm();
|
||||
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
|
||||
|
||||
this._setupToolbarActions();
|
||||
this._setupRootDropTarget();
|
||||
this._setupMapDropTarget();
|
||||
|
||||
this._loadTree();
|
||||
|
||||
return this._button;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this._button.remove();
|
||||
sidebar.appendChild(this._container);
|
||||
}
|
||||
|
||||
_togglePanel(force) {
|
||||
this._panel.hidden = force === undefined ? !this._panel.hidden : !force;
|
||||
const open = force === undefined ? this._panel.hidden : force;
|
||||
this._panel.hidden = !open;
|
||||
this._icon.classList.toggle("active", open);
|
||||
if (open) notifySidePanelOpen(this._panel);
|
||||
}
|
||||
|
||||
_setupDropzone() {
|
||||
const dropzone = this._panel.querySelector(".gpx-dropzone");
|
||||
const fileInput = this._panel.querySelector(".gpx-dropzone__input");
|
||||
|
||||
dropzone.addEventListener("click", () => fileInput.click());
|
||||
// Both toolbar actions are icon buttons now (matching OsmAnd's tracks
|
||||
// panel) rather than an always-visible dropzone/form: upload triggers the
|
||||
// native file picker, new folder prompts for a path.
|
||||
_setupToolbarActions() {
|
||||
const fileInput = this._panel.querySelector(".gpx-file-input");
|
||||
fileInput.addEventListener("change", () => {
|
||||
this._uploadFiles(fileInput.files, "");
|
||||
fileInput.value = "";
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) return;
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("gpx-drop-hover");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("gpx-drop-hover"));
|
||||
dropzone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("gpx-drop-hover");
|
||||
if (event.dataTransfer.files?.length > 0) this._uploadFiles(event.dataTransfer.files, "");
|
||||
});
|
||||
}
|
||||
|
||||
_setupNewFolderForm() {
|
||||
const form = this._panel.querySelector(".gpx-new-folder");
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = form.querySelector("input");
|
||||
const path = input.value.trim();
|
||||
if (!path) return;
|
||||
this._panel.querySelector('[data-action="upload"]').addEventListener("click", () => fileInput.click());
|
||||
this._panel.querySelector('[data-action="new-folder"]').addEventListener("click", async () => {
|
||||
const path = window.prompt("New folder name (e.g. Hiking/2026):");
|
||||
if (!path || !path.trim()) return;
|
||||
|
||||
const response = await fetch(FOLDER_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `path=${encodeURIComponent(path)}`,
|
||||
body: `path=${encodeURIComponent(path.trim())}`,
|
||||
});
|
||||
if (response.ok) {
|
||||
const { tree } = await response.json();
|
||||
this._renderTree(tree);
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dropping a dragged file anywhere in the tree that isn't a folder (e.g.
|
||||
// blank space, or onto another file) moves it back to the top level —
|
||||
// folder drop targets call stopPropagation() so this only fires then.
|
||||
// The tree itself is the drop target for both an OS file drag (upload to
|
||||
// the top level) and, for anywhere that isn't a folder (blank space, or
|
||||
// onto another file — folder drop targets call stopPropagation() so this
|
||||
// only fires then), an internal file drag (move back to the top level).
|
||||
_setupRootDropTarget() {
|
||||
this._treeContainer.addEventListener("dragover", (event) => {
|
||||
if (!event.dataTransfer.types.includes(MOVE_MIME)) return;
|
||||
if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return;
|
||||
event.preventDefault();
|
||||
this._treeContainer.classList.add("gpx-drop-hover");
|
||||
});
|
||||
@@ -119,11 +107,36 @@ export class GpxPanelControl {
|
||||
this._treeContainer.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
this._treeContainer.classList.remove("gpx-drop-hover");
|
||||
if (event.dataTransfer.files?.length > 0) {
|
||||
this._uploadFiles(event.dataTransfer.files, "");
|
||||
return;
|
||||
}
|
||||
const from = event.dataTransfer.getData(MOVE_MIME);
|
||||
if (from) this._moveFile(from, "");
|
||||
});
|
||||
}
|
||||
|
||||
// Dropping a GPX file straight onto the map (not just the panel) uploads
|
||||
// it to the top level too.
|
||||
_setupMapDropTarget() {
|
||||
const mapContainer = this._map.getContainer();
|
||||
mapContainer.addEventListener("dragover", (event) => {
|
||||
if (!event.dataTransfer.types.includes("Files")) return;
|
||||
event.preventDefault();
|
||||
mapContainer.classList.add("gpx-drop-hover");
|
||||
});
|
||||
mapContainer.addEventListener("dragleave", (event) => {
|
||||
if (event.target === mapContainer) mapContainer.classList.remove("gpx-drop-hover");
|
||||
});
|
||||
mapContainer.addEventListener("drop", (event) => {
|
||||
mapContainer.classList.remove("gpx-drop-hover");
|
||||
if (event.dataTransfer.files?.length > 0) {
|
||||
event.preventDefault();
|
||||
this._uploadFiles(event.dataTransfer.files, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadTree() {
|
||||
const response = await fetch(TREE_URL);
|
||||
const tree = await response.json();
|
||||
|
||||
Reference in new issue
Block a user