Add polygon notes (Area mode) to the user overlay tool
notes.js: Point/Area mode toggle mirroring measure.js's UX — Area
mode accumulates clicked vertices with a live dashed-preview polygon
(own small preview source/layers), a Finish button once 3+ corners
are placed, then the same save form as Point mode.
gpx.js's _addLayers now also renders Polygon features from a file's
"lines" source (fill + outline, filtered by geometry-type — the
existing line layer already only draws LineString, so this is just
two more layers on the same source, not a new bucket) with
popup-on-click like a waypoint, not the track line's hover-summary/
profile-panel behavior. Without this a saved polygon wouldn't have
rendered back on reload.
Fixed along the way: .notes-panel__form/.notes-panel__modes both set
their own `display`, which silently wins over the browser's default
`[hidden] { display: none }` (author styles always beat user-agent
ones) — same gotcha .gpx-panel__action[hidden] already has a comment
about. The form was never actually invisible before being opened.
Verified live: real polygon file (fill/outline render, click ->
popup) and the full Area-mode draw flow (mode switch, live preview,
Finish, form) via headless Chrome. Full test suite: 104 runs, 0
failures — one new backend test confirms a Polygon feature round-
trips through POST /api/gpx/notes.
Known gap (noted in TODO.adoc): the category filter matrix only
re-slices the points source, so toggling "Note" off hides point notes
but not polygon ones.
This commit is contained in:
1 parent
f53c74e06f
commit
db58468187
5 files changed
+232
-20
No files matched your search
@@ -40,8 +40,8 @@ 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 with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") — click the map, pick or name a `.geojson` layer, saved into the same GpxStore the Tracks panel already reads (so visibility/popup come for free)
|
* [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
|
||||||
** [ ] Polygon notes (not just points) — needs gpx.js's point/line split (`_addLayers`) taught a third, Polygon-shaped bucket first, or a saved polygon wouldn't render back on reload
|
** [ ] 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
|
||||||
* [ ] 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)
|
||||||
|
|||||||
+47
-1
@@ -774,17 +774,63 @@ html, body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notes-panel__modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__mode {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__mode.active {
|
||||||
|
background: #dbe9ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.notes-panel__hint {
|
.notes-panel__hint {
|
||||||
margin: 0;
|
margin: 0 0 6px;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notes-panel__finish {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a5fb4;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.notes-panel__form {
|
.notes-panel__form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same "author display wins over the UA [hidden] rule" gotcha as
|
||||||
|
.gpx-panel__action[hidden] above — both this and .notes-panel__modes set
|
||||||
|
their own "display", so hiding them needs an explicit override too. */
|
||||||
|
.notes-panel__form[hidden],
|
||||||
|
.notes-panel__modes[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.notes-panel__text {
|
.notes-panel__text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -540,6 +540,29 @@ export class GpxPanelControl {
|
|||||||
});
|
});
|
||||||
this._registerLineInteractivity(`${linesId}-line`);
|
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.
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${linesId}-polygon-fill`,
|
||||||
|
type: "fill",
|
||||||
|
source: linesId,
|
||||||
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
|
paint: { "fill-color": TRACK_COLOR, "fill-opacity": 0.2 },
|
||||||
|
});
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${linesId}-polygon-outline`,
|
||||||
|
type: "line",
|
||||||
|
source: linesId,
|
||||||
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
|
paint: { "line-color": TRACK_COLOR, "line-width": 2 },
|
||||||
|
});
|
||||||
|
this._registerPointInteractivity(`${linesId}-polygon-fill`);
|
||||||
|
|
||||||
// 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).
|
||||||
this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 });
|
this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 });
|
||||||
@@ -704,6 +727,8 @@ export class GpxPanelControl {
|
|||||||
const pointsId = pointsSourceId(path);
|
const pointsId = pointsSourceId(path);
|
||||||
[
|
[
|
||||||
`${linesId}-line`,
|
`${linesId}-line`,
|
||||||
|
`${linesId}-polygon-fill`,
|
||||||
|
`${linesId}-polygon-outline`,
|
||||||
`${pointsId}-clusters`,
|
`${pointsId}-clusters`,
|
||||||
`${pointsId}-cluster-count`,
|
`${pointsId}-cluster-count`,
|
||||||
`${pointsId}-unclustered`,
|
`${pointsId}-unclustered`,
|
||||||
|
|||||||
+144
-16
@@ -2,20 +2,26 @@ 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, write a short note,
|
// A quick "pin it with a note" tool — click the map (Point mode) or click
|
||||||
// pick which .geojson "layer" file it joins (or start a new one). Saved
|
// out a few corners and Finish (Area mode), write a short note, pick which
|
||||||
// server-side (POST /api/gpx/notes) into the same GpxStore everything else
|
// .geojson "layer" file it joins (or start a new one). Saved server-side
|
||||||
// in the Tracks panel already reads from, so a note shows up there with no
|
// (POST /api/gpx/notes) into the same GpxStore everything else in the
|
||||||
|
// Tracks panel already reads from, so a note shows up there with no
|
||||||
// separate rendering path of its own: same tree, same checkbox visibility,
|
// separate rendering path of its own: same tree, same checkbox visibility,
|
||||||
// same popup. Points only for now — a polygon mode would need gpx.js's
|
// same popup — public/js/gpx.js's _addLayers renders a Polygon feature the
|
||||||
// point/line split (public/js/gpx.js's _addLayers) taught a third,
|
// same way it already renders points/lines, no separate code path here.
|
||||||
// Polygon-shaped bucket first, so it's left for a follow-up rather than
|
|
||||||
// saving areas that wouldn't render back once reloaded.
|
|
||||||
export class NotesControl {
|
export class NotesControl {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._active = false;
|
this._active = false;
|
||||||
this._pendingLngLat = null;
|
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) {
|
onAdd(map) {
|
||||||
@@ -26,7 +32,12 @@ export class NotesControl {
|
|||||||
this._container.innerHTML = `
|
this._container.innerHTML = `
|
||||||
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
|
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
|
||||||
<div class="notes-panel" hidden>
|
<div class="notes-panel" hidden>
|
||||||
<p class="notes-panel__hint">Click the map to place a note.</p>
|
<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>
|
<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>
|
||||||
@@ -41,12 +52,17 @@ export class NotesControl {
|
|||||||
this._button = this._container.querySelector(".notes-ctrl__button");
|
this._button = this._container.querySelector(".notes-ctrl__button");
|
||||||
this._panel = this._container.querySelector(".notes-panel");
|
this._panel = this._container.querySelector(".notes-panel");
|
||||||
this._hint = this._container.querySelector(".notes-panel__hint");
|
this._hint = this._container.querySelector(".notes-panel__hint");
|
||||||
|
this._finishButton = this._container.querySelector(".notes-panel__finish");
|
||||||
this._form = this._container.querySelector(".notes-panel__form");
|
this._form = this._container.querySelector(".notes-panel__form");
|
||||||
this._text = this._container.querySelector(".notes-panel__text");
|
this._text = this._container.querySelector(".notes-panel__text");
|
||||||
this._target = this._container.querySelector(".notes-panel__target");
|
this._target = this._container.querySelector(".notes-panel__target");
|
||||||
this._newName = this._container.querySelector(".notes-panel__new-name");
|
this._newName = this._container.querySelector(".notes-panel__new-name");
|
||||||
|
|
||||||
this._button.addEventListener("click", () => this._setActive(!this._active));
|
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();
|
||||||
@@ -55,12 +71,23 @@ export class NotesControl {
|
|||||||
this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm());
|
this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm());
|
||||||
|
|
||||||
this._onClick = (event) => {
|
this._onClick = (event) => {
|
||||||
if (!this._active) return;
|
if (!this._active || !this._form.hidden) return;
|
||||||
this._pendingLngLat = event.lngLat;
|
if (this._mode === "point") {
|
||||||
|
this._pendingGeometry = { type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] };
|
||||||
this._openForm();
|
this._openForm();
|
||||||
|
} else {
|
||||||
|
this._areaPoints.push([event.lngLat.lng, event.lngLat.lat]);
|
||||||
|
this._renderPreview();
|
||||||
|
this._updateAreaUi();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
map.on("click", this._onClick);
|
map.on("click", this._onClick);
|
||||||
|
|
||||||
|
map.on("style.load", () => {
|
||||||
|
if (this._areaPoints.length > 0) this._addPreviewLayers();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._updateHint();
|
||||||
return this._container;
|
return this._container;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +105,105 @@ export class NotesControl {
|
|||||||
this._button.classList.toggle("active", active);
|
this._button.classList.toggle("active", active);
|
||||||
this._panel.hidden = !active;
|
this._panel.hidden = !active;
|
||||||
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
|
||||||
if (!active) this._resetForm();
|
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() {
|
async _openForm() {
|
||||||
this._hint.hidden = true;
|
this._hint.hidden = true;
|
||||||
|
this._finishButton.hidden = true;
|
||||||
|
this._panel.querySelector(".notes-panel__modes").hidden = true;
|
||||||
this._form.hidden = false;
|
this._form.hidden = false;
|
||||||
this._text.value = "";
|
this._text.value = "";
|
||||||
this._text.focus();
|
this._text.focus();
|
||||||
@@ -90,11 +211,13 @@ export class NotesControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_resetForm() {
|
_resetForm() {
|
||||||
this._pendingLngLat = null;
|
this._pendingGeometry = null;
|
||||||
this._hint.hidden = false;
|
this._hint.hidden = false;
|
||||||
|
this._panel.querySelector(".notes-panel__modes").hidden = false;
|
||||||
this._form.hidden = true;
|
this._form.hidden = true;
|
||||||
this._newName.hidden = true;
|
this._newName.hidden = true;
|
||||||
this._newName.value = "";
|
this._newName.value = "";
|
||||||
|
this._updateAreaUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _populateTargets() {
|
async _populateTargets() {
|
||||||
@@ -117,7 +240,7 @@ export class NotesControl {
|
|||||||
|
|
||||||
async _save() {
|
async _save() {
|
||||||
const text = this._text.value.trim();
|
const text = this._text.value.trim();
|
||||||
if (!text || !this._pendingLngLat) return;
|
if (!text || !this._pendingGeometry) 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() : "";
|
||||||
@@ -129,7 +252,7 @@ export class NotesControl {
|
|||||||
const feature = {
|
const feature = {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: { name: text, category: "Note", time: new Date().toISOString() },
|
properties: { name: text, category: "Note", time: new Date().toISOString() },
|
||||||
geometry: { type: "Point", coordinates: [this._pendingLngLat.lng, this._pendingLngLat.lat] },
|
geometry: this._pendingGeometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = new URLSearchParams({ feature: JSON.stringify(feature) });
|
const body = new URLSearchParams({ feature: JSON.stringify(feature) });
|
||||||
@@ -142,6 +265,7 @@ export class NotesControl {
|
|||||||
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._resetForm();
|
this._resetForm();
|
||||||
} 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.");
|
||||||
@@ -151,6 +275,10 @@ export class NotesControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyCollection() {
|
||||||
|
return { type: "FeatureCollection", features: [] };
|
||||||
|
}
|
||||||
|
|
||||||
function geojsonFiles(node, results = []) {
|
function geojsonFiles(node, results = []) {
|
||||||
if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) {
|
if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) {
|
||||||
results.push({ path: node.path });
|
results.push({ path: node.path });
|
||||||
|
|||||||
+14
-1
@@ -176,7 +176,20 @@ class AppTest < Minitest::Test
|
|||||||
assert_equal 400, response.status
|
assert_equal 400, response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- Notes (hand-drawn points, notes.js) ---
|
# --- Notes (hand-drawn points/areas, notes.js) ---
|
||||||
|
|
||||||
|
def test_notes_accepts_a_polygon_feature
|
||||||
|
polygon = JSON.generate(
|
||||||
|
"type" => "Feature",
|
||||||
|
"properties" => { "name" => "Turning area", "category" => "Note" },
|
||||||
|
"geometry" => { "type" => "Polygon", "coordinates" => [[[17.1, 48.1], [17.2, 48.1], [17.2, 48.2], [17.1, 48.1]]] },
|
||||||
|
)
|
||||||
|
response = request.post("/api/gpx/notes", params: { feature: polygon, name: "Areas" }, **logged_in_env)
|
||||||
|
|
||||||
|
assert_equal 200, response.status
|
||||||
|
geometry = json_body(request.get("/api/gpx/file/Areas.geojson"))["features"][0]["geometry"]
|
||||||
|
assert_equal "Polygon", geometry["type"]
|
||||||
|
end
|
||||||
|
|
||||||
def test_notes_requires_login
|
def test_notes_requires_login
|
||||||
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status
|
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status
|
||||||
|
|||||||
Reference in new issue
Block a user