feat: add relay-backed web radar sharing

- publish radar state/bootstrap snapshots to an HTTP relay
- add shared waypoint sync through relay APIs and SSE updates
- add remote Caddy/deploy tooling and mock relay push script
- add static POIs, topo-tile availability checks, and tile-load throttling
- add WASM 3D map engine and Python map data-prep pipeline
- update worn clothing reads to include slot metadata
- add grid controls, render perf HUD, and marker/label scaling tweaks
- remove embedded map resource generation in favor of disk/relay maps
This commit is contained in:
67
2026-06-23 03:11:52 +08:00
parent 7f9a6620f9
commit 361c6baa8f
16 changed files with 539 additions and 147 deletions
+302 -35
View File
@@ -69,6 +69,8 @@ const defaultSettings = {
favoriteLootNames: [],
filterStyles: {},
filterExpanded: {},
gridLineWeight: 2.5,
gridLabelSize: 13,
// v2 UI state
sidebarState: "full",
activeTab: "tab-map",
@@ -139,8 +141,12 @@ const state = {
topoElements: new Map(), // key -> img, O(1) topo lookup
satElements: new Map(), // "z:tx:ty" -> img, O(1) satellite XYZ tile lookup
tilesRafPending: false, // throttle updateVisibleTiles to one RAF per frame
isZooming: false, // true while wheel is spinning; suppresses tile loads
zoomIdleTimer: null, // setTimeout handle for zoom-settle detection
cachedGridKey: null, // detects when grid needs rebuild
cachedGridNode: null, // cached SVG <g> for the grid
sharedWaypoints: [], // relay-backed waypoints visible to all clients
topoAvailable: true, // set false on first topo-tile 404; reset on map change
};
const elements = {
@@ -170,7 +176,6 @@ const elements = {
showLabels: document.getElementById("showLabels"),
showPOIs: document.getElementById("showPOIs"),
showGrid: document.getElementById("showGrid"),
showSatellite: document.getElementById("showSatellite"),
showSatmap: document.getElementById("showSatmap"),
showDistanceRings: document.getElementById("showDistanceRings"),
showMinimap: document.getElementById("showMinimap"),
@@ -194,6 +199,10 @@ const elements = {
labelSettingsReset: document.getElementById("labelSettingsReset"),
textSize: document.getElementById("textSize"),
textSizeValue: document.getElementById("textSizeValue"),
gridLineWeight: document.getElementById("gridLineWeight"),
gridLineWeightValue: document.getElementById("gridLineWeightValue"),
gridLabelSize: document.getElementById("gridLabelSize"),
gridLabelSizeValue: document.getElementById("gridLabelSizeValue"),
minimapEl: document.getElementById("minimapEl"),
minimapImg: document.getElementById("minimapImg"),
minimapCanvas: document.getElementById("minimapCanvas"),
@@ -204,12 +213,72 @@ const elements = {
zoomDisplay: document.getElementById("zoomDisplay"),
ctxMenu: document.getElementById("ctxMenu"),
ctxAddWaypoint: document.getElementById("ctxAddWaypoint"),
ctxAddSharedWaypoint: document.getElementById("ctxAddSharedWaypoint"),
ctxCopyCoords: document.getElementById("ctxCopyCoords"),
toastStack: document.getElementById("toastStack"),
toggle3d: document.getElementById("toggle3d"),
canvas3d: document.getElementById("canvas3d"),
};
// ── Perf instrumentation (enable with ?perf=1) ──────────────────────────────
// Zero-cost when disabled. Answers "is the render loop coupled to data arrival?"
// with numbers: it samples true display fps (a continuous rAF ticker) separately
// from how often render() actually runs and how often snapshots arrive. If
// renders/s ≈ data Hz while display fps stays ~60, rendering is data-coupled.
// Reports once/sec to an on-screen HUD.
const PERF = (() => {
const enabled = params.get("perf") === "1" || params.get("perf") === "true";
if (!enabled) {
const noop = () => {};
return { enabled: false, onSnapshot: noop, onRender: noop };
}
const acc = { snaps: 0, bytes: 0, parseMs: 0, applyMs: 0, renders: 0,
renderMs: 0, renderMax: 0, frames: 0, lastEntities: 0, lastBytes: 0 };
const hud = document.createElement("div");
hud.id = "perfHud";
hud.style.cssText = "position:fixed;top:48px;right:8px;z-index:99999;background:rgba(0,0,0,.82);" +
"color:#9effa0;font:11px/1.45 ui-monospace,Menlo,Consolas,monospace;padding:8px 10px;" +
"border:1px solid #333;border-radius:6px;white-space:pre;pointer-events:none;min-width:190px";
const attach = () => document.body && document.body.appendChild(hud);
if (document.body) attach(); else addEventListener("DOMContentLoaded", attach);
// Continuous rAF ticker — measures real display refresh, independent of data.
const tick = () => { acc.frames++; requestAnimationFrame(tick); };
requestAnimationFrame(tick);
let lastT = performance.now();
setInterval(() => {
const dt = (performance.now() - lastT) / 1000; lastT = performance.now();
const per = (n) => n / dt;
hud.textContent =
"PERF (?perf=1)\n" +
"data " + per(acc.snaps).toFixed(1) + " Hz\n" +
"renders " + per(acc.renders).toFixed(1) + " /s\n" +
"display " + per(acc.frames).toFixed(0) + " fps\n" +
"entities " + acc.lastEntities + "\n" +
"payload " + (acc.lastBytes / 1024).toFixed(1) + " KB\n" +
"parse " + (acc.snaps ? acc.parseMs / acc.snaps : 0).toFixed(2) + " ms\n" +
"apply " + (acc.snaps ? acc.applyMs / acc.snaps : 0).toFixed(2) + " ms\n" +
"render avg " + (acc.renders ? acc.renderMs / acc.renders : 0).toFixed(2) + " ms\n" +
"render max " + acc.renderMax.toFixed(2) + " ms";
acc.snaps = acc.bytes = acc.parseMs = acc.applyMs = acc.renders = acc.renderMs = acc.frames = 0;
acc.renderMax = 0;
}, 1000);
return {
enabled: true,
onSnapshot(bytes, parseMs, applyMs) {
acc.snaps++; acc.bytes += bytes; acc.parseMs += parseMs; acc.applyMs += applyMs;
acc.lastBytes = bytes;
const s = state.lastSnapshot;
acc.lastEntities = s ? ((s.players || []).length + (s.zombies || []).length +
(s.animals || []).length + (s.vehicles || []).length + (s.otherEntities || []).length +
(s.loot || []).length + (s.bullets || []).length) : 0;
},
onRender(ms) { acc.renders++; acc.renderMs += ms; if (ms > acc.renderMax) acc.renderMax = ms; },
};
})();
let filtersByKey = {};
const filterKeyByKind = {
players: "players",
@@ -449,12 +518,43 @@ function applyTransform() {
constrainViewState();
elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`;
elements.canvas.style.setProperty("--inv-scale", 1 / state.scale);
elements.canvas.style.setProperty("--sqrt-inv-scale", 1 / Math.sqrt(state.scale));
elements.canvas.classList.toggle("grid-labels-visible", state.scale * MGRS_STEP >= 80);
scheduleVisibleTilesUpdate();
if (!state.isZooming) scheduleVisibleTilesUpdate();
updateZoomDisplay();
if (state.measureMode) drawMeasureLayer();
}
async function loadStaticPOIs(mapId, mapSize) {
if (!mapId || !mapSize || !state.bootstrap) return;
if ((state.bootstrap.pois || []).length > 0) return; // C++ already provided them
try {
const r = await fetch(`${serverOrigin}/pois/${encodeURIComponent(mapId)}.json`, { cache: "no-store" });
if (!r.ok || !state.bootstrap) return;
const data = await r.json();
if (!Array.isArray(data)) return;
state.bootstrap.pois = data.map((p) => ({
id: p.id || "",
label: p.label || "",
type: p.type || "",
x: Number(p.x) || 0,
y: mapSize - (Number(p.z) || 0),
}));
render();
} catch {}
}
async function checkTopoAvailable(mapId) {
if (!mapId) { state.topoAvailable = false; scheduleVisibleTilesUpdate(); return; }
try {
const r = await fetch(apiUrl(`/api/topo?mapId=${encodeURIComponent(mapId)}`), { cache: "no-store" });
state.topoAvailable = r.ok;
} catch {
state.topoAvailable = false;
}
scheduleVisibleTilesUpdate();
}
function applyMapMetadata(metadata, clearTiles = false) {
if (!metadata) return false;
const previous = state.bootstrap;
@@ -491,6 +591,9 @@ function applyMapMetadata(metadata, clearTiles = false) {
elements.markers.style.height = `${next.mapSize}px`;
if (clearTiles || changed) {
state.tileState = {};
state.topoAvailable = false;
checkTopoAvailable(next.mapId);
loadStaticPOIs(next.mapId, next.mapSize);
for (const img of state.tileElements.values()) img.remove();
state.tileElements.clear();
for (const img of state.topoElements.values()) img.remove();
@@ -801,10 +904,57 @@ function findNearestWaypoint(worldX, worldY, radius) {
return nearest;
}
function findNearestSharedWaypoint(worldX, worldY, radius) {
let nearest = null;
let nearestDist = radius;
for (const wp of (state.sharedWaypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
const dist = Math.hypot(wp.x - worldX, wp.y - worldY);
if (dist < nearestDist) { nearestDist = dist; nearest = wp; }
}
return nearest;
}
async function postSharedWaypoint(x, y, name) {
try {
const res = await fetch(apiUrl("/api/waypoints"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ x, y, name }),
});
if (!res.ok) showToast("Failed to save shared waypoint", "danger");
} catch {
showToast("Failed to save shared waypoint", "danger");
}
}
async function deleteSharedWaypoint(id) {
try {
const res = await fetch(apiUrl(`/api/waypoints/${id}`), { method: "DELETE" });
if (!res.ok) showToast("Failed to delete waypoint", "danger");
} catch {
showToast("Failed to delete waypoint", "danger");
}
}
function showContextMenu(x, y, worldX, worldY) {
state.contextMenuWorldPos = { x: worldX, y: worldY };
const old = document.getElementById("ctxRemoveWaypoint");
if (old) old.remove();
document.getElementById("ctxRemoveWaypoint")?.remove();
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
const nearShared = findNearestSharedWaypoint(worldX, worldY, 30 / state.scale);
if (nearShared) {
const removeBtn = document.createElement("button");
removeBtn.id = "ctxRemoveSharedWaypoint";
removeBtn.className = "ctx-item";
removeBtn.textContent = `Remove shared${nearShared.name ? `: ${nearShared.name}` : " waypoint"}`;
removeBtn.addEventListener("click", () => {
deleteSharedWaypoint(nearShared.id);
hideContextMenu();
});
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
}
const near = findNearestWaypoint(worldX, worldY, 30 / state.scale);
if (near) {
const removeBtn = document.createElement("button");
@@ -820,6 +970,7 @@ function showContextMenu(x, y, worldX, worldY) {
});
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
}
elements.ctxMenu.style.left = `${x}px`;
elements.ctxMenu.style.top = `${y}px`;
elements.ctxMenu.classList.add("open");
@@ -827,8 +978,8 @@ function showContextMenu(x, y, worldX, worldY) {
function hideContextMenu() {
elements.ctxMenu.classList.remove("open");
const old = document.getElementById("ctxRemoveWaypoint");
if (old) old.remove();
document.getElementById("ctxRemoveWaypoint")?.remove();
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
}
// ── Minimap ────────────────────────────────────────────────────────────────
@@ -893,6 +1044,13 @@ function updateMinimap() {
ctx.fillStyle = "#e2e8f0";
ctx.fill();
}
for (const wp of (state.sharedWaypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
ctx.beginPath();
ctx.arc(wp.x * mmScale, wp.y * mmScale, 2.5, 0, Math.PI * 2);
ctx.fillStyle = wp.color || "#f59e0b";
ctx.fill();
}
}
// ── Filter / visibility ────────────────────────────────────────────────────
@@ -1066,9 +1224,8 @@ function estimateLabelSize(text, fontSize) {
function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
const size = estimateLabelSize(text, fontSize);
if (!useFixedScreenLabel(kind, itemOrGroup)) return size;
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
return { width: size.width / effectiveScale, height: size.height / effectiveScale };
return { width: size.width / Math.sqrt(effectiveScale), height: size.height / Math.sqrt(effectiveScale) };
}
function buildPinnedLabelCandidates(entry, size) {
@@ -1412,7 +1569,7 @@ function computeLabelPlacements(entries) {
const groups = buildOrdinaryLootLabelGroups(entries);
for (const group of groups) {
const size = estimateLabelSize(group.labelText, group.label.textSize);
const size = getScaledLabelLayoutSize("loot", group, group.labelText, group.label.textSize);
const candidates = buildOrdinaryGroupLabelCandidates(group, size);
let bestPlacement = null;
let bestScore = Number.POSITIVE_INFINITY;
@@ -1846,13 +2003,18 @@ function createMarker(entry) {
glyph.style.background = markerColor;
glyph.style.borderWidth = `${borderSize}px`;
glyph.style.color = markerColor;
{
const _s = Math.max(minMapScale, Number(state.scale) || 1);
glyph.style.transform = `translate(-50%, -50%) scale(${1 / Math.sqrt(_s)})`;
}
if (kind === "players") {
const direction = document.createElement("div");
direction.className = "direction entity-direction";
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
direction.style.color = markerColor;
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28))}px`;
const _dirScale = Math.max(minMapScale, Number(state.scale) || 1);
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28)) / Math.sqrt(_dirScale)}px`;
marker.appendChild(direction);
}
@@ -1866,11 +2028,9 @@ function createMarker(entry) {
labelNode.style.color = markerColor;
labelNode.style.left = `${labelPlacement.left}px`;
labelNode.style.top = `${labelPlacement.top}px`;
if (useFixedScreenLabel(kind, item)) {
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / effectiveScale})`;
}
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
marker.appendChild(labelNode);
}
@@ -1893,10 +2053,10 @@ function createPriorityLabelGroup(group) {
labelNode.style.color = group.markerColor;
labelNode.style.left = `${labelPlacement.left}px`;
labelNode.style.top = `${labelPlacement.top}px`;
if (useFixedScreenLabel(group.kind, group)) {
{
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / effectiveScale})`;
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
}
marker.appendChild(labelNode);
@@ -1941,6 +2101,11 @@ function createLootLabelGroup(group) {
labelNode.style.color = group.markerColor;
labelNode.style.left = `${labelPlacement.left}px`;
labelNode.style.top = `${labelPlacement.top}px`;
{
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
}
marker.appendChild(labelNode);
for (const entry of group.entries) {
@@ -2211,7 +2376,7 @@ function updateVisibleTiles() {
const keepMaxX = Math.min(tileCountX - 1, maxTileX + keepMargin);
const keepMaxY = Math.min(tileCountY - 1, maxTileY + keepMargin);
const satmapOn = state.settings.showSatmap;
const satmapOn = state.settings.showSatmap && state.topoAvailable;
elements.canvas.classList.toggle("satmap-active", satmapOn);
elements.tiles.style.display = satmapOn ? "none" : "";
elements.topoTiles.style.display = satmapOn ? "" : "none";
@@ -2285,7 +2450,7 @@ function updateVisibleTiles() {
}
// ── Satmap tiles (loaded when satmap is active) ─────────────────────────
if (satmapOn) {
if (satmapOn && state.topoAvailable) {
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
const key = `${tileX}:${tileY}`;
@@ -2297,7 +2462,11 @@ function updateVisibleTiles() {
img.style.cssText = `left:${tileX * tileSize}px;top:${tileY * tileSize}px;width:${tileSize + 1}px;height:${tileSize + 1}px`;
img.addEventListener("error", () => {
img.remove();
// keep key in state.topoElements as a "do not retry" sentinel
if (!state.topoAvailable) return;
state.topoAvailable = false;
for (const el of state.topoElements.values()) el.remove();
state.topoElements.clear();
scheduleVisibleTilesUpdate();
});
img.src = topoTileUrl(tileX, tileY);
state.topoElements.set(key, img);
@@ -2372,17 +2541,25 @@ function updateVisibleTiles() {
// ── Main render ────────────────────────────────────────────────────────────
function render() {
if (!state.lastSnapshot || !state.bootstrap) return;
if (!PERF.enabled) return renderImpl();
const t = performance.now();
renderImpl();
PERF.onRender(performance.now() - t);
}
if (state.settings.followPlayer && state.lastSnapshot.hasLocalPlayer && state.lastSnapshot.localPlayer) {
centerOn(state.lastSnapshot.localPlayer);
function renderImpl() {
if (!state.bootstrap) return;
const snap = state.lastSnapshot;
if (snap && state.settings.followPlayer && snap.hasLocalPlayer && snap.localPlayer) {
centerOn(snap.localPlayer);
}
const markerNodes = [];
const itemLabelNodes = [];
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", state.lastSnapshot?.bullets || []));
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", snap?.bullets || []));
const localPlayer = state.lastSnapshot.hasLocalPlayer ? state.lastSnapshot.localPlayer : null;
const localPlayer = snap?.hasLocalPlayer ? snap.localPlayer : null;
if (localPlayer) {
const marker = document.createElement("div");
marker.className = "entity local-player-entity";
@@ -2466,6 +2643,30 @@ function render() {
markerNodes.push(wrapper);
}
// Shared waypoints (relay-backed, visible to all clients)
for (const wp of (state.sharedWaypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
const wrapper = document.createElement("div");
wrapper.className = "entity waypoint-entity";
wrapper.style.left = `${wp.x}px`;
wrapper.style.top = `${wp.y}px`;
const inner = document.createElement("div");
inner.style.cssText = "position:absolute;left:0;top:0;transform:scale(var(--inv-scale));transform-origin:0 0";
const dot = document.createElement("div");
dot.className = "marker shared-waypoint";
if (wp.color) dot.style.borderColor = wp.color;
inner.appendChild(dot);
if (wp.name) {
const lbl = document.createElement("span");
lbl.className = "waypoint-label shared-waypoint-label";
if (wp.color) lbl.style.color = wp.color;
lbl.textContent = wp.name;
inner.appendChild(lbl);
}
wrapper.appendChild(inner);
markerNodes.push(wrapper);
}
const gridNodes = buildGridLayer();
const gridLabelNodes = buildGridLabels();
const combatNodes = buildCombatRings(localPlayer);
@@ -2712,11 +2913,7 @@ async function bootstrap() {
setTheme(state.settings.theme);
syncLabelSettingsUi();
loadMinimapImage();
// Enable/disable satellite checkbox based on whether tiles exist.
const hasSat = !!bootstrapData.satUrl;
elements.showSatellite.disabled = !hasSat;
elements.showSatellite.title = hasSat ? "" : "No satellite data — run: py -m dayzmap satellite --map <mapId>";
render(); // show waypoints/POIs even before first C++ state push
// Enable 3D button when processed data exists for the current map.
engine3d.manifestUrl = bootstrapData.manifestUrl || null;
@@ -2738,7 +2935,17 @@ function connectEvents() {
source.addEventListener("state", (event) => {
stopFallbackPolling();
applySnapshot(JSON.parse(event.data));
if (!PERF.enabled) { applySnapshot(JSON.parse(event.data)); return; }
const t0 = performance.now();
const snap = JSON.parse(event.data);
const t1 = performance.now();
applySnapshot(snap);
PERF.onSnapshot(event.data.length, t1 - t0, performance.now() - t1);
});
source.addEventListener("waypoints", (event) => {
state.sharedWaypoints = JSON.parse(event.data);
render();
});
source.onerror = () => {
@@ -2756,6 +2963,21 @@ function connectEvents() {
// ── UI sync helpers ────────────────────────────────────────────────────────
function syncGridSettings() {
const lw = Number(state.settings.gridLineWeight) || 2.5;
const ls = Number(state.settings.gridLabelSize) || 13;
elements.canvas.style.setProperty("--grid-line-weight", lw);
elements.canvas.style.setProperty("--grid-label-size", `${ls}px`);
if (elements.gridLineWeight) {
elements.gridLineWeight.value = String(lw);
elements.gridLineWeightValue.textContent = `${lw.toFixed(1)} px`;
}
if (elements.gridLabelSize) {
elements.gridLabelSize.value = String(ls);
elements.gridLabelSizeValue.textContent = `${ls} px`;
}
}
function syncLabelSettingsUi() {
elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread);
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
@@ -2785,7 +3007,6 @@ function syncAllCheckboxes() {
showLabels: elements.showLabels,
showPOIs: elements.showPOIs,
showGrid: elements.showGrid,
showSatellite: elements.showSatellite,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
@@ -2946,7 +3167,6 @@ function bindUi() {
showLabels: elements.showLabels,
showPOIs: elements.showPOIs,
showGrid: elements.showGrid,
showSatellite: elements.showSatellite,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
@@ -2954,7 +3174,13 @@ function bindUi() {
el.addEventListener("change", () => {
state.settings[key] = el.checked;
persistSettings();
if (key === "showSatmap") updateVisibleTiles();
if (key === "showSatmap") {
if (el.checked && !state.topoAvailable && state.bootstrap?.mapId) {
checkTopoAvailable(state.bootstrap.mapId);
} else {
updateVisibleTiles();
}
}
else if (key === "showMinimap") updateMinimap();
else render();
});
@@ -2992,6 +3218,21 @@ function bindUi() {
// Label settings
syncLabelSettingsUi();
// Grid settings
syncGridSettings();
elements.gridLineWeight.addEventListener("input", () => {
state.settings.gridLineWeight = Number(elements.gridLineWeight.value);
elements.gridLineWeightValue.textContent = `${state.settings.gridLineWeight.toFixed(1)} px`;
elements.canvas.style.setProperty("--grid-line-weight", state.settings.gridLineWeight);
persistSettings();
});
elements.gridLabelSize.addEventListener("input", () => {
state.settings.gridLabelSize = Number(elements.gridLabelSize.value);
elements.gridLabelSizeValue.textContent = `${state.settings.gridLabelSize} px`;
elements.canvas.style.setProperty("--grid-label-size", `${state.settings.gridLabelSize}px`);
persistSettings();
});
elements.ordinaryLootSpread.addEventListener("input", () => {
state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value);
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
@@ -3069,6 +3310,12 @@ function bindUi() {
state.lastX = event.clientX;
state.lastY = event.clientY;
state.pinchDistance = 0;
if (state.dragging && state.isZooming) {
state.isZooming = false;
clearTimeout(state.zoomIdleTimer);
state.zoomIdleTimer = null;
scheduleVisibleTilesUpdate();
}
return;
}
const pair = getActivePointerPair();
@@ -3134,10 +3381,16 @@ function bindUi() {
drawMeasureLayer(event.clientX, event.clientY);
});
// Wheel zoom
// Wheel zoom — suppress tile loading while spinning; load once zoom settles.
elements.viewport.addEventListener("wheel", (event) => {
if (event.target.closest("#ctxMenu")) return;
event.preventDefault();
state.isZooming = true;
clearTimeout(state.zoomIdleTimer);
state.zoomIdleTimer = setTimeout(() => {
state.isZooming = false;
scheduleVisibleTilesUpdate();
}, 150);
zoomAt(event.clientX, event.clientY, state.scale * (event.deltaY < 0 ? 1.1 : 0.9));
if (state.measureMode) drawMeasureLayer();
}, { passive: false });
@@ -3168,6 +3421,14 @@ function bindUi() {
render();
});
elements.ctxAddSharedWaypoint.addEventListener("click", () => {
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
const name = (window.prompt("Shared waypoint name (optional):") ?? "").trim();
const { x, y } = state.contextMenuWorldPos;
hideContextMenu();
postSharedWaypoint(x, y, name);
});
elements.ctxCopyCoords.addEventListener("click", () => {
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
const mapSize = state.bootstrap?.mapSize || 0;
@@ -3211,6 +3472,12 @@ ensureFilterSettings();
renderFilterCards();
bindUi();
// Fetch shared waypoints once on startup; SSE will handle live updates after connect.
fetch(apiUrl("/api/waypoints"), { cache: "no-store" })
.then((r) => r.ok ? r.json() : [])
.then((wps) => { if (Array.isArray(wps)) { state.sharedWaypoints = wps; render(); } })
.catch(() => {});
bootstrap()
.then(() => fetch(apiUrl("/api/state"), { cache: "no-store" }))
.then((response) => {
+19 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DayZ Web Radar</title>
<link rel="stylesheet" href="style.css?v=7">
<link rel="stylesheet" href="style.css?v=13">
</head>
<body>
@@ -53,8 +53,7 @@
<label class="toggle-row"><input type="checkbox" id="showLabels" checked><span>Labels</span></label>
<label class="toggle-row"><input type="checkbox" id="showPOIs" checked><span>Locations</span></label>
<label class="toggle-row"><input type="checkbox" id="showGrid"><span>Grid <kbd>G</kbd></span></label>
<label class="toggle-row"><input type="checkbox" id="showSatellite"><span>Satellite</span></label>
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Sat map</span></label>
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Satellite map</span></label>
<label class="toggle-row"><input type="checkbox" id="showDistanceRings"><span>Distance rings</span></label>
<label class="toggle-row"><input type="checkbox" id="showMinimap"><span>Minimap</span></label>
</div>
@@ -152,6 +151,20 @@
</div>
</div>
<div class="tab-section">
<div class="section-label">Grid</div>
<label class="setting-row">
<span class="setting-name">Line weight</span>
<span id="gridLineWeightValue" class="setting-val">2.5 px</span>
<input type="range" id="gridLineWeight" min="0.5" max="6" step="0.5" value="2.5">
</label>
<label class="setting-row">
<span class="setting-name">Label size</span>
<span id="gridLabelSizeValue" class="setting-val">13 px</span>
<input type="range" id="gridLabelSize" min="8" max="24" step="1" value="13">
</label>
</div>
<div class="tab-section">
<div class="section-label">Theme</div>
<div class="theme-bar">
@@ -206,7 +219,8 @@
<!-- Right-click context menu -->
<div class="ctx-menu" id="ctxMenu">
<button class="ctx-item" id="ctxAddWaypoint">Add waypoint</button>
<button class="ctx-item" id="ctxAddWaypoint">Add waypoint (local)</button>
<button class="ctx-item ctx-item-shared" id="ctxAddSharedWaypoint">Add shared waypoint</button>
<button class="ctx-item" id="ctxCopyCoords">Copy coordinates</button>
</div>
@@ -228,6 +242,6 @@
<!-- ── Toast notifications ────────────────────────────────────────── -->
<div class="toast-stack" id="toastStack"></div>
<script src="app.js?v=7"></script>
<script src="app.js?v=13"></script>
</body>
</html>
+22 -7
View File
@@ -811,6 +811,18 @@ body.sidebar-hidden .viewport { left: 0; }
width: 10px; height: 10px;
}
.marker.shared-waypoint {
background: #f59e0b;
border: 2px solid #fde68a;
width: 10px; height: 10px;
border-radius: 2px;
transform: translate(-50%, -50%) rotate(45deg);
}
.shared-waypoint-label {
font-weight: 700;
}
.entity.bullet-phantom .marker.bullets {
opacity: 0.45;
box-shadow: 0 0 6px rgba(148,163,184,0.45);
@@ -957,11 +969,11 @@ body.sidebar-hidden .viewport { left: 0; }
/* ── Grid overlay ────────────────────────────────────────────────────── */
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: 1; }
.grid-minor { stroke: rgba(255,255,255,0.12); stroke-width: 0.5; }
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: calc(var(--grid-line-weight, 2.5) * var(--inv-scale, 1)); }
.grid-minor { stroke: rgba(255,255,255,0.12); stroke-width: calc(0.5 * var(--inv-scale, 1)); }
.canvas.satmap-active .grid-major { stroke: rgba(0,0,0,0.45); }
.canvas.satmap-active .grid-label-inner { font-size: 10px; color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
.canvas.satmap-active .grid-major { stroke: rgba(0,0,0,0.45); stroke-width: calc(var(--grid-line-weight, 2.5) * var(--inv-scale, 1)); }
.canvas.satmap-active .grid-label-inner { font-size: var(--grid-label-size, 13px); color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
.grid-label-wrapper {
position: absolute;
@@ -972,12 +984,13 @@ body.sidebar-hidden .viewport { left: 0; }
.canvas.grid-labels-visible .grid-label-wrapper { visibility: visible; }
.grid-label-inner {
font-size: 10px;
font-size: var(--grid-label-size, 13px);
font-family: monospace;
color: rgba(255,255,255,0.50);
color: rgba(255,255,255,0.55);
white-space: nowrap;
line-height: 1;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
text-shadow: 0 1px 3px rgba(0,0,0,0.95);
will-change: transform;
}
/* ── Combat mode ─────────────────────────────────────────────────────── */
@@ -1058,6 +1071,8 @@ body.measure-active .viewport { cursor: crosshair; }
}
.ctx-item:hover { background: var(--bg-hover); }
.ctx-item-shared { color: #f59e0b; }
.ctx-item-shared:hover { color: #fde68a; }
/* ── Coord bar ───────────────────────────────────────────────────────── */