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:
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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}`, {
|
||||
|
||||
Reference in New Issue
Block a user