Consolidate note-drawing into Measure; add edit/delete
Measure already draws distance lines and area polygons by hand — a second click-to-draw UI in the notes tool (Point/Area mode, from the previous commit) duplicated that. Replaced it with a "Save as note" button on the Measure panel that saves whatever shape is currently drawn there; NotesControl goes back to being just a one-click point pin. Both share a single NoteSaveForm (notes.js) for the note-text + target-layer-file UI instead of two copies of it. gpx.js: the "lines" source now also renders a note-flagged LineString (dashed, popup-on-click) alongside the Polygon fill/outline from before — the plain track-line layer excludes category:"Note" features so a saved distance note doesn't also get the track hover-summary/ profile-panel treatment. Notes are now individually editable/deletable from their own popup (logged-in only): Edit swaps the popup content for an inline textarea, Delete confirms and removes. Both target the feature by a new client-generated properties.id rather than array position (new GpxStore#update_feature/#delete_feature, PUT/DELETE /api/gpx/notes), since neither array index nor the note's own text (the thing being edited) is a stable identifier. Verified live: Measure's Save button (disabled/enabled state, saves the exact on-screen shape), the popup's Edit/Delete buttons and inline edit form. Full test suite: 115 runs, 0 failures (new coverage for update_feature/delete_feature and the PUT/DELETE routes).
This commit is contained in:
1 parent
db58468187
commit
aafb4cb4a0
9 files changed
+499
-239
No files matched your search
+110
-16
@@ -12,7 +12,7 @@ import {
|
||||
POI_ICONS,
|
||||
PARK4NIGHT_TAGS,
|
||||
} from "/js/icons.js";
|
||||
import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged } from "/js/panels.js";
|
||||
import { notifySidePanelOpen, onlyOneSidePanelOpen, onGpxStoreChanged, notifyGpxStoreChanged } from "/js/panels.js";
|
||||
import { formatActivityType } from "/js/track_profile.js";
|
||||
import { isLoggedIn } from "/js/auth.js";
|
||||
|
||||
@@ -531,22 +531,27 @@ export class GpxPanelControl {
|
||||
}
|
||||
|
||||
this._map.addSource(linesId, { type: "geojson", data: lines });
|
||||
// Excludes category:"Note" — a saved Measure-tool distance line gets
|
||||
// its own layer below (popup-on-click, not this hover-summary/profile-
|
||||
// panel track behavior), not this one.
|
||||
this._map.addLayer({
|
||||
id: `${linesId}-line`,
|
||||
type: "line",
|
||||
source: linesId,
|
||||
filter: ["any", ["!has", "category"], ["!=", ["get", "category"], "Note"]],
|
||||
layout: { "line-cap": "round", "line-join": "round" },
|
||||
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
||||
});
|
||||
this._registerLineInteractivity(`${linesId}-line`);
|
||||
|
||||
// Polygon notes (see notes.js) — the same "lines" source already only
|
||||
// renders LineString geometry through the line layer above (MapLibre's
|
||||
// own "line" layer type silently ignores Polygon features), so this is
|
||||
// just two more layers reading the same source, not a new bucket.
|
||||
// Popup-on-click like a waypoint (_registerPointInteractivity), not the
|
||||
// track line's hover-summary/profile-panel behavior above — a note
|
||||
// polygon has no elevation profile to open.
|
||||
// Polygon/line notes (see notes.js and measure.js's "Save as note") —
|
||||
// the same "lines" source already only renders LineString geometry
|
||||
// through the line layer above, and Polygon geometry isn't drawn by a
|
||||
// "line"-type layer at all (MapLibre only draws LineString for that
|
||||
// type), so these are just more layers reading the same source, not a
|
||||
// new bucket. Popup-on-click like a waypoint (_registerPointInteractivity),
|
||||
// not the track line's hover-summary/profile-panel behavior — a note
|
||||
// has no elevation profile to open.
|
||||
this._map.addLayer({
|
||||
id: `${linesId}-polygon-fill`,
|
||||
type: "fill",
|
||||
@@ -561,7 +566,17 @@ export class GpxPanelControl {
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: { "line-color": TRACK_COLOR, "line-width": 2 },
|
||||
});
|
||||
this._registerPointInteractivity(`${linesId}-polygon-fill`);
|
||||
this._registerPointInteractivity(`${linesId}-polygon-fill`, path);
|
||||
|
||||
this._map.addLayer({
|
||||
id: `${linesId}-note-line`,
|
||||
type: "line",
|
||||
source: linesId,
|
||||
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "category"], "Note"]],
|
||||
layout: { "line-cap": "round", "line-join": "round" },
|
||||
paint: { "line-color": TRACK_COLOR, "line-width": 3, "line-dasharray": [2, 1] },
|
||||
});
|
||||
this._registerPointInteractivity(`${linesId}-note-line`, path);
|
||||
|
||||
// clusterRadius/clusterMaxZoom are harmless when there's nothing dense
|
||||
// enough to cluster (a handful of waypoints just render individually).
|
||||
@@ -570,7 +585,7 @@ export class GpxPanelControl {
|
||||
await Promise.all([this._iconsReady, this._registerBadgeColors(badgeColors)]);
|
||||
if (!this._map.getSource(pointsId)) return true; // a style.load (basemap switch) tore it down while icons were loading
|
||||
|
||||
this._addClusterLayers(pointsId);
|
||||
this._addClusterLayers(pointsId, path);
|
||||
|
||||
if (points.features.length > 0) this._pointsByPath.set(path, points);
|
||||
this._updateLegend();
|
||||
@@ -584,7 +599,7 @@ export class GpxPanelControl {
|
||||
// each waypoint's own category color (see Maptool::Gpx#waypoint_color),
|
||||
// falling back to the plain track color for waypoints with no category
|
||||
// (e.g. a hand-placed GNSS pin, not from a POI export).
|
||||
_addClusterLayers(pointsId) {
|
||||
_addClusterLayers(pointsId, path) {
|
||||
this._map.addLayer({
|
||||
id: `${pointsId}-clusters`,
|
||||
type: "circle",
|
||||
@@ -643,7 +658,7 @@ export class GpxPanelControl {
|
||||
});
|
||||
|
||||
this._registerClusterInteractivity(pointsId);
|
||||
this._registerPointInteractivity(`${pointsId}-unclustered`);
|
||||
this._registerPointInteractivity(`${pointsId}-unclustered`, path);
|
||||
}
|
||||
|
||||
// Clicking a cluster zooms in just far enough to break it apart, the
|
||||
@@ -671,17 +686,22 @@ export class GpxPanelControl {
|
||||
// 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) {
|
||||
// `path` (which file this layer's features came from) is only used for
|
||||
// a "Note" feature's Edit/Delete actions — plain GPX/FIT/park4night
|
||||
// waypoints don't get those, so it's otherwise unused.
|
||||
_registerPointInteractivity(layerId, path) {
|
||||
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" })
|
||||
const editable = feature.properties.category === "Note" && isLoggedIn();
|
||||
const popup = new Popup({ closeButton: true, maxWidth: "260px" })
|
||||
.setLngLat(event.lngLat)
|
||||
.setHTML(popupHtml(feature.properties))
|
||||
.setHTML(popupHtml(feature.properties, { editable }))
|
||||
.addTo(this._map);
|
||||
if (editable) this._wireNoteActions(popup, path, feature);
|
||||
});
|
||||
this._map.on("mouseenter", layerId, () => {
|
||||
this._map.getCanvas().style.cursor = "pointer";
|
||||
@@ -691,6 +711,70 @@ export class GpxPanelControl {
|
||||
});
|
||||
}
|
||||
|
||||
// Edit swaps the popup's content for a plain textarea + Save/Cancel (the
|
||||
// note's location doesn't change, just its text, so there's no need for
|
||||
// NoteSaveForm's fuller target-file picker here). Delete just confirms
|
||||
// and removes. Both end by reloading the file's layers from the server —
|
||||
// simplest way to keep the map, the category legend, and this popup's
|
||||
// own feature data all in sync with what the edit/delete actually wrote,
|
||||
// rather than hand-patching three different client-side copies of it.
|
||||
_wireNoteActions(popup, path, feature) {
|
||||
const element = popup.getElement();
|
||||
const id = feature.properties.id;
|
||||
|
||||
element.querySelector(".gpx-popup__delete")?.addEventListener("click", async () => {
|
||||
if (!window.confirm("Delete this note?")) return;
|
||||
try {
|
||||
const response = await fetch(`/api/gpx/notes?${new URLSearchParams({ path, id })}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error(`delete failed: ${response.status}`);
|
||||
popup.remove();
|
||||
notifyGpxStoreChanged();
|
||||
await this._reloadFile(path);
|
||||
} catch (err) {
|
||||
window.alert("Could not delete the note — check your connection and try again.");
|
||||
}
|
||||
});
|
||||
|
||||
element.querySelector(".gpx-popup__edit")?.addEventListener("click", () => {
|
||||
const content = popup.getElement().querySelector(".maplibregl-popup-content");
|
||||
content.innerHTML = `
|
||||
<div class="gpx-popup__edit-form">
|
||||
<textarea class="gpx-popup__edit-text" rows="2">${escapeHtml(feature.properties.name || "")}</textarea>
|
||||
<div class="gpx-popup__edit-actions">
|
||||
<button type="button" class="gpx-popup__edit-save">Save</button>
|
||||
<button type="button" class="gpx-popup__edit-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const textarea = content.querySelector(".gpx-popup__edit-text");
|
||||
textarea.focus();
|
||||
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
|
||||
content.innerHTML = popupHtml(feature.properties, { editable: true });
|
||||
this._wireNoteActions(popup, path, feature);
|
||||
});
|
||||
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
const updated = { ...feature, properties: { ...feature.properties, name: text } };
|
||||
try {
|
||||
const body = new URLSearchParams({ path, id, feature: JSON.stringify(updated) });
|
||||
const response = await fetch("/api/gpx/notes", { method: "PUT", body });
|
||||
if (!response.ok) throw new Error(`save failed: ${response.status}`);
|
||||
popup.remove();
|
||||
notifyGpxStoreChanged();
|
||||
await this._reloadFile(path);
|
||||
} catch (err) {
|
||||
window.alert("Could not save the note — check your connection and try again.");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async _reloadFile(path) {
|
||||
this._removeLayers(path);
|
||||
await this._addLayers(path);
|
||||
}
|
||||
|
||||
// Hover shows a quick summary (unlike a waypoint popup, which is
|
||||
// click-triggered — a track's summary describes the whole line, not
|
||||
// whatever point along it happens to get hovered, so there's no single
|
||||
@@ -729,6 +813,7 @@ export class GpxPanelControl {
|
||||
`${linesId}-line`,
|
||||
`${linesId}-polygon-fill`,
|
||||
`${linesId}-polygon-outline`,
|
||||
`${linesId}-note-line`,
|
||||
`${pointsId}-clusters`,
|
||||
`${pointsId}-cluster-count`,
|
||||
`${pointsId}-unclustered`,
|
||||
@@ -985,7 +1070,11 @@ function gpxFileUrl(path) {
|
||||
|
||||
// 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, category, photos, icon }) {
|
||||
// editable (only ever true for a "Note" feature the viewer is logged in
|
||||
// for — see _registerPointInteractivity) adds Edit/Delete buttons; the
|
||||
// click handling for them lives in _wireNoteActions, not here, since this
|
||||
// only builds markup, it has no map/store access of its own.
|
||||
function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) {
|
||||
const parts = [];
|
||||
if (name) {
|
||||
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
|
||||
@@ -1000,6 +1089,11 @@ function popupHtml({ name, description, time, category, photos, icon }) {
|
||||
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
||||
}
|
||||
}
|
||||
if (editable) {
|
||||
parts.push(
|
||||
`<div class="gpx-popup__note-actions"><button type="button" class="gpx-popup__edit">Edit</button><button type="button" class="gpx-popup__delete">Delete</button></div>`,
|
||||
);
|
||||
}
|
||||
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user