Markers: editable, manual x/y/z popup placement, drop heightmap auto-derive

Markers now carry a caller-supplied x/y/z instead of an auto-resolved
terrain height: clicking the map opens a real Leaflet popup pre-filled
with the clicked x/z, a default y of 60, and a random color, all
editable before saving. The same popup now also opens for editing an
existing marker (new updateMarker/PATCH /api/markers/:markerId, TDD'd
in markers.test.ts/index.test.ts). Removes the now-unused
resolveHeight/GET /api/height machinery. Linked-account markers
already synced server-side via the markers table, so cross-device
sync falls out of the existing loadMarkers()-on-init flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-08 19:43:21 +02:00
parent 66fe2ffb7e
commit b2f9b6c0e9
10 changed files with 304 additions and 229 deletions
+6
View File
@@ -0,0 +1,6 @@
// `rng` is injectable (defaults to Math.random) so this is deterministically testable without
// stubbing the global — see colors.test.ts.
export function randomHexColor(rng = Math.random) {
const value = Math.floor(rng() * 0x1000000) & 0xffffff;
return "#" + value.toString(16).padStart(6, "0");
}
+11
View File
@@ -0,0 +1,11 @@
import { test, expect } from "bun:test";
import { randomHexColor } from "./colors.js";
test("produces a well-formed 6-digit hex color string", () => {
expect(randomHexColor()).toMatch(/^#[0-9a-f]{6}$/);
});
test("is deterministic for an injected rng, spanning the full range", () => {
expect(randomHexColor(() => 0)).toBe("#000000");
expect(randomHexColor(() => 0.5)).toBe("#800000");
});
+104 -61
View File
@@ -8,6 +8,9 @@
// Marker world<->latlng conversion lives in coords.js (see its doc comment for the derivation)
// — pulled out into its own module so it's unit-testable without a browser (see coords.test.ts).
import { worldToLatLng, latLngToWorld } from "./coords.js";
import { randomHexColor } from "./colors.js";
const DEFAULT_MARKER_HEIGHT = 60;
// Session handling: the session token from /api/link/redeem is kept in localStorage and sent
// back via the X-MCMapper-Session header / a WS message field, not an httpOnly cookie — see
@@ -33,9 +36,12 @@ function mapmapper() {
markers: [],
markerLayer: null,
placingMarker: false,
pendingMarker: null, // {x, z} — set by a map click while placingMarker is true
markerNameInput: "",
markerColorInput: "#3391ff",
// Backing state for the click-to-place/edit popup form (see openMarkerForm). `mode` is null
// when no form is open, "create" or "edit" otherwise — the same form/popup is reused for
// both, per the user's ask that markers be editable too, not just placeable.
markerForm: { mode: null, id: null, x: 0, y: DEFAULT_MARKER_HEIGHT, z: 0, name: "", color: "#3391ff" },
markerFormEl: null, // captured once in init() — see openMarkerForm's doc comment for why
markerPopup: null,
markerStatus: "",
async init() {
@@ -48,6 +54,13 @@ function mapmapper() {
this.markerLayer = L.layerGroup().addTo(this.leaflet);
this.leaflet.on("click", (e) => this.onMapClick(e));
// Captured once, up front: Leaflet physically moves this node in and out of its popup
// pane's DOM on every open/close (see openMarkerForm), which would break Alpine's $refs
// treewalk (it only finds elements still attached under the root x-data element) if we
// re-queried $refs on every open. The Node reference itself — and Alpine's bindings on
// it — survive being detached/reattached, so grabbing it once here is safe.
this.markerFormEl = this.$refs.markerFormEl;
if (this.server) {
L.tileLayer(`/api/tiles/${this.server.id}/0/{z}/{x}/{y}.png`, {
tileSize: 256,
@@ -65,25 +78,104 @@ function mapmapper() {
}
},
// Marker placement is 2D-only (see the plan's marker feature section) — a click on this
// Leaflet map while `placingMarker` is on just records the clicked world x/z; `y` is always
// resolved server-side from the chunk heightmap (see confirmMarker), never picked here.
// Marker placement is 2D-click-to-open (see the plan's marker feature section) — a click on
// this Leaflet map while `placingMarker` is on opens the popup form pre-filled with the
// clicked world x/z, a default height of DEFAULT_MARKER_HEIGHT, and a random color; the
// player can freely edit any of x/y/z before saving (no server-side height lookup anymore).
onMapClick(e) {
if (!this.placingMarker) return;
this.placingMarker = false;
this.pendingMarker = latLngToWorld(e.latlng);
this.markerNameInput = "";
this.markerStatus = "";
const { x, z } = latLngToWorld(e.latlng);
this.openMarkerForm({ mode: "create", id: null, x, y: DEFAULT_MARKER_HEIGHT, z, name: "", color: randomHexColor() }, e.latlng);
},
togglePlacingMarker() {
this.placingMarker = !this.placingMarker;
if (!this.placingMarker) this.pendingMarker = null;
if (!this.placingMarker) this.cancelMarkerForm();
},
cancelMarker() {
this.pendingMarker = null;
editMarker(marker) {
this.openMarkerForm(
{ mode: "edit", id: marker.id, x: marker.x, y: marker.y, z: marker.z, name: marker.name, color: marker.color },
worldToLatLng(marker.x, marker.z),
);
},
/**
* Opens a real Leaflet popup (per the user's ask — "open a pop-up") anchored at `latlng`,
* reusing the single `markerFormEl` node (see init()'s doc comment on why it's captured
* once) for both placing a new marker and editing an existing one — `markerForm.mode`
* decides which save behavior `saveMarkerForm` takes.
*/
openMarkerForm(form, latlng) {
this.markerForm = form;
this.markerStatus = "";
this.markerPopup = L.popup({ minWidth: 220 })
.setLatLng(latlng)
.setContent(this.markerFormEl)
.openOn(this.leaflet);
},
cancelMarkerForm() {
if (this.markerPopup) this.leaflet.closePopup(this.markerPopup);
this.markerPopup = null;
this.markerForm = { mode: null, id: null, x: 0, y: DEFAULT_MARKER_HEIGHT, z: 0, name: "", color: "#3391ff" };
this.markerStatus = "";
},
/**
* Linked accounts persist markers server-side (Postgres, via markers.ts) so the same list
* follows them across devices after re-linking there; anonymous visitors keep markers in
* browser localStorage only (see loadLocalMarkers/saveLocalMarkers). Handles both create and
* edit — see markerForm.mode.
*/
async saveMarkerForm() {
const name = this.markerForm.name.trim();
if (!name) {
this.markerStatus = "name is required";
return;
}
const { mode, id, x, y, z, color } = this.markerForm;
if (mode === "create") {
if (this.account && this.sessionToken) {
const res = await fetch("/api/markers", {
method: "POST",
headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken },
body: JSON.stringify({ serverId: this.server.id, dimension: 0, x, y, z, name, color }),
});
const data = await res.json();
if (!data.ok) {
this.markerStatus = data.error;
return;
}
this.markers.push(data.marker);
} else {
const marker = { id: crypto.randomUUID(), serverId: this.server.id, dimension: 0, x, y, z, name, color };
this.markers.push(marker);
this.saveLocalMarkers(this.markers);
}
} else if (mode === "edit") {
if (this.account && this.sessionToken) {
const res = await fetch(`/api/markers/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken },
body: JSON.stringify({ x, y, z, name, color }),
});
const data = await res.json();
if (!data.ok) {
this.markerStatus = data.error;
return;
}
this.markers = this.markers.map((m) => (m.id === id ? data.marker : m));
} else {
this.markers = this.markers.map((m) => (m.id === id ? { ...m, x, y, z, name, color } : m));
this.saveLocalMarkers(this.markers);
}
}
this.cancelMarkerForm();
this.renderMarkerLayer();
},
localMarkerStorageKey() {
@@ -128,55 +220,6 @@ function mapmapper() {
}
},
/**
* Linked accounts persist markers server-side (Postgres, via markers.ts — `y` auto-derived
* there from the chunk store's heightmap). Anonymous visitors keep markers in localStorage
* only, but still need a real `y`, so they hit the unauthenticated /api/height lookup
* instead — see markers.ts's resolveHeight doc comment for why that route exists.
*/
async confirmMarker() {
const name = this.markerNameInput.trim();
if (!name || !this.pendingMarker) return;
const { x, z } = this.pendingMarker;
if (this.account && this.sessionToken) {
const res = await fetch("/api/markers", {
method: "POST",
headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken },
body: JSON.stringify({ serverId: this.server.id, dimension: 0, x, z, name, color: this.markerColorInput }),
});
const data = await res.json();
if (!data.ok) {
this.markerStatus = data.error;
return;
}
this.markers.push(data.marker);
} else {
const res = await fetch(`/api/height/${this.server.id}/0/${x}/${z}`);
if (!res.ok) {
this.markerStatus = "column_not_mapped";
return;
}
const { height } = await res.json();
const marker = {
id: crypto.randomUUID(),
serverId: this.server.id,
dimension: 0,
x,
y: height,
z,
name,
color: this.markerColorInput,
};
this.markers.push(marker);
this.saveLocalMarkers(this.markers);
}
this.pendingMarker = null;
this.markerStatus = "";
this.renderMarkerLayer();
},
async deleteMarker(marker) {
if (this.account && this.sessionToken) {
await fetch(`/api/markers/${marker.id}`, {