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
+489
-229
No files matched your search
@@ -40,8 +40,10 @@ independence from connectivity.
|
|||||||
== Custom layers & data
|
== Custom layers & data
|
||||||
|
|
||||||
* [x] GPX/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks — also FIT), auto-styled by category with a generated elevation profile — KML not supported (only GPX/FIT/GeoJSON/.zip)
|
* [x] GPX/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks — also FIT), auto-styled by category with a generated elevation profile — KML not supported (only GPX/FIT/GeoJSON/.zip)
|
||||||
* [x] User overlay: offline drawing of custom points *and areas* with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") — Point/Area mode toggle with a live drawing preview, saved into the same GpxStore the Tracks panel already reads (so visibility/popup come for free); gpx.js's `_addLayers` now renders Polygon features (fill + outline) alongside points/lines, not just points
|
* [x] User overlay: offline drawing of custom points with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") — one click on the map, pick or name a `.geojson` layer, saved into the same GpxStore the Tracks panel already reads (so visibility/popup come for free)
|
||||||
** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon ones sharing that category
|
** [x] Save a distance/area shape as a note too — Measure already draws these by hand, so a "Save" button there (not a second click-to-draw UI in the notes tool) reuses that instead of duplicating it; both share one `NoteSaveForm` (notes.js). `gpx.js`'s `_addLayers` now renders Polygon and note-flagged LineString features (fill/outline, dashed line), not just points
|
||||||
|
** [x] Edit/delete individual notes from their popup (logged-in only) — targets a feature by a client-generated `properties.id` (`GpxStore#update_feature`/`#delete_feature`), not array position
|
||||||
|
** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon/line ones sharing that category
|
||||||
* [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
|
* [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
|
||||||
|
|
||||||
== Nice to have (later)
|
== Nice to have (later)
|
||||||
|
|||||||
@@ -254,6 +254,45 @@ module Maptool
|
|||||||
json path: path, tree: GPX_STORE.tree
|
json path: path, tree: GPX_STORE.tree
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Editing/deleting one note (see the popup's Edit/Delete actions,
|
||||||
|
# gpx.js) — targets a feature by properties["id"], not array position
|
||||||
|
# (see GpxStore#update_feature/#delete_feature for why).
|
||||||
|
put "/api/gpx/notes" do
|
||||||
|
require_login!
|
||||||
|
begin
|
||||||
|
feature = JSON.parse(params[:feature].to_s)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
halt 400, json(error: "invalid feature")
|
||||||
|
end
|
||||||
|
halt 400, json(error: "invalid feature") unless feature.is_a?(Hash) && feature["geometry"]
|
||||||
|
halt 400, json(error: "path and id are required") if params[:path].to_s.empty? || params[:id].to_s.empty?
|
||||||
|
|
||||||
|
begin
|
||||||
|
GPX_STORE.update_feature(path: params[:path], id: params[:id], feature: feature)
|
||||||
|
rescue GpxStore::InvalidPath
|
||||||
|
halt 400, json(error: "invalid path")
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
halt 404, json(error: "note not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
json ok: true
|
||||||
|
end
|
||||||
|
|
||||||
|
delete "/api/gpx/notes" do
|
||||||
|
require_login!
|
||||||
|
halt 400, json(error: "path and id are required") if params[:path].to_s.empty? || params[:id].to_s.empty?
|
||||||
|
|
||||||
|
begin
|
||||||
|
GPX_STORE.delete_feature(path: params[:path], id: params[:id])
|
||||||
|
rescue GpxStore::InvalidPath
|
||||||
|
halt 400, json(error: "invalid path")
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
halt 404, json(error: "note not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
json ok: true
|
||||||
|
end
|
||||||
|
|
||||||
# ?download=geojson exports the file's GeoJSON as an attachment instead
|
# ?download=geojson exports the file's GeoJSON as an attachment instead
|
||||||
# of rendering it on the map — the only way to get a .gpx/.fit's
|
# of rendering it on the map — the only way to get a .gpx/.fit's
|
||||||
# converted form out of the app, since GpxStore#geojson's cache is
|
# converted form out of the app, since GpxStore#geojson's cache is
|
||||||
|
|||||||
@@ -154,6 +154,38 @@ module Maptool
|
|||||||
path
|
path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Replaces the feature whose properties["id"] matches `id` (see
|
||||||
|
# append_feature/notes.js — every hand-drawn note gets a client-
|
||||||
|
# generated id specifically so it can be found again later, since nothing
|
||||||
|
# else here is a stable identifier: array position shifts as other
|
||||||
|
# features are added/removed, and a note's own text is exactly the thing
|
||||||
|
# being edited). Raises Errno::ENOENT for a missing file *or* a missing
|
||||||
|
# feature — same "nothing to act on" story either way.
|
||||||
|
def update_feature(path:, id:, feature:)
|
||||||
|
file = resolve_file(path)
|
||||||
|
raise Errno::ENOENT, file unless File.file?(file)
|
||||||
|
|
||||||
|
collection = JSON.parse(File.read(file))
|
||||||
|
index = (collection["features"] || []).find_index { |f| f.dig("properties", "id") == id }
|
||||||
|
raise Errno::ENOENT, "#{path}##{id}" unless index
|
||||||
|
|
||||||
|
collection["features"][index] = feature
|
||||||
|
File.write(file, JSON.generate(collection))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same id-matching story as update_feature above.
|
||||||
|
def delete_feature(path:, id:)
|
||||||
|
file = resolve_file(path)
|
||||||
|
raise Errno::ENOENT, file unless File.file?(file)
|
||||||
|
|
||||||
|
collection = JSON.parse(File.read(file))
|
||||||
|
before = (collection["features"] || []).length
|
||||||
|
collection["features"] = (collection["features"] || []).reject { |f| f.dig("properties", "id") == id }
|
||||||
|
raise Errno::ENOENT, "#{path}##{id}" if collection["features"].length == before
|
||||||
|
|
||||||
|
File.write(file, JSON.generate(collection))
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def resolve_dir(folder)
|
def resolve_dir(folder)
|
||||||
|
|||||||
+71
-3
@@ -673,9 +673,6 @@ html, body {
|
|||||||
left: 100%;
|
left: 100%;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
@@ -689,6 +686,41 @@ html, body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.measure-panel__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same "author display wins over the UA [hidden] rule" gotcha as
|
||||||
|
.gpx-panel__action[hidden]/.notes-panel__form[hidden] elsewhere in this
|
||||||
|
file — this sets its own "display" too. */
|
||||||
|
.measure-panel__controls[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel .measure-panel__save {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__save:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel .notes-panel__form {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ".measure-panel " prefix (not just ".measure-panel__mode") to outrank
|
/* ".measure-panel " prefix (not just ".measure-panel__mode") to outrank
|
||||||
".maplibregl-ctrl-group button" (higher specificity, since these buttons
|
".maplibregl-ctrl-group button" (higher specificity, since these buttons
|
||||||
sit inside a maplibregl-ctrl-group container for the ruler icon's own
|
sit inside a maplibregl-ctrl-group container for the ruler icon's own
|
||||||
@@ -990,6 +1022,42 @@ html, body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpx-popup__note-actions,
|
||||||
|
.gpx-popup__edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-popup__note-actions button,
|
||||||
|
.gpx-popup__edit-actions button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-popup__edit-actions .gpx-popup__edit-save {
|
||||||
|
background: #1a5fb4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-popup__edit-text {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.gnss-control__status {
|
.gnss-control__status {
|
||||||
color: #888;
|
color: #888;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
+110
-16
@@ -12,7 +12,7 @@ import {
|
|||||||
POI_ICONS,
|
POI_ICONS,
|
||||||
PARK4NIGHT_TAGS,
|
PARK4NIGHT_TAGS,
|
||||||
} from "/js/icons.js";
|
} 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 { formatActivityType } from "/js/track_profile.js";
|
||||||
import { isLoggedIn } from "/js/auth.js";
|
import { isLoggedIn } from "/js/auth.js";
|
||||||
|
|
||||||
@@ -531,22 +531,27 @@ export class GpxPanelControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._map.addSource(linesId, { type: "geojson", data: lines });
|
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({
|
this._map.addLayer({
|
||||||
id: `${linesId}-line`,
|
id: `${linesId}-line`,
|
||||||
type: "line",
|
type: "line",
|
||||||
source: linesId,
|
source: linesId,
|
||||||
|
filter: ["any", ["!has", "category"], ["!=", ["get", "category"], "Note"]],
|
||||||
layout: { "line-cap": "round", "line-join": "round" },
|
layout: { "line-cap": "round", "line-join": "round" },
|
||||||
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
||||||
});
|
});
|
||||||
this._registerLineInteractivity(`${linesId}-line`);
|
this._registerLineInteractivity(`${linesId}-line`);
|
||||||
|
|
||||||
// Polygon notes (see notes.js) — the same "lines" source already only
|
// Polygon/line notes (see notes.js and measure.js's "Save as note") —
|
||||||
// renders LineString geometry through the line layer above (MapLibre's
|
// the same "lines" source already only renders LineString geometry
|
||||||
// own "line" layer type silently ignores Polygon features), so this is
|
// through the line layer above, and Polygon geometry isn't drawn by a
|
||||||
// just two more layers reading the same source, not a new bucket.
|
// "line"-type layer at all (MapLibre only draws LineString for that
|
||||||
// Popup-on-click like a waypoint (_registerPointInteractivity), not the
|
// type), so these are just more layers reading the same source, not a
|
||||||
// track line's hover-summary/profile-panel behavior above — a note
|
// new bucket. Popup-on-click like a waypoint (_registerPointInteractivity),
|
||||||
// polygon has no elevation profile to open.
|
// not the track line's hover-summary/profile-panel behavior — a note
|
||||||
|
// has no elevation profile to open.
|
||||||
this._map.addLayer({
|
this._map.addLayer({
|
||||||
id: `${linesId}-polygon-fill`,
|
id: `${linesId}-polygon-fill`,
|
||||||
type: "fill",
|
type: "fill",
|
||||||
@@ -561,7 +566,17 @@ export class GpxPanelControl {
|
|||||||
filter: ["==", ["geometry-type"], "Polygon"],
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
paint: { "line-color": TRACK_COLOR, "line-width": 2 },
|
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
|
// clusterRadius/clusterMaxZoom are harmless when there's nothing dense
|
||||||
// enough to cluster (a handful of waypoints just render individually).
|
// 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)]);
|
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
|
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);
|
if (points.features.length > 0) this._pointsByPath.set(path, points);
|
||||||
this._updateLegend();
|
this._updateLegend();
|
||||||
@@ -584,7 +599,7 @@ export class GpxPanelControl {
|
|||||||
// each waypoint's own category color (see Maptool::Gpx#waypoint_color),
|
// each waypoint's own category color (see Maptool::Gpx#waypoint_color),
|
||||||
// falling back to the plain track color for waypoints with no category
|
// falling back to the plain track color for waypoints with no category
|
||||||
// (e.g. a hand-placed GNSS pin, not from a POI export).
|
// (e.g. a hand-placed GNSS pin, not from a POI export).
|
||||||
_addClusterLayers(pointsId) {
|
_addClusterLayers(pointsId, path) {
|
||||||
this._map.addLayer({
|
this._map.addLayer({
|
||||||
id: `${pointsId}-clusters`,
|
id: `${pointsId}-clusters`,
|
||||||
type: "circle",
|
type: "circle",
|
||||||
@@ -643,7 +658,7 @@ export class GpxPanelControl {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this._registerClusterInteractivity(pointsId);
|
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
|
// 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
|
// delegated listener that lives on the map itself and keeps matching
|
||||||
// that layer id after setStyle() removes and (via _addLayers) re-adds
|
// that layer id after setStyle() removes and (via _addLayers) re-adds
|
||||||
// it, so re-registering on every re-add would stack duplicate popups.
|
// 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;
|
if (this._interactiveLayers.has(layerId)) return;
|
||||||
this._interactiveLayers.add(layerId);
|
this._interactiveLayers.add(layerId);
|
||||||
|
|
||||||
this._map.on("click", layerId, (event) => {
|
this._map.on("click", layerId, (event) => {
|
||||||
const feature = event.features[0];
|
const feature = event.features[0];
|
||||||
if (!feature) return;
|
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)
|
.setLngLat(event.lngLat)
|
||||||
.setHTML(popupHtml(feature.properties))
|
.setHTML(popupHtml(feature.properties, { editable }))
|
||||||
.addTo(this._map);
|
.addTo(this._map);
|
||||||
|
if (editable) this._wireNoteActions(popup, path, feature);
|
||||||
});
|
});
|
||||||
this._map.on("mouseenter", layerId, () => {
|
this._map.on("mouseenter", layerId, () => {
|
||||||
this._map.getCanvas().style.cursor = "pointer";
|
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
|
// Hover shows a quick summary (unlike a waypoint popup, which is
|
||||||
// click-triggered — a track's summary describes the whole line, not
|
// click-triggered — a track's summary describes the whole line, not
|
||||||
// whatever point along it happens to get hovered, so there's no single
|
// whatever point along it happens to get hovered, so there's no single
|
||||||
@@ -729,6 +813,7 @@ export class GpxPanelControl {
|
|||||||
`${linesId}-line`,
|
`${linesId}-line`,
|
||||||
`${linesId}-polygon-fill`,
|
`${linesId}-polygon-fill`,
|
||||||
`${linesId}-polygon-outline`,
|
`${linesId}-polygon-outline`,
|
||||||
|
`${linesId}-note-line`,
|
||||||
`${pointsId}-clusters`,
|
`${pointsId}-clusters`,
|
||||||
`${pointsId}-cluster-count`,
|
`${pointsId}-cluster-count`,
|
||||||
`${pointsId}-unclustered`,
|
`${pointsId}-unclustered`,
|
||||||
@@ -985,7 +1070,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, 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 = [];
|
const parts = [];
|
||||||
if (name) {
|
if (name) {
|
||||||
const glyph = icon && POI_ICONS[icon] ? `<span class="gpx-popup__header-icon">${POI_ICONS[icon]}</span>` : "";
|
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>`);
|
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>";
|
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-5
@@ -1,4 +1,5 @@
|
|||||||
import { RULER_ICON } from "/js/icons.js";
|
import { RULER_ICON } from "/js/icons.js";
|
||||||
|
import { NoteSaveForm } from "/js/notes.js";
|
||||||
|
|
||||||
const SOURCE_ID = "measure";
|
const SOURCE_ID = "measure";
|
||||||
const LINE_LAYER_ID = "measure-line";
|
const LINE_LAYER_ID = "measure-line";
|
||||||
@@ -29,24 +30,38 @@ export class MeasureControl {
|
|||||||
this._container.innerHTML = `
|
this._container.innerHTML = `
|
||||||
<button type="button" class="measure-ctrl__button" title="Measure" aria-label="Measure">${RULER_ICON}</button>
|
<button type="button" class="measure-ctrl__button" title="Measure" aria-label="Measure">${RULER_ICON}</button>
|
||||||
<div class="measure-panel" hidden>
|
<div class="measure-panel" hidden>
|
||||||
|
<div class="measure-panel__controls">
|
||||||
<button type="button" class="measure-panel__mode active" data-mode="distance">Distance</button>
|
<button type="button" class="measure-panel__mode active" data-mode="distance">Distance</button>
|
||||||
<button type="button" class="measure-panel__mode" data-mode="area">Area</button>
|
<button type="button" class="measure-panel__mode" data-mode="area">Area</button>
|
||||||
<span class="measure-panel__result"></span>
|
<span class="measure-panel__result"></span>
|
||||||
|
<button type="button" class="measure-panel__save" title="Save as a note" aria-label="Save as a note" disabled>📍</button>
|
||||||
<button type="button" class="measure-panel__clear" title="Clear" aria-label="Clear">×</button>
|
<button type="button" class="measure-panel__clear" title="Clear" aria-label="Clear">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
this._button = this._container.querySelector(".measure-ctrl__button");
|
this._button = this._container.querySelector(".measure-ctrl__button");
|
||||||
this._panel = this._container.querySelector(".measure-panel");
|
this._panel = this._container.querySelector(".measure-panel");
|
||||||
|
this._controls = this._container.querySelector(".measure-panel__controls");
|
||||||
this._result = this._container.querySelector(".measure-panel__result");
|
this._result = this._container.querySelector(".measure-panel__result");
|
||||||
|
this._saveButton = this._container.querySelector(".measure-panel__save");
|
||||||
|
|
||||||
|
this._noteForm = new NoteSaveForm(this._panel, {
|
||||||
|
onSaved: () => {
|
||||||
|
this._controls.hidden = false;
|
||||||
|
this._clear();
|
||||||
|
},
|
||||||
|
onCancel: () => (this._controls.hidden = false),
|
||||||
|
});
|
||||||
|
|
||||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
this._button.addEventListener("click", () => this._setActive(!this._active));
|
||||||
this._panel.querySelectorAll(".measure-panel__mode").forEach((button) => {
|
this._panel.querySelectorAll(".measure-panel__mode").forEach((button) => {
|
||||||
button.addEventListener("click", () => this._setMode(button.dataset.mode));
|
button.addEventListener("click", () => this._setMode(button.dataset.mode));
|
||||||
});
|
});
|
||||||
|
this._saveButton.addEventListener("click", () => this._saveAsNote());
|
||||||
this._panel.querySelector(".measure-panel__clear").addEventListener("click", () => this._clear());
|
this._panel.querySelector(".measure-panel__clear").addEventListener("click", () => this._clear());
|
||||||
|
|
||||||
this._onClick = (event) => {
|
this._onClick = (event) => {
|
||||||
if (!this._active) return;
|
if (!this._active || this._noteForm.isOpen) return;
|
||||||
this._points.push([event.lngLat.lng, event.lngLat.lat]);
|
this._points.push([event.lngLat.lng, event.lngLat.lat]);
|
||||||
this._render();
|
this._render();
|
||||||
};
|
};
|
||||||
@@ -79,10 +94,30 @@ export class MeasureControl {
|
|||||||
if (active) {
|
if (active) {
|
||||||
this._addLayers();
|
this._addLayers();
|
||||||
} else {
|
} else {
|
||||||
|
this._noteForm.close();
|
||||||
|
this._controls.hidden = false;
|
||||||
this._clear();
|
this._clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same shape _render() draws — a distance line (2+ points) or a
|
||||||
|
// closed area polygon (3+ points), or null while there isn't yet enough
|
||||||
|
// to measure — extracted so "Save as note" saves exactly what's on
|
||||||
|
// screen rather than recomputing it slightly differently.
|
||||||
|
_currentShapeGeometry() {
|
||||||
|
if (this._mode === "area") {
|
||||||
|
return this._points.length >= 3 ? { type: "Polygon", coordinates: [[...this._points, this._points[0]]] } : null;
|
||||||
|
}
|
||||||
|
return this._points.length >= 2 ? { type: "LineString", coordinates: this._points } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveAsNote() {
|
||||||
|
const geometry = this._currentShapeGeometry();
|
||||||
|
if (!geometry) return;
|
||||||
|
this._controls.hidden = true;
|
||||||
|
this._noteForm.open(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
_setMode(mode) {
|
_setMode(mode) {
|
||||||
this._mode = mode;
|
this._mode = mode;
|
||||||
this._panel.querySelectorAll(".measure-panel__mode").forEach((button) => {
|
this._panel.querySelectorAll(".measure-panel__mode").forEach((button) => {
|
||||||
@@ -165,10 +200,9 @@ export class MeasureControl {
|
|||||||
properties: { role: "vertex" },
|
properties: { role: "vertex" },
|
||||||
geometry: { type: "Point", coordinates: coord },
|
geometry: { type: "Point", coordinates: coord },
|
||||||
}));
|
}));
|
||||||
const shapeFeature =
|
const geometry = this._currentShapeGeometry();
|
||||||
this._mode === "area"
|
const shapeFeature = geometry && { type: "Feature", geometry };
|
||||||
? this._points.length >= 3 && { type: "Feature", geometry: { type: "Polygon", coordinates: [[...this._points, this._points[0]]] } }
|
this._saveButton.disabled = !geometry;
|
||||||
: this._points.length >= 2 && { type: "Feature", geometry: { type: "LineString", coordinates: this._points } };
|
|
||||||
// Area mode has no equivalent yet — only asked for distance's own
|
// Area mode has no equivalent yet — only asked for distance's own
|
||||||
// per-leg labels, and a polygon's edges read differently (perimeter
|
// per-leg labels, and a polygon's edges read differently (perimeter
|
||||||
// segments, not a path) if this ever extends there.
|
// segments, not a path) if this ever extends there.
|
||||||
|
|||||||
+105
-199
@@ -2,43 +2,32 @@ import { NOTE_PIN_ICON } from "/js/icons.js";
|
|||||||
import { notifyGpxStoreChanged } from "/js/panels.js";
|
import { notifyGpxStoreChanged } from "/js/panels.js";
|
||||||
|
|
||||||
const NEW_LAYER_VALUE = "__new__";
|
const NEW_LAYER_VALUE = "__new__";
|
||||||
const PREVIEW_SOURCE_ID = "notes-preview";
|
|
||||||
const PREVIEW_FILL_LAYER_ID = "notes-preview-fill";
|
|
||||||
const PREVIEW_LINE_LAYER_ID = "notes-preview-line";
|
|
||||||
const PREVIEW_POINT_LAYER_ID = "notes-preview-points";
|
|
||||||
const PREVIEW_COLOR = "#c0392b";
|
|
||||||
|
|
||||||
// A quick "pin it with a note" tool — click the map (Point mode) or click
|
// Every hand-drawn note (from here or measure.js's "Save as note") gets one
|
||||||
// out a few corners and Finish (Area mode), write a short note, pick which
|
// of these — the only stable way to find a specific feature again for
|
||||||
// .geojson "layer" file it joins (or start a new one). Saved server-side
|
// editing/deleting later (see GpxStore#update_feature/#delete_feature):
|
||||||
// (POST /api/gpx/notes) into the same GpxStore everything else in the
|
// array position shifts as other notes are added/removed, and the note's
|
||||||
// Tracks panel already reads from, so a note shows up there with no
|
// own text is exactly the thing an edit changes.
|
||||||
// separate rendering path of its own: same tree, same checkbox visibility,
|
export function generateNoteId() {
|
||||||
// same popup — public/js/gpx.js's _addLayers renders a Polygon feature the
|
return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
// same way it already renders points/lines, no separate code path here.
|
}
|
||||||
export class NotesControl {
|
|
||||||
constructor() {
|
|
||||||
this._active = false;
|
|
||||||
this._mode = "point"; // "point" | "area"
|
|
||||||
this._areaPoints = []; // vertices while drawing an area, before Finish
|
|
||||||
this._pendingGeometry = null; // the finished Point/Polygon geometry, once placed
|
|
||||||
}
|
|
||||||
|
|
||||||
onAdd(map) {
|
// The "note text + which .geojson layer" form, shared by NotesControl (a
|
||||||
this._map = map;
|
// single click) and measure.js's "Save as note" button (its current
|
||||||
|
// distance/area shape) — extracted here rather than duplicated in both,
|
||||||
|
// since the two differ only in *what* the note attaches a location to,
|
||||||
|
// not in how it's captured or where it's saved. Renders into whatever
|
||||||
|
// container it's given (each caller owns where that sits in its own panel);
|
||||||
|
// hidden until open() is called.
|
||||||
|
export class NoteSaveForm {
|
||||||
|
constructor(container, { onSaved, onCancel } = {}) {
|
||||||
|
this._onSaved = onSaved;
|
||||||
|
this._onCancel = onCancel;
|
||||||
|
|
||||||
this._container = document.createElement("div");
|
this._el = document.createElement("div");
|
||||||
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group notes-ctrl";
|
this._el.className = "notes-panel__form";
|
||||||
this._container.innerHTML = `
|
this._el.hidden = true;
|
||||||
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
|
this._el.innerHTML = `
|
||||||
<div class="notes-panel" hidden>
|
|
||||||
<div class="notes-panel__modes">
|
|
||||||
<button type="button" class="notes-panel__mode active" data-mode="point">Point</button>
|
|
||||||
<button type="button" class="notes-panel__mode" data-mode="area">Area</button>
|
|
||||||
</div>
|
|
||||||
<p class="notes-panel__hint"></p>
|
|
||||||
<button type="button" class="notes-panel__finish" hidden>Finish shape</button>
|
|
||||||
<div class="notes-panel__form" hidden>
|
|
||||||
<textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea>
|
<textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea>
|
||||||
<select class="notes-panel__target"></select>
|
<select class="notes-panel__target"></select>
|
||||||
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
||||||
@@ -46,178 +35,40 @@ export class NotesControl {
|
|||||||
<button type="button" class="notes-panel__save">Save</button>
|
<button type="button" class="notes-panel__save">Save</button>
|
||||||
<button type="button" class="notes-panel__cancel">Cancel</button>
|
<button type="button" class="notes-panel__cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
this._button = this._container.querySelector(".notes-ctrl__button");
|
container.appendChild(this._el);
|
||||||
this._panel = this._container.querySelector(".notes-panel");
|
|
||||||
this._hint = this._container.querySelector(".notes-panel__hint");
|
this._text = this._el.querySelector(".notes-panel__text");
|
||||||
this._finishButton = this._container.querySelector(".notes-panel__finish");
|
this._target = this._el.querySelector(".notes-panel__target");
|
||||||
this._form = this._container.querySelector(".notes-panel__form");
|
this._newName = this._el.querySelector(".notes-panel__new-name");
|
||||||
this._text = this._container.querySelector(".notes-panel__text");
|
this._saveButton = this._el.querySelector(".notes-panel__save");
|
||||||
this._target = this._container.querySelector(".notes-panel__target");
|
|
||||||
this._newName = this._container.querySelector(".notes-panel__new-name");
|
|
||||||
|
|
||||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
|
||||||
this._panel.querySelectorAll(".notes-panel__mode").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => this._setMode(button.dataset.mode));
|
|
||||||
});
|
|
||||||
this._finishButton.addEventListener("click", () => this._finishArea());
|
|
||||||
this._target.addEventListener("change", () => {
|
this._target.addEventListener("change", () => {
|
||||||
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
|
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
|
||||||
if (!this._newName.hidden) this._newName.focus();
|
if (!this._newName.hidden) this._newName.focus();
|
||||||
});
|
});
|
||||||
this._container.querySelector(".notes-panel__save").addEventListener("click", () => this._save());
|
this._saveButton.addEventListener("click", () => this._save());
|
||||||
this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm());
|
this._el.querySelector(".notes-panel__cancel").addEventListener("click", () => this.close(true));
|
||||||
|
|
||||||
this._onClick = (event) => {
|
|
||||||
if (!this._active || !this._form.hidden) return;
|
|
||||||
if (this._mode === "point") {
|
|
||||||
this._pendingGeometry = { type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] };
|
|
||||||
this._openForm();
|
|
||||||
} else {
|
|
||||||
this._areaPoints.push([event.lngLat.lng, event.lngLat.lat]);
|
|
||||||
this._renderPreview();
|
|
||||||
this._updateAreaUi();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
map.on("click", this._onClick);
|
|
||||||
|
|
||||||
map.on("style.load", () => {
|
|
||||||
if (this._areaPoints.length > 0) this._addPreviewLayers();
|
|
||||||
});
|
|
||||||
|
|
||||||
this._updateHint();
|
|
||||||
return this._container;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemove() {
|
get isOpen() {
|
||||||
this._map.off("click", this._onClick);
|
return !this._el.hidden;
|
||||||
this._container.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getDefaultPosition() {
|
async open(geometry) {
|
||||||
return "top-left";
|
this._geometry = geometry;
|
||||||
}
|
this._el.hidden = false;
|
||||||
|
|
||||||
_setActive(active) {
|
|
||||||
this._active = active;
|
|
||||||
this._button.classList.toggle("active", active);
|
|
||||||
this._panel.hidden = !active;
|
|
||||||
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
|
||||||
if (!active) {
|
|
||||||
this._clearArea();
|
|
||||||
this._resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_setMode(mode) {
|
|
||||||
this._mode = mode;
|
|
||||||
this._panel.querySelectorAll(".notes-panel__mode").forEach((button) => {
|
|
||||||
button.classList.toggle("active", button.dataset.mode === mode);
|
|
||||||
});
|
|
||||||
this._clearArea();
|
|
||||||
this._updateHint();
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearArea() {
|
|
||||||
this._areaPoints = [];
|
|
||||||
this._renderPreview();
|
|
||||||
this._updateAreaUi();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateAreaUi() {
|
|
||||||
this._finishButton.hidden = this._mode !== "area" || this._areaPoints.length < 3;
|
|
||||||
this._updateHint();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateHint() {
|
|
||||||
if (this._mode === "point") {
|
|
||||||
this._hint.textContent = "Click the map to place a note.";
|
|
||||||
} else if (this._areaPoints.length === 0) {
|
|
||||||
this._hint.textContent = "Click to start drawing an area.";
|
|
||||||
} else {
|
|
||||||
this._hint.textContent = `${this._areaPoints.length} corner${this._areaPoints.length === 1 ? "" : "s"} — click to add more${
|
|
||||||
this._areaPoints.length >= 3 ? ", or Finish" : ""
|
|
||||||
}.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_finishArea() {
|
|
||||||
if (this._areaPoints.length < 3) return;
|
|
||||||
this._pendingGeometry = { type: "Polygon", coordinates: [[...this._areaPoints, this._areaPoints[0]]] };
|
|
||||||
this._openForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
_addPreviewLayers() {
|
|
||||||
if (this._map.getSource(PREVIEW_SOURCE_ID)) return;
|
|
||||||
this._map.addSource(PREVIEW_SOURCE_ID, { type: "geojson", data: emptyCollection() });
|
|
||||||
this._map.addLayer({
|
|
||||||
id: PREVIEW_FILL_LAYER_ID,
|
|
||||||
type: "fill",
|
|
||||||
source: PREVIEW_SOURCE_ID,
|
|
||||||
filter: ["==", ["geometry-type"], "Polygon"],
|
|
||||||
paint: { "fill-color": PREVIEW_COLOR, "fill-opacity": 0.15 },
|
|
||||||
});
|
|
||||||
this._map.addLayer({
|
|
||||||
id: PREVIEW_LINE_LAYER_ID,
|
|
||||||
type: "line",
|
|
||||||
source: PREVIEW_SOURCE_ID,
|
|
||||||
filter: ["!=", ["geometry-type"], "Point"],
|
|
||||||
paint: { "line-color": PREVIEW_COLOR, "line-width": 2, "line-dasharray": [2, 1] },
|
|
||||||
});
|
|
||||||
this._map.addLayer({
|
|
||||||
id: PREVIEW_POINT_LAYER_ID,
|
|
||||||
type: "circle",
|
|
||||||
source: PREVIEW_SOURCE_ID,
|
|
||||||
filter: ["==", ["geometry-type"], "Point"],
|
|
||||||
paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": PREVIEW_COLOR, "circle-stroke-width": 2 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_removePreviewLayers() {
|
|
||||||
[PREVIEW_FILL_LAYER_ID, PREVIEW_LINE_LAYER_ID, PREVIEW_POINT_LAYER_ID].forEach((id) => {
|
|
||||||
if (this._map.getLayer(id)) this._map.removeLayer(id);
|
|
||||||
});
|
|
||||||
if (this._map.getSource(PREVIEW_SOURCE_ID)) this._map.removeSource(PREVIEW_SOURCE_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderPreview() {
|
|
||||||
if (this._areaPoints.length === 0) {
|
|
||||||
this._removePreviewLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._addPreviewLayers();
|
|
||||||
const pointFeatures = this._areaPoints.map((coord) => ({ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: coord } }));
|
|
||||||
// A closed ring needs 3+ vertices to be valid; with fewer, preview it as
|
|
||||||
// an open line so a half-drawn shape still shows something.
|
|
||||||
const shape =
|
|
||||||
this._areaPoints.length >= 3
|
|
||||||
? { type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[...this._areaPoints, this._areaPoints[0]]] } }
|
|
||||||
: this._areaPoints.length === 2
|
|
||||||
? { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: this._areaPoints } }
|
|
||||||
: null;
|
|
||||||
this._map.getSource(PREVIEW_SOURCE_ID)?.setData({ type: "FeatureCollection", features: [...(shape ? [shape] : []), ...pointFeatures] });
|
|
||||||
}
|
|
||||||
|
|
||||||
async _openForm() {
|
|
||||||
this._hint.hidden = true;
|
|
||||||
this._finishButton.hidden = true;
|
|
||||||
this._panel.querySelector(".notes-panel__modes").hidden = true;
|
|
||||||
this._form.hidden = false;
|
|
||||||
this._text.value = "";
|
this._text.value = "";
|
||||||
this._text.focus();
|
this._text.focus();
|
||||||
await this._populateTargets();
|
await this._populateTargets();
|
||||||
}
|
}
|
||||||
|
|
||||||
_resetForm() {
|
close(viaCancel = false) {
|
||||||
this._pendingGeometry = null;
|
this._geometry = null;
|
||||||
this._hint.hidden = false;
|
this._el.hidden = true;
|
||||||
this._panel.querySelector(".notes-panel__modes").hidden = false;
|
|
||||||
this._form.hidden = true;
|
|
||||||
this._newName.hidden = true;
|
this._newName.hidden = true;
|
||||||
this._newName.value = "";
|
this._newName.value = "";
|
||||||
this._updateAreaUi();
|
if (viaCancel) this._onCancel?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _populateTargets() {
|
async _populateTargets() {
|
||||||
@@ -240,7 +91,7 @@ export class NotesControl {
|
|||||||
|
|
||||||
async _save() {
|
async _save() {
|
||||||
const text = this._text.value.trim();
|
const text = this._text.value.trim();
|
||||||
if (!text || !this._pendingGeometry) return;
|
if (!text || !this._geometry) return;
|
||||||
|
|
||||||
const isNewLayer = this._target.value === NEW_LAYER_VALUE;
|
const isNewLayer = this._target.value === NEW_LAYER_VALUE;
|
||||||
const name = isNewLayer ? this._newName.value.trim() : "";
|
const name = isNewLayer ? this._newName.value.trim() : "";
|
||||||
@@ -251,32 +102,87 @@ export class NotesControl {
|
|||||||
|
|
||||||
const feature = {
|
const feature = {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: { name: text, category: "Note", time: new Date().toISOString() },
|
properties: { id: generateNoteId(), name: text, category: "Note", time: new Date().toISOString() },
|
||||||
geometry: this._pendingGeometry,
|
geometry: this._geometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = new URLSearchParams({ feature: JSON.stringify(feature) });
|
const body = new URLSearchParams({ feature: JSON.stringify(feature) });
|
||||||
if (isNewLayer) body.set("name", name);
|
if (isNewLayer) body.set("name", name);
|
||||||
else body.set("path", this._target.value);
|
else body.set("path", this._target.value);
|
||||||
|
|
||||||
const saveButton = this._container.querySelector(".notes-panel__save");
|
this._saveButton.disabled = true;
|
||||||
saveButton.disabled = true;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/gpx/notes", { method: "POST", body });
|
const response = await fetch("/api/gpx/notes", { method: "POST", body });
|
||||||
if (!response.ok) throw new Error(`save failed: ${response.status}`);
|
if (!response.ok) throw new Error(`save failed: ${response.status}`);
|
||||||
notifyGpxStoreChanged();
|
notifyGpxStoreChanged();
|
||||||
this._clearArea();
|
this.close();
|
||||||
this._resetForm();
|
this._onSaved?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert("Could not save the note — check your connection and try again.");
|
window.alert("Could not save the note — check your connection and try again.");
|
||||||
} finally {
|
} finally {
|
||||||
saveButton.disabled = false;
|
this._saveButton.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCollection() {
|
// A quick "pin it with a note" tool — click the map, write a short note,
|
||||||
return { type: "FeatureCollection", features: [] };
|
// pick which .geojson "layer" file it joins (or start a new one). Point
|
||||||
|
// only: for a distance/area shape, draw it with the Measure tool and use
|
||||||
|
// its own "Save as note" button instead of duplicating that click-to-place
|
||||||
|
// UX here — see measure.js.
|
||||||
|
export class NotesControl {
|
||||||
|
constructor() {
|
||||||
|
this._active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(map) {
|
||||||
|
this._map = map;
|
||||||
|
|
||||||
|
this._container = document.createElement("div");
|
||||||
|
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group notes-ctrl";
|
||||||
|
this._container.innerHTML = `
|
||||||
|
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
|
||||||
|
<div class="notes-panel" hidden>
|
||||||
|
<p class="notes-panel__hint">Click the map to place a note.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this._button = this._container.querySelector(".notes-ctrl__button");
|
||||||
|
this._panel = this._container.querySelector(".notes-panel");
|
||||||
|
this._hint = this._container.querySelector(".notes-panel__hint");
|
||||||
|
|
||||||
|
this._form = new NoteSaveForm(this._panel, {
|
||||||
|
onSaved: () => (this._hint.hidden = false),
|
||||||
|
onCancel: () => (this._hint.hidden = false),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._button.addEventListener("click", () => this._setActive(!this._active));
|
||||||
|
|
||||||
|
this._onClick = (event) => {
|
||||||
|
if (!this._active || this._form.isOpen) return;
|
||||||
|
this._hint.hidden = true;
|
||||||
|
this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] });
|
||||||
|
};
|
||||||
|
map.on("click", this._onClick);
|
||||||
|
|
||||||
|
return this._container;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove() {
|
||||||
|
this._map.off("click", this._onClick);
|
||||||
|
this._container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultPosition() {
|
||||||
|
return "top-left";
|
||||||
|
}
|
||||||
|
|
||||||
|
_setActive(active) {
|
||||||
|
this._active = active;
|
||||||
|
this._button.classList.toggle("active", active);
|
||||||
|
this._panel.hidden = !active;
|
||||||
|
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
||||||
|
if (!active) this._form.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function geojsonFiles(node, results = []) {
|
function geojsonFiles(node, results = []) {
|
||||||
|
|||||||
+53
-2
@@ -230,6 +230,57 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal 404, response.status
|
assert_equal 404, response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_notes_update_requires_login
|
||||||
|
assert_equal 401, request.put("/api/gpx/notes", params: { feature: sample_note_feature, path: "x.geojson", id: "a" }).status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_notes_update_edits_the_matching_note
|
||||||
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Original", id: "a"), name: "My Notes" }, **logged_in_env))
|
||||||
|
|
||||||
|
response = request.put(
|
||||||
|
"/api/gpx/notes",
|
||||||
|
params: { feature: sample_note_feature("Edited", id: "a"), path: created["path"], id: "a" },
|
||||||
|
**logged_in_env,
|
||||||
|
)
|
||||||
|
assert_equal 200, response.status
|
||||||
|
|
||||||
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(created["path"])}"))["features"]
|
||||||
|
assert_equal ["Edited"], features.map { |f| f["properties"]["name"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_notes_update_missing_note_is_404
|
||||||
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Original", id: "a"), name: "My Notes" }, **logged_in_env))
|
||||||
|
|
||||||
|
response = request.put(
|
||||||
|
"/api/gpx/notes",
|
||||||
|
params: { feature: sample_note_feature("Edited", id: "nope"), path: created["path"], id: "nope" },
|
||||||
|
**logged_in_env,
|
||||||
|
)
|
||||||
|
assert_equal 404, response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_notes_delete_requires_login
|
||||||
|
assert_equal 401, request.delete("/api/gpx/notes", params: { path: "x.geojson", id: "a" }).status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_notes_delete_removes_the_matching_note
|
||||||
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot", id: "a"), name: "My Notes" }, **logged_in_env))
|
||||||
|
request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate", id: "b"), path: created["path"] }, **logged_in_env)
|
||||||
|
|
||||||
|
response = request.delete("/api/gpx/notes", params: { path: created["path"], id: "a" }, **logged_in_env)
|
||||||
|
assert_equal 200, response.status
|
||||||
|
|
||||||
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(created["path"])}"))["features"]
|
||||||
|
assert_equal ["Narrow gate"], features.map { |f| f["properties"]["name"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_notes_delete_missing_note_is_404
|
||||||
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot", id: "a"), name: "My Notes" }, **logged_in_env))
|
||||||
|
|
||||||
|
response = request.delete("/api/gpx/notes", params: { path: created["path"], id: "nope" }, **logged_in_env)
|
||||||
|
assert_equal 404, response.status
|
||||||
|
end
|
||||||
|
|
||||||
# --- LiveTrack webhook ---
|
# --- LiveTrack webhook ---
|
||||||
|
|
||||||
def test_livetrack_webhook_requires_the_configured_secret
|
def test_livetrack_webhook_requires_the_configured_secret
|
||||||
@@ -268,10 +319,10 @@ class AppTest < Minitest::Test
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def sample_note_feature(name = "Turning spot")
|
def sample_note_feature(name = "Turning spot", id: nil)
|
||||||
JSON.generate(
|
JSON.generate(
|
||||||
"type" => "Feature",
|
"type" => "Feature",
|
||||||
"properties" => { "name" => name, "category" => "Note" },
|
"properties" => { "name" => name, "category" => "Note", "id" => id },
|
||||||
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] },
|
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] },
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
+36
-2
@@ -205,10 +205,44 @@ class GpxStoreTest < Minitest::Test
|
|||||||
assert_equal "notes.geojson", path
|
assert_equal "notes.geojson", path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_update_feature_replaces_only_the_matching_id
|
||||||
|
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
|
||||||
|
@store.append_feature(feature: sample_feature("Narrow gate", id: "b"), path: path)
|
||||||
|
|
||||||
|
@store.update_feature(path: path, id: "a", feature: sample_feature("Turning spot (edited)", id: "a"))
|
||||||
|
|
||||||
|
names = JSON.parse(@store.geojson(path))["features"].map { |f| f["properties"]["name"] }
|
||||||
|
assert_equal ["Turning spot (edited)", "Narrow gate"], names
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_update_feature_missing_file_raises_enoent
|
||||||
|
assert_raises(Errno::ENOENT) { @store.update_feature(path: "nope.geojson", id: "a", feature: sample_feature("x", id: "a")) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_update_feature_missing_id_raises_enoent
|
||||||
|
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
|
||||||
|
assert_raises(Errno::ENOENT) { @store.update_feature(path: path, id: "nope", feature: sample_feature("x", id: "a")) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_delete_feature_removes_only_the_matching_id
|
||||||
|
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
|
||||||
|
@store.append_feature(feature: sample_feature("Narrow gate", id: "b"), path: path)
|
||||||
|
|
||||||
|
@store.delete_feature(path: path, id: "a")
|
||||||
|
|
||||||
|
names = JSON.parse(@store.geojson(path))["features"].map { |f| f["properties"]["name"] }
|
||||||
|
assert_equal ["Narrow gate"], names
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_delete_feature_missing_id_raises_enoent
|
||||||
|
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
|
||||||
|
assert_raises(Errno::ENOENT) { @store.delete_feature(path: path, id: "nope") }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def sample_feature(name)
|
def sample_feature(name, id: nil)
|
||||||
{ "type" => "Feature", "properties" => { "name" => name, "category" => "Note" },
|
{ "type" => "Feature", "properties" => { "name" => name, "category" => "Note", "id" => id },
|
||||||
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] } }
|
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user