import { NOTE_PIN_ICON } from "/js/icons.js"; import { notifyGpxStoreChanged } from "/js/panels.js"; 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 // out a few corners and Finish (Area mode), write a short note, pick which // .geojson "layer" file it joins (or start a new one). Saved server-side // (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, // same popup — public/js/gpx.js's _addLayers renders a Polygon feature the // 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) { this._map = map; this._container = document.createElement("div"); this._container.className = "maplibregl-ctrl maplibregl-ctrl-group notes-ctrl"; this._container.innerHTML = ` `; this._button = this._container.querySelector(".notes-ctrl__button"); this._panel = this._container.querySelector(".notes-panel"); 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._text = this._container.querySelector(".notes-panel__text"); 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._newName.hidden = this._target.value !== NEW_LAYER_VALUE; if (!this._newName.hidden) this._newName.focus(); }); this._container.querySelector(".notes-panel__save").addEventListener("click", () => this._save()); this._container.querySelector(".notes-panel__cancel").addEventListener("click", () => this._resetForm()); 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() { 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._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.focus(); await this._populateTargets(); } _resetForm() { this._pendingGeometry = null; this._hint.hidden = false; this._panel.querySelector(".notes-panel__modes").hidden = false; this._form.hidden = true; this._newName.hidden = true; this._newName.value = ""; this._updateAreaUi(); } async _populateTargets() { const previousValue = this._target.value; let files = []; try { const tree = await (await fetch("/api/gpx/tree")).json(); files = geojsonFiles(tree); } catch (err) { // Tree fetch failed — target select just falls back to "new layer" // only, still usable. } this._target.innerHTML = files.map((file) => ``).join("") + ``; this._target.value = files.some((file) => file.path === previousValue) ? previousValue : NEW_LAYER_VALUE; this._newName.hidden = this._target.value !== NEW_LAYER_VALUE; } async _save() { const text = this._text.value.trim(); if (!text || !this._pendingGeometry) return; const isNewLayer = this._target.value === NEW_LAYER_VALUE; const name = isNewLayer ? this._newName.value.trim() : ""; if (isNewLayer && !name) { this._newName.focus(); return; } const feature = { type: "Feature", properties: { name: text, category: "Note", time: new Date().toISOString() }, geometry: this._pendingGeometry, }; const body = new URLSearchParams({ feature: JSON.stringify(feature) }); if (isNewLayer) body.set("name", name); else body.set("path", this._target.value); const saveButton = this._container.querySelector(".notes-panel__save"); saveButton.disabled = true; try { const response = await fetch("/api/gpx/notes", { method: "POST", body }); if (!response.ok) throw new Error(`save failed: ${response.status}`); notifyGpxStoreChanged(); this._clearArea(); this._resetForm(); } catch (err) { window.alert("Could not save the note — check your connection and try again."); } finally { saveButton.disabled = false; } } } function emptyCollection() { return { type: "FeatureCollection", features: [] }; } function geojsonFiles(node, results = []) { if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) { results.push({ path: node.path }); } (node.children || []).forEach((child) => geojsonFiles(child, results)); return results; } function escapeAttr(value) { return value.replace(/&/g, "&").replace(/"/g, """); } function escapeText(value) { return value.replace(/&/g, "&").replace(/