Phase 4: web markers and JourneyMap/Xaero waypoint chat sharing

Linked accounts can place 2D markers on the map (y auto-derived from the
chunk store's heightmap); anonymous visitors keep a localStorage-only
list via the same height lookup. Sharing a marker forwards a structured
payload to the mod over its WS connection, which builds the actual
chat text (see MCMapper-Mod for the JourneyMap/Xaero formatting).

Built test-first per the project's TDD workflow.

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:21:07 +02:00
parent 6770bc23cc
commit 66fe2ffb7e
13 changed files with 836 additions and 10 deletions
+1
View File
@@ -13,6 +13,7 @@ const app = new Elysia()
.get("/health", () => ({ status: "ok" }))
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
.get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js")))
.get("/js/coords.js", () => Bun.file(join(import.meta.dir, "public/js/coords.js")))
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
.get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js")))
.listen(Number(process.env.PORT ?? 3001));
+24
View File
@@ -0,0 +1,24 @@
// World-block <-> Leaflet-latlng conversion for the 2D map (marker placement, Phase 4).
//
// Leaflet's CRS.Simple maps a latlng to a pixel point as (lng, -lat) — see L.CRS.Simple's
// `transformation`. The `/api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY` route takes
// whatever {x}/{y} Leaflet requests for the tiles on screen and treats {x} as chunkX directly,
// {y} as -chunkZ (see that route's comment in api/src/index.ts, and its test in index.test.ts
// which pins chunkZ=-3 to a requested {y} of 3). Working backward from that pins down the only
// linear world<->latlng mapping consistent with how tiles are actually placed:
//
// lat = 16 * blockZ
// lng = 16 * blockX
//
// (16 = 256px tile size / 16 blocks per chunk.) This is zoom-independent — a latlng always
// names the same world position regardless of the current view zoom — plain objects are used
// instead of Leaflet's `L.LatLng` class so this stays testable without a browser/Leaflet.
export const BLOCKS_PER_TILE = 16;
export function worldToLatLng(x, z) {
return { lat: z * BLOCKS_PER_TILE, lng: x * BLOCKS_PER_TILE };
}
export function latLngToWorld(latlng) {
return { x: Math.round(latlng.lng / BLOCKS_PER_TILE), z: Math.round(latlng.lat / BLOCKS_PER_TILE) };
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, test, expect } from "bun:test";
import { worldToLatLng, latLngToWorld, BLOCKS_PER_TILE } from "./coords.js";
// This mapping is derived from (and pinned to) the /api/tiles route's tested contract — see
// index.test.ts's "negates tileY back to the stored chunkZ" case: chunkZ=-3 is requested by
// Leaflet as tileY=3. See coords.js's doc comment for the full derivation.
describe("worldToLatLng / latLngToWorld", () => {
test("round-trips arbitrary world coordinates through latlng and back", () => {
const world = { x: 137, z: -284 };
const latlng = worldToLatLng(world.x, world.z);
expect(latLngToWorld(latlng)).toEqual(world);
});
test("scales by BLOCKS_PER_TILE (16 px per block at native zoom)", () => {
const latlng = worldToLatLng(10, 20);
expect(latlng.lng).toBe(10 * BLOCKS_PER_TILE);
expect(latlng.lat).toBe(20 * BLOCKS_PER_TILE);
});
test("matches the tested /api/tiles contract: chunk (0,-3) requests tileY=3", () => {
// Use the chunk's exact block boundary (z=-48, i.e. chunk -3 * 16) rather than an interior
// point — floor(-x/16) isn't -floor(x/16) for non-multiples of 16, so an interior point would
// spuriously fail this check without indicating any actual bug in the block<->tile mapping.
const latlng = worldToLatLng(0, -48);
const impliedTileY = Math.floor(-latlng.lat / 256); // Leaflet's own CRS.Simple pixel math
expect(impliedTileY).toBe(3);
});
test("rounds fractional latlng back to the nearest block", () => {
expect(latLngToWorld({ lat: 16.4, lng: -15.6 })).toEqual({ x: -1, z: 1 });
});
});
+160 -7
View File
@@ -1,15 +1,14 @@
// Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3). Marker tool and admin panel are
// still later phases.
// Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3) + markers (Phase 4). Admin panel
// is still a later phase.
//
// Tiles are one Minecraft chunk (16x16 blocks) each, upscaled to 256px, at a single native
// zoom level (see api's tile route + worker/src/render/cpu.rs) — Leaflet stretches that one
// native zoom to whatever zoom the user picks via `maxNativeZoom`/`minNativeZoom`.
//
// Coordinate mapping: Leaflet's CRS.Simple treats [lat, lng] as [y, x] with y growing upward
// on screen. Minecraft's Z grows south (visually "down" on a conventional north-up map), so
// tile y = -chunkZ here; the api negates it back to chunkZ when looking up the tile pointer
// (see api/src/index.ts's /api/tiles route comment).
//
// 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";
// 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
// api/src/link.ts's doc comment for why that's a deliberate Phase 3 MVP tradeoff.
@@ -31,6 +30,14 @@ function mapmapper() {
account: null,
nickname: localStorage.getItem(NICKNAME_STORAGE_KEY) || "",
markers: [],
markerLayer: null,
placingMarker: false,
pendingMarker: null, // {x, z} — set by a map click while placingMarker is true
markerNameInput: "",
markerColorInput: "#3391ff",
markerStatus: "",
async init() {
const servers = await fetch("/api/servers").then((r) => r.json());
this.loading = false;
@@ -38,6 +45,8 @@ function mapmapper() {
this.leaflet = L.map("map", { crs: L.CRS.Simple, minZoom: -4, maxZoom: 6 });
this.leaflet.setView([0, 0], 0);
this.markerLayer = L.layerGroup().addTo(this.leaflet);
this.leaflet.on("click", (e) => this.onMapClick(e));
if (this.server) {
L.tileLayer(`/api/tiles/${this.server.id}/0/{z}/{x}/{y}.png`, {
@@ -51,10 +60,147 @@ function mapmapper() {
}).addTo(this.leaflet);
await this.loadAccount();
await this.loadMarkers();
this.connectChat();
}
},
// 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.
onMapClick(e) {
if (!this.placingMarker) return;
this.placingMarker = false;
this.pendingMarker = latLngToWorld(e.latlng);
this.markerNameInput = "";
this.markerStatus = "";
},
togglePlacingMarker() {
this.placingMarker = !this.placingMarker;
if (!this.placingMarker) this.pendingMarker = null;
},
cancelMarker() {
this.pendingMarker = null;
this.markerStatus = "";
},
localMarkerStorageKey() {
return `mcmapper_markers_${this.server.id}`;
},
loadLocalMarkers() {
try {
return JSON.parse(localStorage.getItem(this.localMarkerStorageKey()) || "[]");
} catch {
return [];
}
},
saveLocalMarkers(markers) {
localStorage.setItem(this.localMarkerStorageKey(), JSON.stringify(markers));
},
async loadMarkers() {
if (this.account && this.sessionToken) {
const res = await fetch(`/api/markers/${this.server.id}`, {
headers: { "X-MCMapper-Session": this.sessionToken },
});
this.markers = res.ok ? await res.json() : [];
} else {
this.markers = this.loadLocalMarkers();
}
this.renderMarkerLayer();
},
renderMarkerLayer() {
this.markerLayer.clearLayers();
for (const marker of this.markers) {
L.circleMarker(worldToLatLng(marker.x, marker.z), {
radius: 7,
color: marker.color,
fillColor: marker.color,
fillOpacity: 0.9,
})
.bindTooltip(marker.name)
.addTo(this.markerLayer);
}
},
/**
* 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}`, {
method: "DELETE",
headers: { "X-MCMapper-Session": this.sessionToken },
});
} else {
this.saveLocalMarkers(this.loadLocalMarkers().filter((m) => m.id !== marker.id));
}
this.markers = this.markers.filter((m) => m.id !== marker.id);
this.renderMarkerLayer();
},
// Only linked accounts can share — an anonymous marker never has a server-side row for
// shareMarkerToChat to look up (see markers.ts). The UI hides the share button accordingly.
async shareMarker(marker) {
const res = await fetch(`/api/markers/${marker.id}/share`, {
method: "POST",
headers: { "X-MCMapper-Session": this.sessionToken },
});
const data = await res.json();
this.markerStatus = data.ok ? `shared "${marker.name}" to chat` : data.error;
},
async loadAccount() {
if (!this.sessionToken) return;
const res = await fetch("/api/me", { headers: { "X-MCMapper-Session": this.sessionToken } });
@@ -112,6 +258,7 @@ function mapmapper() {
this.account = data.account;
this.linkStatus = `linked as ${data.account.username}`;
this.linkCode = "";
await this.loadMarkers();
} else {
this.linkStatus = data.error;
}
@@ -124,6 +271,12 @@ function mapmapper() {
localStorage.removeItem(SESSION_STORAGE_KEY);
this.sessionToken = null;
this.account = null;
await this.loadMarkers();
},
};
}
// Loaded as `type="module"` (see index.pug) so the coords.js import above works — that takes
// `mapmapper` out of the global scope Alpine's `x-data="mapmapper()"` expects, so it's put back
// explicitly here.
window.mapmapper = mapmapper;
+31 -1
View File
@@ -23,6 +23,36 @@ html(lang="en")
div.flex-1.flex.overflow-hidden
div#map.flex-1
aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server")
div.border-b.border-neutral-700.p-2.space-y-2(style="max-height: 40%; overflow-y: auto;")
div.flex.items-center.justify-between
h2.text-sm.font-semibold Markers
button.px-2.py-1.rounded.text-xs(
x-bind:class="placingMarker ? 'bg-amber-600' : 'bg-neutral-700'"
x-on:click="togglePlacingMarker"
x-text="placingMarker ? 'click the map…' : '+ place marker'")
template(x-if="pendingMarker")
div.space-y-1.bg-neutral-900.rounded.p-2
p.text-xs.text-neutral-400(x-text="'at ' + pendingMarker.x + ', ' + pendingMarker.z")
div.flex.gap-1
input.flex-1.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" placeholder="marker name" x-model="markerNameInput"
x-on:keydown.enter="confirmMarker")
input.w-10(type="color" x-model="markerColorInput")
div.flex.gap-1
button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="confirmMarker") Save
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarker") Cancel
template(x-for="marker in markers" x-bind:key="marker.id")
div.flex.items-center.gap-2.text-sm
span.inline-block.w-3.h-3.rounded-full.flex-shrink-0(x-bind:style="'background:' + marker.color")
span.flex-1.truncate(x-text="marker.name")
span.text-xs.text-neutral-500(x-text="marker.x + ', ' + marker.y + ', ' + marker.z")
button.text-xs.underline(x-show="account" x-on:click="shareMarker(marker)") share
button.text-xs.text-red-400(x-on:click="deleteMarker(marker)") ×
p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus")
div.flex-1.overflow-y-auto.p-2.space-y-1(x-ref="chatLog")
template(x-for="msg in chatMessages" x-bind:key="msg.id")
p.text-sm.break-words
@@ -57,4 +87,4 @@ html(lang="en")
button.px-2.py-1.bg-neutral-700.rounded.text-sm(x-on:click="redeemLink") Link
p.text-xs.text-amber-400(x-show="linkStatus" x-text="linkStatus")
script(src="/js/map.js")
script(type="module" src="/js/map.js")