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 = `
${NOTE_PIN_ICON}
`;
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) => `${escapeText(file.path)} `).join("") +
`+ New layer… `;
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(/