import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { NOTE_PIN_ICON, POI_ICONS } from "/js/icons.js"; import { notifyGpxStoreChanged } from "/js/panels.js"; // Human labels for the icon picker's title attribute — POI_ICONS' own keys // are internal (OSM-tag-shaped) ids, not display text. const ICON_LABELS = { amenity_parking: "Parking", tourism_camp_site: "Camp site", leisure_picnic_table: "Picnic table", amenity_fuel: "Fuel", tourism_hotel: "Lodging", forest: "Forest", sport_cycling: "Cycling", amenity_drinking_water: "Drinking water", amenity_sanitary_dump_station: "Dump station", amenity_toilets: "Toilets", shop_gas: "Gas/LPG refill", amenity_electricity: "Electricity", shop_laundry: "Laundry", leisure_playground: "Playground", tourism_viewpoint: "Viewpoint", leisure_swimming: "Swimming", leisure_fishing: "Fishing", sport_climbing: "Climbing", route_hiking: "Hiking", shop_supermarket: "Supermarket", shop_convenience: "Convenience store", }; const NEW_LAYER_VALUE = "__new__"; // Every hand-drawn note (from here or measure.js's "Save as note") gets one // of these — the only stable way to find a specific feature again for // editing/deleting later (see GpxStore#update_feature/#delete_feature): // array position shifts as other notes are added/removed, and the note's // own text is exactly the thing an edit changes. export function generateNoteId() { return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; } // The "note text + which .geojson layer" form, shared by NotesControl (a // 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, onIconChange } = {}) { this._onSaved = onSaved; this._onCancel = onCancel; this._onIconChange = onIconChange; this._el = document.createElement("div"); this._el.className = "notes-panel__form"; this._el.hidden = true; this._el.innerHTML = `
${Object.keys(POI_ICONS) .map( (key) => ``, ) .join("")}
`; container.appendChild(this._el); this._title = this._el.querySelector(".notes-panel__title"); this._iconPicker = this._el.querySelector(".notes-panel__icons"); this._body = this._el.querySelector(".notes-panel__body"); this._target = this._el.querySelector(".notes-panel__target"); this._newName = this._el.querySelector(".notes-panel__new-name"); this._saveButton = this._el.querySelector(".notes-panel__save"); this._selectedIcon = ""; this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((button) => { button.addEventListener("click", () => { this._selectedIcon = button.dataset.icon; this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((b) => b.classList.toggle("active", b === button)); this._onIconChange?.(this._selectedIcon); }); }); this._target.addEventListener("change", () => { this._newName.hidden = this._target.value !== NEW_LAYER_VALUE; if (!this._newName.hidden) this._newName.focus(); }); this._saveButton.addEventListener("click", () => this._save()); this._el.querySelector(".notes-panel__cancel").addEventListener("click", () => this.close(true)); } get isOpen() { return !this._el.hidden; } async open(geometry) { this._geometry = geometry; this._el.hidden = false; this._title.value = ""; this._body.value = ""; this._selectedIcon = ""; this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((b, i) => b.classList.toggle("active", i === 0)); this._onIconChange?.(this._selectedIcon); // An icon only makes sense on a single point — Measure's "Save as // note" can hand this a LineString/Polygon, which has no one spot for // a badge glyph to sit at. this._iconPicker.hidden = geometry.type !== "Point"; this._title.focus(); await this._populateTargets(); } close(viaCancel = false) { this._geometry = null; this._el.hidden = true; this._newName.hidden = true; this._newName.value = ""; if (viaCancel) this._onCancel?.(); } 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 title = this._title.value.trim(); const details = this._body.value.trim(); if (!title || !this._geometry) 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: { id: generateNoteId(), name: title, category: "Note", time: new Date().toISOString(), ...(details ? { description: details } : {}), ...(this._selectedIcon ? { icon: this._selectedIcon } : {}), }, geometry: this._geometry, }; const requestBody = new URLSearchParams({ feature: JSON.stringify(feature) }); if (isNewLayer) requestBody.set("name", name); else requestBody.set("path", this._target.value); this._saveButton.disabled = true; try { const response = await fetch("/api/gpx/notes", { method: "POST", body: requestBody }); if (!response.ok) throw new Error(`save failed: ${response.status}`); const { path } = await response.json(); notifyGpxStoreChanged(path); this.close(); this._onSaved?.(); } catch (err) { window.alert("Could not save the note — check your connection and try again."); } finally { this._saveButton.disabled = false; } } } // A quick "pin it with a note" tool — click the map, write a short note, // 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; this._marker = null; // where the pending note will land — visible until saved/cancelled } 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._form = new NoteSaveForm(this._panel, { onSaved: () => { this._hint.hidden = false; this._removeMarker(); }, onCancel: () => { this._hint.hidden = false; this._removeMarker(); }, // Keeps the map preview in sync with the icon picker — it used to // always show a plain red pin no matter which icon got picked. onIconChange: (icon) => { if (this._marker) this._marker.getElement().innerHTML = icon && POI_ICONS[icon] ? POI_ICONS[icon] : NOTE_PIN_ICON; }, }); this._button.addEventListener("click", () => this._setActive(!this._active)); this._onClick = (event) => { if (!this._active || this._form.isOpen) return; this._hint.hidden = true; const el = document.createElement("div"); el.className = "notes-panel__preview-marker"; el.innerHTML = NOTE_PIN_ICON; this._marker = new Marker({ element: el }).setLngLat(event.lngLat).addTo(this._map); this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] }); }; map.on("click", this._onClick); return this._container; } _removeMarker() { this._marker?.remove(); this._marker = null; } 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(); this._removeMarker(); } } } 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(/