Files
67 361c6baa8f 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
2026-06-23 03:11:52 +08:00

3501 lines
143 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const storageKey = "dayz-web-map-settings";
const MGRS_STEP = 1000; // 1 km MGRS grid square size in world units
const params = new URLSearchParams(window.location.search);
const password = params.get("password") || "";
const serverOrigin = (params.get("server") || "").replace(/\/$/, "");
const favoriteLootColor = "#ef4444";
const favoriteLootFilterDefinition = { key: "favoriteLoot", label: "Favorites", visibleKey: "showFavoriteLoot", kind: "loot", category: "favorite", color: favoriteLootColor, markerSize: 10, textSize: 14, showLabel: true };
const lootPalette = ["#f43f5e", "#f59e0b", "#22c55e", "#eab308", "#84cc16", "#ec4899", "#0ea5e9", "#14b8a6", "#c084fc", "#f97316", "#d946ef", "#dc2626", "#fbbf24", "#fb923c", "#0891b2", "#64748b"];
const entityFilterDefinitions = [
{ key: "players", label: "Players", visibleKey: "showPlayers", kind: "players", color: "#ff6b35", markerSize: 12, textSize: 14, showLabel: true, directionLength: 28 },
{ key: "zombies", label: "Zombies", visibleKey: "showZombies", kind: "zombies", color: "#65a30d", markerSize: 12, textSize: 14, showLabel: true },
{ key: "animals", label: "Animals", visibleKey: "showAnimals", kind: "animals", color: "#38bdf8", markerSize: 12, textSize: 14, showLabel: true },
{ key: "vehicles", label: "Vehicles", visibleKey: "showVehicles", kind: "vehicles", color: "#a855f7", markerSize: 14, textSize: 14, showLabel: true },
{ key: "bullets", label: "Bullets", visibleKey: "showBullets", kind: "bullets", color: "#facc15", markerSize: 6, textSize: 12, showLabel: false, showTrajectory: true, showPrediction: true, phantomLifetimeMs: 5000, predictionDistance: 250 },
{ key: "otherEntities", label: "Other Entity", visibleKey: "showOtherEntities", kind: "otherEntities", color: "#475569", markerSize: 10, textSize: 14, showLabel: true }
];
const previousDefaultFilterColors = {
players: "#f97316",
zombies: "#22c55e",
animals: "#60a5fa",
vehicles: "#a855f7",
bullets: "#f8fafc",
otherEntities: "#94a3b8",
isHouse: "#fb7185",
isWeapon: "#4ade80",
isAmmo: "#f59e0b",
isFood: "#a3e635",
isClothing: "#fde047",
isBackpack: "#f0abfc",
isMedical: "#e5e7eb",
isVehiclePart: "#38bdf8",
isTool: "#22d3ee",
isCrafting: "#2dd4bf",
isConsumables: "#f43f5e",
isOptics: "#94a3b8",
isMelee: "#fb7185",
isWeaponAttachments: "#4ade80",
isExplosives: "#f59e0b",
isForBuilding: "#a3e635",
isOtherLoot: "#fde047"
};
let filterDefinitions = [...entityFilterDefinitions];
const defaultSettings = {
followPlayer: false,
showPlayers: true,
showZombies: true,
showAnimals: true,
showLoot: true,
showVehicles: true,
showBullets: true,
showOtherEntities: true,
showFavoriteLoot: true,
showLabels: true,
showPOIs: true,
showGrid: false,
showSatellite: false,
showSatmap: false,
showDistanceRings: false,
showMinimap: false,
isCombatMode: false,
distanceFilter: 2000,
textSize: 14,
ordinaryLootSpread: 2,
groupedLootSpread: 2,
sameLootMergeRadius: 110,
lineAnchorMode: "bottom-center",
mergeSameLootLabels: true,
favoriteLootNames: [],
filterStyles: {},
filterExpanded: {},
gridLineWeight: 2.5,
gridLabelSize: 13,
// v2 UI state
sidebarState: "full",
activeTab: "tab-map",
theme: "dark",
presets: [null, null, null, null],
waypoints: [],
activePreset: null,
// legacy compat
settingsCollapsed: false,
labelSettingsOpen: false,
};
const savedSettings = (() => {
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) || "{}");
const merged = {
...defaultSettings,
...parsed,
filterStyles: { ...(defaultSettings.filterStyles || {}), ...(parsed.filterStyles || {}) },
filterExpanded: { ...(defaultSettings.filterExpanded || {}), ...(parsed.filterExpanded || {}) },
presets: Array.isArray(parsed.presets)
? parsed.presets.slice(0, 4).concat([null, null, null, null]).slice(0, 4)
: [null, null, null, null],
waypoints: Array.isArray(parsed.waypoints) ? parsed.waypoints : [],
};
if (!parsed.sidebarState && parsed.settingsCollapsed) {
merged.sidebarState = "icons";
}
// migrate old showTopo key
if ("showTopo" in parsed && !("showSatmap" in parsed)) {
merged.showSatmap = parsed.showTopo;
}
return merged;
} catch {
return { ...defaultSettings };
}
})();
const state = {
settings: savedSettings,
scale: 0.6,
offsetX: 0,
offsetY: 0,
dragging: false,
lastX: 0,
lastY: 0,
bootstrap: null,
lastSnapshot: null,
tileState: {},
initialCentered: false,
eventSource: null,
fallbackPollTimer: null,
reconnectTimer: null,
activePointers: new Map(),
pinchDistance: 0,
bulletVisuals: new Map(),
bulletAnimationFrame: null,
// v2 additions
measureMode: false,
measurePointA: null,
measurePointB: null,
knownPlayerAddresses: new Set(),
wasConnected: false,
contextMenuWorldPos: null,
lootSearchText: "",
listDirty: false,
tileElements: new Map(), // key -> img, O(1) tile lookup
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 = {
status: document.getElementById("status"),
viewport: document.getElementById("viewport"),
canvas: document.getElementById("canvas"),
tiles: document.getElementById("tiles"),
paths: document.getElementById("paths"),
itemLabels: document.getElementById("itemLabels"),
markers: document.getElementById("markers"),
topoTiles: document.getElementById("topo-tiles"),
satTiles: document.getElementById("sat-tiles"),
connectionDot: document.getElementById("connectionDot"),
serverBadge: document.getElementById("serverBadge"),
serverInfo: document.getElementById("serverInfo"),
combatToggle: document.getElementById("combatToggle"),
sidebarToggle: document.getElementById("sidebarToggle"),
sidebar: document.getElementById("sidebar"),
sidebarContent: document.getElementById("sidebarContent"),
badgePlayers: document.getElementById("badge-players"),
badgeZombies: document.getElementById("badge-zombies"),
badgeAnimals: document.getElementById("badge-animals"),
badgeVehicles: document.getElementById("badge-vehicles"),
badgeBullets: document.getElementById("badge-bullets"),
badgeLoot: document.getElementById("badge-loot"),
followPlayer: document.getElementById("followPlayer"),
showLabels: document.getElementById("showLabels"),
showPOIs: document.getElementById("showPOIs"),
showGrid: document.getElementById("showGrid"),
showSatmap: document.getElementById("showSatmap"),
showDistanceRings: document.getElementById("showDistanceRings"),
showMinimap: document.getElementById("showMinimap"),
distanceFilter: document.getElementById("distanceFilter"),
distanceValue: document.getElementById("distanceValue"),
entityFilterList: document.getElementById("entityFilterList"),
playersList: document.getElementById("playersList"),
lootSearch: document.getElementById("lootSearch"),
showLoot: document.getElementById("showLoot"),
lootFilterList: document.getElementById("lootFilterList"),
lootInfo: document.getElementById("lootInfo"),
lootList: document.getElementById("lootList"),
ordinaryLootSpread: document.getElementById("ordinaryLootSpread"),
ordinaryLootSpreadValue: document.getElementById("ordinaryLootSpreadValue"),
groupedLootSpread: document.getElementById("groupedLootSpread"),
groupedLootSpreadValue: document.getElementById("groupedLootSpreadValue"),
sameLootMergeRadius: document.getElementById("sameLootMergeRadius"),
sameLootMergeRadiusValue: document.getElementById("sameLootMergeRadiusValue"),
lineAnchorMode: document.getElementById("lineAnchorMode"),
mergeSameLootLabels: document.getElementById("mergeSameLootLabels"),
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"),
measureLayer: document.getElementById("measureLayer"),
measureToggle: document.getElementById("measureToggle"),
coordDisplay: document.getElementById("coordDisplay"),
gridDisplay: document.getElementById("gridDisplay"),
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",
zombies: "zombies",
animals: "animals",
vehicles: "vehicles",
bullets: "bullets",
otherEntities: "otherEntities"
};
let lootFilterKeyByCategory = {};
const minMapScale = 0.02;
const maxMapScale = 1.5;
const textMeasureCanvas = document.createElement("canvas");
const textMeasureContext = textMeasureCanvas.getContext("2d");
function rebuildFilterCaches() {
filtersByKey = Object.fromEntries(filterDefinitions.map((definition) => [definition.key, definition]));
lootFilterKeyByCategory = Object.fromEntries(
filterDefinitions
.filter((definition) => definition.kind === "loot")
.map((definition) => [definition.category, definition.key])
);
}
function buildLootFilterDefinitions(dynamicFilters) {
return [favoriteLootFilterDefinition]
.concat((dynamicFilters || [])
.filter((definition) => definition.kind === "loot")
.sort((lhs, rhs) => Number((lhs.key || "").toLowerCase() === "isotherloot") - Number((rhs.key || "").toLowerCase() === "isotherloot"))
.map((definition, index) => ({
key: definition.key,
label: definition.label || definition.key,
visibleKey: definition.key,
kind: "loot",
category: definition.category || definition.key,
color: definition.color || lootPalette[index % lootPalette.length],
markerSize: 8,
textSize: 14,
showLabel: true
})));
}
function sanitize(text) {
return String(text ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderSafeMultilineHtml(lines) {
return (Array.isArray(lines) ? lines : [String(lines || "")])
.filter((line) => String(line || "").trim().length > 0)
.map((line) => sanitize(line))
.join("<br>");
}
function normalizeInlineText(value) {
return String(value || "")
.replace(/\s*\r?\n\s*/g, " ")
.replace(/\s+/g, " ")
.replace(/\s*-\s*/g, "-")
.trim();
}
function getEntityLabelLines(item) {
const baseLabel = normalizeInlineText(item?.label || "Unknown");
const handItem = normalizeInlineText(item?.handItem || "");
const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : "";
const firstLine = item?.dead
? `\u{1F480}${baseLabel}${distanceText}\u{1FAA6}`
: `${baseLabel}${distanceText}`;
return handItem.length > 0
? [firstLine.trim(), `\u{1F91A}${handItem}\u{1F52B}`]
: [firstLine.trim()];
}
function ensureFilterSettings() {
for (const definition of filterDefinitions) {
const current = state.settings.filterStyles[definition.key] || {};
const currentColor = String(current.color || "").toLowerCase();
const previousDefaultColor = previousDefaultFilterColors[definition.key];
state.settings.filterStyles[definition.key] = {
color: !currentColor || currentColor === previousDefaultColor ? definition.color : current.color,
markerSize: Number(current.markerSize || definition.markerSize),
textSize: Number(current.textSize || definition.textSize),
showLabel: current.showLabel !== false
};
if (definition.key === "players") {
const rawDirectionLength = Number(current.directionLength || definition.directionLength || 28);
const safeDirectionLength = Number.isFinite(rawDirectionLength) ? rawDirectionLength : 28;
state.settings.filterStyles[definition.key].directionLength = Math.min(60, Math.max(10, safeDirectionLength));
}
if (definition.key === "bullets") {
const rawPhantomLifetimeMs = Number(current.phantomLifetimeMs || definition.phantomLifetimeMs || 5000);
const rawPredictionDistance = Number(current.predictionDistance || definition.predictionDistance || 250);
state.settings.filterStyles[definition.key].showTrajectory = current.showTrajectory !== false;
state.settings.filterStyles[definition.key].showPrediction = current.showPrediction !== false;
state.settings.filterStyles[definition.key].phantomLifetimeMs = Math.min(60000, Math.max(0, Number.isFinite(rawPhantomLifetimeMs) ? rawPhantomLifetimeMs : 5000));
state.settings.filterStyles[definition.key].predictionDistance = Math.min(1000, Math.max(25, Number.isFinite(rawPredictionDistance) ? rawPredictionDistance : 250));
}
state.settings.filterExpanded[definition.key] = !!state.settings.filterExpanded[definition.key];
if (typeof state.settings[definition.visibleKey] !== "boolean") {
state.settings[definition.visibleKey] = true;
}
}
if (!Array.isArray(state.settings.favoriteLootNames)) {
state.settings.favoriteLootNames = [];
}
state.settings.favoriteLootNames = Array.from(new Set(state.settings.favoriteLootNames.map((value) => String(value || "").trim().toLowerCase()).filter(Boolean)));
state.settings.ordinaryLootSpread = Math.min(4, Math.max(1, Number(state.settings.ordinaryLootSpread || defaultSettings.ordinaryLootSpread)));
state.settings.groupedLootSpread = Math.min(4, Math.max(1, Number(state.settings.groupedLootSpread || defaultSettings.groupedLootSpread)));
state.settings.sameLootMergeRadius = Math.min(240, Math.max(20, Number(state.settings.sameLootMergeRadius || defaultSettings.sameLootMergeRadius)));
state.settings.lineAnchorMode = ["auto-nearest", "top-left", "top-center", "top-right", "middle-left", "middle-right", "bottom-left", "bottom-center", "bottom-right"].includes(state.settings.lineAnchorMode)
? state.settings.lineAnchorMode
: defaultSettings.lineAnchorMode;
state.settings.mergeSameLootLabels = state.settings.mergeSameLootLabels !== false;
// v2 fields
if (!["full", "icons", "hidden"].includes(state.settings.sidebarState)) {
state.settings.sidebarState = "full";
}
if (!["tab-map", "tab-entities", "tab-loot", "tab-settings"].includes(state.settings.activeTab)) {
state.settings.activeTab = "tab-map";
}
if (!["dark", "darker", "solarized"].includes(state.settings.theme)) {
state.settings.theme = "dark";
}
if (typeof state.settings.showDistanceRings !== "boolean") state.settings.showDistanceRings = false;
if (typeof state.settings.showMinimap !== "boolean") state.settings.showMinimap = false;
if (!Array.isArray(state.settings.presets)) state.settings.presets = [null, null, null, null];
while (state.settings.presets.length < 4) state.settings.presets.push(null);
if (!Array.isArray(state.settings.waypoints)) state.settings.waypoints = [];
// Assign IDs to any waypoints missing them
let nextWpId = state.settings.waypoints.reduce((m, w) => Math.max(m, Number(w.id || 0)), 0) + 1;
for (const wp of state.settings.waypoints) {
if (!wp.id) wp.id = nextWpId++;
}
}
function persistSettings() {
localStorage.setItem(storageKey, JSON.stringify(state.settings));
}
function normalizeLootName(name) {
return String(name || "").trim().toLowerCase();
}
function getFavoriteLootNames() {
return new Set(state.settings.favoriteLootNames || []);
}
function isFavoriteLoot(item) {
return !!(item && getFavoriteLootNames().has(normalizeLootName(item.label)));
}
function toggleFavoriteLoot(itemName) {
const normalized = normalizeLootName(itemName);
if (!normalized) return;
const favorites = getFavoriteLootNames();
if (favorites.has(normalized)) {
favorites.delete(normalized);
} else {
favorites.add(normalized);
}
state.settings.favoriteLootNames = Array.from(favorites);
persistSettings();
state.listDirty = true;
render();
}
function apiUrl(path) {
const base = serverOrigin + path;
return password ? `${base}?password=${encodeURIComponent(password)}` : base;
}
function tileUrl(tileX, tileY, retry = 0) {
const query = new URLSearchParams({ x: String(tileX), y: String(tileY), retry: String(retry) });
if (state.bootstrap?.mapId) query.set("mapId", state.bootstrap.mapId);
if (password) query.set("password", password);
return `${serverOrigin}/tile?${query.toString()}`;
}
function topoTileUrl(tileX, tileY) {
const query = new URLSearchParams({ x: String(tileX), y: String(tileY) });
if (state.bootstrap?.mapId) query.set("mapId", state.bootstrap.mapId);
if (password) query.set("password", password);
return `${serverOrigin}/topo-tile?${query.toString()}`;
}
function satTileUrl(z, tx, ty) {
const base = state.bootstrap?.satUrl || "";
return `${serverOrigin}${base}/${z}/${tx}/${ty}.webp`;
}
function satZoomForScale() {
const { mapSize, satMaxZoom = 7 } = state.bootstrap;
// Target ~300 screen pixels per tile for a good balance of sharpness vs requests.
const z = Math.round(Math.log2(mapSize * state.scale / 300));
return Math.max(0, Math.min(satMaxZoom, z));
}
function getViewportMetrics() {
const rect = elements.viewport.getBoundingClientRect();
const mapSize = Number(state.bootstrap?.mapSize || 0);
return {
width: Math.max(0, rect.width),
height: Math.max(0, rect.height),
mapSize
};
}
function constrainViewState() {
const metrics = getViewportMetrics();
if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) return;
const minScaleForWidth = metrics.width / metrics.mapSize;
const minScaleForHeight = metrics.height / metrics.mapSize;
const effectiveMinScale = Math.max(minMapScale, minScaleForWidth, minScaleForHeight);
state.scale = Math.min(maxMapScale, Math.max(effectiveMinScale, state.scale));
const scaledMapWidth = metrics.mapSize * state.scale;
const scaledMapHeight = metrics.mapSize * state.scale;
const minOffsetX = metrics.width - scaledMapWidth;
const minOffsetY = metrics.height - scaledMapHeight;
state.offsetX = clampNumber(state.offsetX, Math.min(minOffsetX, 0), 0);
state.offsetY = clampNumber(state.offsetY, Math.min(minOffsetY, 0), 0);
}
function scheduleVisibleTilesUpdate() {
if (state.tilesRafPending) return;
state.tilesRafPending = true;
requestAnimationFrame(() => {
state.tilesRafPending = false;
updateVisibleTiles();
});
}
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);
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;
const next = {
...(previous || {}),
...metadata,
mapId: metadata.mapId || previous?.mapId || "",
mapName: metadata.mapName || previous?.mapName || "",
mapSize: Number(metadata.mapSize || previous?.mapSize || 0),
tileSize: Number(metadata.tileSize || previous?.tileSize || 512),
tileCountX: Number(metadata.tileCountX || previous?.tileCountX || 0),
tileCountY: Number(metadata.tileCountY || previous?.tileCountY || 0)
};
const changed = !previous
|| previous.mapId !== next.mapId
|| previous.mapName !== next.mapName
|| previous.mapSize !== next.mapSize
|| previous.tileSize !== next.tileSize
|| previous.tileCountX !== next.tileCountX
|| previous.tileCountY !== next.tileCountY;
state.bootstrap = next;
elements.canvas.style.width = `${next.mapSize}px`;
elements.canvas.style.height = `${next.mapSize}px`;
elements.tiles.style.width = `${next.mapSize}px`;
elements.tiles.style.height = `${next.mapSize}px`;
elements.topoTiles.style.width = `${next.mapSize}px`;
elements.topoTiles.style.height = `${next.mapSize}px`;
elements.paths.setAttribute("viewBox", `0 0 ${next.mapSize} ${next.mapSize}`);
elements.paths.setAttribute("width", `${next.mapSize}`);
elements.paths.setAttribute("height", `${next.mapSize}`);
elements.itemLabels.style.width = `${next.mapSize}px`;
elements.itemLabels.style.height = `${next.mapSize}px`;
elements.markers.style.width = `${next.mapSize}px`;
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();
state.topoElements.clear();
for (const img of state.satElements.values()) img.remove();
state.satElements.clear();
state.cachedGridKey = null;
state.cachedGridNode = null;
elements.tiles.replaceChildren();
elements.topoTiles.replaceChildren();
}
applyTransform();
return changed;
}
function centerOn(point) {
if (!point) return;
const rect = elements.viewport.getBoundingClientRect();
state.offsetX = (rect.width / 2) - (point.x * state.scale);
state.offsetY = (rect.height / 2) - (point.y * state.scale);
applyTransform();
}
function clampNumber(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function clampScale(value) {
const metrics = getViewportMetrics();
if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) {
return Math.min(maxMapScale, Math.max(minMapScale, value));
}
const minScaleForWidth = metrics.width / metrics.mapSize;
const minScaleForHeight = metrics.height / metrics.mapSize;
return Math.min(maxMapScale, Math.max(minMapScale, minScaleForWidth, minScaleForHeight, value));
}
function zoomAt(clientX, clientY, nextScale) {
const rect = elements.viewport.getBoundingClientRect();
const localPlayer = state.settings.followPlayer && state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot?.localPlayer
? state.lastSnapshot.localPlayer
: null;
const originX = localPlayer
? ((localPlayer.x * state.scale) + state.offsetX)
: (clientX - rect.left);
const originY = localPlayer
? ((localPlayer.y * state.scale) + state.offsetY)
: (clientY - rect.top);
const worldX = (originX - state.offsetX) / state.scale;
const worldY = (originY - state.offsetY) / state.scale;
state.scale = clampScale(nextScale);
state.offsetX = originX - (worldX * state.scale);
state.offsetY = originY - (worldY * state.scale);
applyTransform();
}
function screenToWorld(clientX, clientY) {
const rect = elements.viewport.getBoundingClientRect();
return {
x: (clientX - rect.left - state.offsetX) / state.scale,
y: (clientY - rect.top - state.offsetY) / state.scale,
};
}
function updateCoordDisplay(worldX, worldY) {
if (!state.bootstrap || state.bootstrap.mapSize <= 0) return;
const mapSize = state.bootstrap.mapSize;
elements.coordDisplay.textContent = `${Math.round(worldX)}, ${Math.round(mapSize - worldY)}`;
const eastStr = String(Math.max(0, Math.floor(worldX / 100))).padStart(3, "0");
const northStr = String(Math.max(0, Math.floor((mapSize - worldY) / 100))).padStart(3, "0");
elements.gridDisplay.textContent = `${eastStr} ${northStr}`;
}
function updateZoomDisplay() {
elements.zoomDisplay.textContent = `${Math.round(state.scale * 100)}%`;
}
// ── Sidebar / tab / theme ──────────────────────────────────────────────────
function setSidebarState(newState) {
state.settings.sidebarState = newState;
persistSettings();
document.body.classList.remove("sidebar-icons", "sidebar-hidden");
if (newState === "icons") document.body.classList.add("sidebar-icons");
else if (newState === "hidden") document.body.classList.add("sidebar-hidden");
setTimeout(() => applyTransform(), 200);
}
function switchTab(tabId) {
state.settings.activeTab = tabId;
persistSettings();
for (const btn of document.querySelectorAll(".tab-btn")) {
btn.classList.toggle("active", btn.dataset.tab === tabId);
}
for (const panel of document.querySelectorAll(".tab-panel")) {
panel.classList.toggle("active", panel.id === tabId);
}
state.listDirty = true;
render();
}
function setTheme(theme) {
state.settings.theme = theme;
persistSettings();
document.documentElement.setAttribute("data-theme", theme);
for (const btn of document.querySelectorAll(".theme-btn")) {
btn.classList.toggle("active", btn.dataset.theme === theme);
}
}
// ── Toast system ───────────────────────────────────────────────────────────
function showToast(message, type) {
const toasts = elements.toastStack;
while (toasts.children.length >= 3) {
toasts.removeChild(toasts.firstChild);
}
const el = document.createElement("div");
el.className = `toast${type ? ` toast-${type}` : ""}`;
el.textContent = message;
toasts.appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
el.addEventListener("transitionend", () => el.remove(), { once: true });
}, 4000);
}
// ── Preset system ──────────────────────────────────────────────────────────
function getBuiltinPreset(index) {
if (index === 0) {
return { showPlayers: true, showZombies: false, showAnimals: false, showVehicles: false, showBullets: true, showLoot: false, showOtherEntities: false, showFavoriteLoot: false, isCombatMode: true, showDistanceRings: true, showPOIs: false };
}
if (index === 1) {
return { showPlayers: true, showZombies: false, showAnimals: false, showVehicles: false, showBullets: false, showLoot: true, showOtherEntities: false, showFavoriteLoot: true, isCombatMode: false, showDistanceRings: false, showPOIs: true };
}
if (index === 2) {
return { showPlayers: true, showZombies: true, showAnimals: true, showVehicles: true, showBullets: true, showLoot: true, showOtherEntities: true, showFavoriteLoot: true, isCombatMode: false, showDistanceRings: false, showPOIs: true };
}
return null;
}
function snapshotCurrentFilterState() {
const snap = {
showPlayers: state.settings.showPlayers,
showZombies: state.settings.showZombies,
showAnimals: state.settings.showAnimals,
showVehicles: state.settings.showVehicles,
showBullets: state.settings.showBullets,
showLoot: state.settings.showLoot,
showOtherEntities: state.settings.showOtherEntities,
showFavoriteLoot: state.settings.showFavoriteLoot,
isCombatMode: state.settings.isCombatMode,
showDistanceRings: state.settings.showDistanceRings,
showPOIs: state.settings.showPOIs,
};
for (const def of filterDefinitions.filter((d) => d.kind === "loot")) {
if (typeof state.settings[def.visibleKey] === "boolean") {
snap[def.visibleKey] = state.settings[def.visibleKey];
}
}
return snap;
}
function applyFilterState(data) {
if (!data) return;
for (const [key, val] of Object.entries(data)) {
if (key in defaultSettings) {
state.settings[key] = val;
}
}
}
function loadPreset(index) {
const preset = state.settings.presets[index] || getBuiltinPreset(index);
if (!preset) return;
applyFilterState(preset);
state.settings.activePreset = index;
persistSettings();
updateCombatToggleUi();
syncAllCheckboxes();
updatePresetButtons();
state.listDirty = true;
render();
const names = ["Combat", "Loot", "Full", "Custom"];
showToast(`Preset: ${names[index] || ""}`, "");
}
function savePreset(index) {
state.settings.presets[index] = snapshotCurrentFilterState();
state.settings.activePreset = index;
persistSettings();
updatePresetButtons();
const names = ["Combat", "Loot", "Full", "Custom"];
showToast(`Saved: ${names[index] || ""}`, "success");
}
function updatePresetButtons() {
for (const btn of document.querySelectorAll(".preset-btn")) {
btn.classList.toggle("active", Number(btn.dataset.preset) === state.settings.activePreset);
}
}
// ── Measurement tool ───────────────────────────────────────────────────────
function toggleMeasureMode(active) {
state.measureMode = active;
state.measurePointA = null;
state.measurePointB = null;
document.body.classList.toggle("measure-active", active);
elements.measureToggle.classList.toggle("active", active);
resizeMeasureLayer();
if (!active) {
const ctx = elements.measureLayer.getContext("2d");
ctx.clearRect(0, 0, elements.measureLayer.width, elements.measureLayer.height);
}
}
function resizeMeasureLayer() {
const rect = elements.viewport.getBoundingClientRect();
elements.measureLayer.width = Math.max(1, Math.round(rect.width));
elements.measureLayer.height = Math.max(1, Math.round(rect.height));
}
function drawMeasureLayer(cursorClientX, cursorClientY) {
resizeMeasureLayer();
const canvas = elements.measureLayer;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!state.measureMode) return;
function wsToScreen(wx, wy) {
return { x: wx * state.scale + state.offsetX, y: wy * state.scale + state.offsetY };
}
if (!state.measurePointA) {
if (cursorClientX !== undefined) {
const rect = elements.viewport.getBoundingClientRect();
const cx = cursorClientX - rect.left;
const cy = cursorClientY - rect.top;
ctx.strokeStyle = "rgba(232,160,32,0.6)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - 10, cy); ctx.lineTo(cx + 10, cy);
ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy + 10);
ctx.stroke();
}
return;
}
const as = wsToScreen(state.measurePointA.x, state.measurePointA.y);
ctx.fillStyle = "#e8a020";
ctx.beginPath();
ctx.arc(as.x, as.y, 5, 0, Math.PI * 2);
ctx.fill();
let ex, ey, endWorld;
if (state.measurePointB) {
const bs = wsToScreen(state.measurePointB.x, state.measurePointB.y);
ex = bs.x; ey = bs.y;
endWorld = state.measurePointB;
} else if (cursorClientX !== undefined) {
const rect = elements.viewport.getBoundingClientRect();
ex = cursorClientX - rect.left;
ey = cursorClientY - rect.top;
endWorld = screenToWorld(cursorClientX, cursorClientY);
} else {
return;
}
ctx.strokeStyle = "#e8a020";
ctx.lineWidth = 2;
ctx.setLineDash([8, 4]);
ctx.beginPath();
ctx.moveTo(as.x, as.y);
ctx.lineTo(ex, ey);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#e8a020";
ctx.beginPath();
ctx.arc(ex, ey, 5, 0, Math.PI * 2);
ctx.fill();
const dist = Math.hypot(endWorld.x - state.measurePointA.x, endWorld.y - state.measurePointA.y);
const midX = (as.x + ex) / 2;
const midY = (as.y + ey) / 2;
const text = `${Math.round(dist)} m`;
ctx.font = "bold 13px Arial, sans-serif";
const tw = ctx.measureText(text).width;
ctx.fillStyle = "rgba(13,15,18,0.85)";
ctx.fillRect(midX - tw / 2 - 4, midY - 10, tw + 8, 18);
ctx.fillStyle = "#e8a020";
ctx.fillText(text, midX - tw / 2, midY + 4);
}
// ── Context menu ───────────────────────────────────────────────────────────
function findNearestWaypoint(worldX, worldY, radius) {
let nearest = null;
let nearestDist = radius;
for (const wp of (state.settings.waypoints || [])) {
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;
}
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 };
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");
removeBtn.id = "ctxRemoveWaypoint";
removeBtn.className = "ctx-item";
removeBtn.textContent = `Remove${near.label ? `: ${near.label}` : " waypoint"}`;
removeBtn.addEventListener("click", () => {
state.settings.waypoints = state.settings.waypoints.filter((w) => w.id !== near.id);
persistSettings();
hideContextMenu();
state.listDirty = true;
render();
});
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
}
elements.ctxMenu.style.left = `${x}px`;
elements.ctxMenu.style.top = `${y}px`;
elements.ctxMenu.classList.add("open");
}
function hideContextMenu() {
elements.ctxMenu.classList.remove("open");
document.getElementById("ctxRemoveWaypoint")?.remove();
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
}
// ── Minimap ────────────────────────────────────────────────────────────────
function loadMinimapImage() {
if (!state.bootstrap?.mapId) return;
elements.minimapImg.style.display = "";
elements.minimapImg.src = apiUrl(`/map-image?mapId=${encodeURIComponent(state.bootstrap.mapId)}`);
elements.minimapImg.addEventListener("error", () => { elements.minimapImg.style.display = "none"; }, { once: true });
}
function updateMinimap() {
const show = state.settings.showMinimap && !!state.bootstrap && state.bootstrap.mapSize > 0;
elements.minimapEl.style.display = show ? "" : "none";
if (!show) return;
const mm = elements.minimapCanvas;
const dpr = window.devicePixelRatio || 1;
const CSS = 160;
mm.width = Math.round(CSS * dpr);
mm.height = Math.round(CSS * dpr);
const mapSize = state.bootstrap.mapSize;
const mmScale = CSS / mapSize;
const ctx = mm.getContext("2d");
// Work in CSS pixel units so all coordinates stay in [0, 160]
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, CSS, CSS);
const rect = elements.viewport.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const minX = Math.max(0, -state.offsetX / state.scale) * mmScale;
const minY = Math.max(0, -state.offsetY / state.scale) * mmScale;
const maxX = Math.min(mapSize, (-state.offsetX + rect.width) / state.scale) * mmScale;
const maxY = Math.min(mapSize, (-state.offsetY + rect.height) / state.scale) * mmScale;
const vw = maxX - minX;
const vh = maxY - minY;
ctx.fillStyle = "rgba(255,255,255,0.07)";
ctx.fillRect(minX, minY, vw, vh);
ctx.strokeStyle = "rgba(255,255,255,0.65)";
ctx.lineWidth = 1 / dpr;
ctx.strokeRect(minX + 0.5 / dpr, minY + 0.5 / dpr, vw - 1 / dpr, vh - 1 / dpr);
}
if (state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot?.localPlayer) {
const lp = state.lastSnapshot.localPlayer;
const px = lp.x * mmScale;
const py = lp.y * mmScale;
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.lineWidth = 1 / dpr;
ctx.stroke();
}
for (const wp of (state.settings.waypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
ctx.beginPath();
ctx.arc(wp.x * mmScale, wp.y * mmScale, 2, 0, Math.PI * 2);
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 ────────────────────────────────────────────────────
function getFilterDefinition(kind, item) {
if (kind === "loot") {
if (isFavoriteLoot(item)) return filtersByKey.favoriteLoot || null;
const filterKey = lootFilterKeyByCategory[item.lootCategory || "other"] || "lootOther";
return filtersByKey[filterKey] || null;
}
return filtersByKey[filterKeyByKind[kind]] || null;
}
function getLootCategoryDefinition(item) {
const filterKey = lootFilterKeyByCategory[item?.lootCategory || "other"] || "lootOther";
return filtersByKey[filterKey] || null;
}
function getMarkerColor(kind, item) {
const filterDefinition = getFilterDefinition(kind, item);
const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null;
return filterStyle ? filterStyle.color : (kind === "loot" && isFavoriteLoot(item) ? favoriteLootColor : "#f8fafc");
}
function isLootCategoryEnabled(category) {
if (!category) return true;
if (typeof state.settings[category] !== "boolean") state.settings[category] = true;
return state.settings[category];
}
function isItemVisibleByFilters(kind, item) {
if (!item) return false;
if (state.settings.isCombatMode && kind !== "players" && kind !== "bullets") return false;
if (kind === "loot" && isFavoriteLoot(item)) {
return state.settings.showFavoriteLoot !== false;
}
if (state.settings.distanceFilter > 0 && item.distance > state.settings.distanceFilter) return false;
if (kind === "loot") {
if (!state.settings.showLoot) return false;
if (!isLootCategoryEnabled(item.lootCategory)) return false;
if (state.lootSearchText.length > 0) {
if (!normalizeLootName(item.label || "").includes(state.lootSearchText)) return false;
}
}
if (kind === "players" && !state.settings.showPlayers) return false;
if (kind === "zombies" && !state.settings.showZombies) return false;
if (kind === "animals" && !state.settings.showAnimals) return false;
if (kind === "vehicles" && !state.settings.showVehicles) return false;
if (kind === "bullets" && !state.settings.showBullets) return false;
if (kind === "otherEntities" && !state.settings.showOtherEntities) return false;
if (kind === "bullets") {
const phantomLifetimeMs = Number(state.settings.filterStyles?.bullets?.phantomLifetimeMs || 0);
if (item.isPhantom && phantomLifetimeMs >= 0) {
const lastSeenAtUtcMs = Number(item.lastSeenAtUtcMs || 0);
if (lastSeenAtUtcMs > 0 && (Date.now() - lastSeenAtUtcMs) > phantomLifetimeMs) return false;
}
}
if (kind === "players" && state.lastSnapshot?.localPlayer) {
const dx = item.x - state.lastSnapshot.localPlayer.x;
const dy = item.y - state.lastSnapshot.localPlayer.y;
if ((dx * dx + dy * dy) <= 9.0) return false;
}
return true;
}
function getVisibleCollection(kind, list) {
return (list || []).filter((item) => isItemVisibleByFilters(kind, item));
}
// ── Label layout (PRESERVED VERBATIM) ────────────────────────────────────
function buildLabelText(item) {
const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : "";
if (item?.kind === "player") return getEntityLabelLines(item).join("\n");
return `${String(item?.label || "Unknown")}${distanceText}`;
}
function isEntityKind(kind) { return kind !== "loot"; }
function getRenderPriority(kind, item) {
if (isEntityKind(kind)) return 3;
return isFavoriteLoot(item) ? 2 : 1;
}
function getLayerClass(kind, item) {
const priority = getRenderPriority(kind, item);
if (priority === 3) return "layer-entity";
if (priority === 2) return "layer-favorite";
return "layer-loot";
}
function getLabelConfig(kind, item) {
const filterDefinition = getFilterDefinition(kind, item);
const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null;
if (kind === "bullets") {
return { filterDefinition, filterStyle, showLabel: false, textSize: filterStyle ? filterStyle.textSize : state.settings.textSize };
}
const showLabel = !!(state.settings.showLabels && item.label && (!filterStyle || filterStyle.showLabel));
return { filterDefinition, filterStyle, showLabel, textSize: filterStyle ? filterStyle.textSize : state.settings.textSize };
}
function useFixedScreenLabel(kind, itemOrGroup) {
if (kind === "players") return true;
if (kind === "loot") {
if (Object.prototype.hasOwnProperty.call(itemOrGroup || {}, "priority")) return itemOrGroup?.priority === 2;
return isFavoriteLoot(itemOrGroup);
}
return false;
}
function getActivePointerPair() {
const pointers = Array.from(state.activePointers.values());
return pointers.length >= 2 ? [pointers[0], pointers[1]] : null;
}
function getPointerDistance(first, second) {
const dx = second.clientX - first.clientX;
const dy = second.clientY - first.clientY;
return Math.hypot(dx, dy);
}
function getPointerMidpoint(first, second) {
return { clientX: (first.clientX + second.clientX) / 2, clientY: (first.clientY + second.clientY) / 2 };
}
function updateMapDrag(clientX, clientY) {
state.offsetX += clientX - state.lastX;
state.offsetY += clientY - state.lastY;
state.lastX = clientX;
state.lastY = clientY;
applyTransform();
}
function getRectIntersectionArea(first, second) {
const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));
return width * height;
}
function getPlacementPenalty(rect, occupiedRects, viewportRect) {
let overlapArea = 0;
for (const occupied of occupiedRects) {
overlapArea += getRectIntersectionArea(rect, occupied);
}
const overflowX = Math.max(0, viewportRect.left - rect.left) + Math.max(0, rect.right - viewportRect.right);
const overflowY = Math.max(0, viewportRect.top - rect.top) + Math.max(0, rect.bottom - viewportRect.bottom);
return (overlapArea * 10) + ((overflowX + overflowY) * 120);
}
function estimateLabelSize(text, fontSize) {
const safeFontSize = Math.max(10, Number(fontSize) || 14);
const safeText = String(text || "");
const lines = safeText.split("\n");
const lineHeight = Math.max(1, Math.ceil(safeFontSize * 1.15));
if (textMeasureContext) {
textMeasureContext.font = `700 ${safeFontSize}px Arial, sans-serif`;
let width = 1;
let ascent = Math.ceil(safeFontSize * 0.8);
let descent = Math.ceil(safeFontSize * 0.2);
for (const line of lines) {
const metrics = textMeasureContext.measureText(line || " ");
width = Math.max(width, Math.ceil(metrics.width));
ascent = Math.max(ascent, Math.ceil(metrics.actualBoundingBoxAscent || (safeFontSize * 0.8)));
descent = Math.max(descent, Math.ceil(metrics.actualBoundingBoxDescent || (safeFontSize * 0.2)));
}
return { width: Math.max(1, width), height: Math.max(1, ascent + descent + ((lines.length - 1) * lineHeight)) };
}
const longestLineLength = lines.reduce((maxLength, line) => Math.max(maxLength, String(line || "").length), 0);
return { width: Math.max(1, Math.round(longestLineLength * safeFontSize * 0.52)), height: Math.max(1, Math.round(safeFontSize + ((lines.length - 1) * lineHeight))) };
}
function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
const size = estimateLabelSize(text, fontSize);
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
return { width: size.width / Math.sqrt(effectiveScale), height: size.height / Math.sqrt(effectiveScale) };
}
function buildPinnedLabelCandidates(entry, size) {
const markerSize = entry.label.filterStyle ? entry.label.filterStyle.markerSize : (entry.kind === "vehicles" ? 14 : 12);
const radius = Math.max(6, markerSize * 0.7);
const gap = Math.max(2, Math.round(markerSize * 0.2));
const sideCandidates = {
"middle-right": { left: radius + gap, top: -Math.round(size.height / 2), distance: gap },
"middle-left": { left: -(size.width + radius + gap), top: -Math.round(size.height / 2), distance: gap },
"top-center": { left: -Math.round(size.width / 2), top: -(size.height + radius + gap), distance: gap },
"bottom-center": { left: -Math.round(size.width / 2), top: radius + gap, distance: gap }
};
return [sideCandidates["middle-left"], sideCandidates["middle-right"], sideCandidates["top-center"], sideCandidates["bottom-center"]];
}
function buildScreenRect(screenX, screenY, candidate, size, scale) {
return {
left: screenX + (candidate.left * scale),
top: screenY + (candidate.top * scale),
right: screenX + ((candidate.left + size.width) * scale),
bottom: screenY + ((candidate.top + size.height) * scale)
};
}
function isPointOutsideViewport(screenX, screenY, viewportRect) {
return screenX < viewportRect.left || screenX > viewportRect.right || screenY < viewportRect.top || screenY > viewportRect.bottom;
}
function getBlockedViewportRects() {
return [];
}
function buildViewportEdgeCandidates(screenX, screenY, size, viewportRect) {
const padding = 10;
const labelScreenWidth = size.width * state.scale;
const labelScreenHeight = size.height * state.scale;
const availableLeft = viewportRect.left + padding;
const availableTop = viewportRect.top + padding;
const availableRight = viewportRect.right - labelScreenWidth - padding;
const availableBottom = viewportRect.bottom - labelScreenHeight - padding;
const overflowLeft = screenX < viewportRect.left ? viewportRect.left - screenX : Number.POSITIVE_INFINITY;
const overflowRight = screenX > viewportRect.right ? screenX - viewportRect.right : Number.POSITIVE_INFINITY;
const overflowTop = screenY < viewportRect.top ? viewportRect.top - screenY : Number.POSITIVE_INFINITY;
const overflowBottom = screenY > viewportRect.bottom ? screenY - viewportRect.bottom : Number.POSITIVE_INFINITY;
const edgeCandidates = [
{ edge: "left", overflow: overflowLeft, left: availableLeft, top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom)) },
{ edge: "right", overflow: overflowRight, left: availableRight, top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom)) },
{ edge: "top", overflow: overflowTop, left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)), top: availableTop },
{ edge: "bottom", overflow: overflowBottom, left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)), top: availableBottom }
];
return edgeCandidates
.sort((lhs, rhs) => lhs.overflow - rhs.overflow)
.map((candidate) => ({
left: (candidate.left - screenX) / state.scale,
top: (candidate.top - screenY) / state.scale,
anchorX: 0,
anchorY: 0,
distance: Number.isFinite(candidate.overflow) ? candidate.overflow : 100000
}));
}
function normalizeGroupLootLabel(label) {
return String(label || "").trim().toLowerCase();
}
function getLootGroupingKey(item) {
return `${normalizeGroupLootLabel(item?.label)}:${String(item?.lootCategory || "other").trim().toLowerCase()}`;
}
function canUsePriorityGrouping(entry) {
return getRenderPriority(entry.kind, entry.item) >= 2 && entry.kind !== "players";
}
function buildPriorityLabelGroups(entries) {
const groups = [];
const groupableEntries = entries
.filter((entry) => canUsePriorityGrouping(entry) && entry.label.showLabel)
.map((entry) => ({
entry,
key: `${entry.kind}:${getRenderPriority(entry.kind, entry.item)}:${entry.kind === "loot" ? getLootGroupingKey(entry.item) : normalizeGroupLootLabel(entry.item.label)}`,
screenX: (entry.item.x * state.scale) + state.offsetX,
screenY: (entry.item.y * state.scale) + state.offsetY
}))
.sort((lhs, rhs) => {
const byKey = lhs.key.localeCompare(rhs.key);
if (byKey !== 0) return byKey;
if (lhs.screenY !== rhs.screenY) return lhs.screenY - rhs.screenY;
return lhs.screenX - rhs.screenX;
});
const clusterDistance = Math.max(24, Math.round(Number(state.settings.sameLootMergeRadius || defaultSettings.sameLootMergeRadius) * 0.55));
for (const candidate of groupableEntries) {
let bestGroup = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const group of groups) {
if (group.key !== candidate.key) continue;
const dx = candidate.screenX - group.screenX;
const dy = candidate.screenY - group.screenY;
const distance = Math.hypot(dx, dy);
if (distance <= clusterDistance && distance < bestDistance) { bestDistance = distance; bestGroup = group; }
}
if (!bestGroup) {
groups.push({
key: candidate.key,
kind: candidate.entry.kind,
priority: getRenderPriority(candidate.entry.kind, candidate.entry.item),
entries: [candidate.entry],
label: candidate.entry.label,
markerColor: candidate.entry.markerColor,
baseText: String(candidate.entry.item.label || "Unknown"),
anchorX: candidate.entry.item.x,
anchorY: candidate.entry.item.y,
screenX: candidate.screenX,
screenY: candidate.screenY,
labelText: String(candidate.entry.item.label || "Unknown"),
labelPlacement: null
});
continue;
}
bestGroup.entries.push(candidate.entry);
const count = bestGroup.entries.length;
bestGroup.anchorX = ((bestGroup.anchorX * (count - 1)) + candidate.entry.item.x) / count;
bestGroup.anchorY = ((bestGroup.anchorY * (count - 1)) + candidate.entry.item.y) / count;
bestGroup.screenX = ((bestGroup.screenX * (count - 1)) + candidate.screenX) / count;
bestGroup.screenY = ((bestGroup.screenY * (count - 1)) + candidate.screenY) / count;
}
for (const group of groups) {
let maxWorldRadius = 0;
for (const entry of group.entries) {
const worldDx = entry.item.x - group.anchorX;
const worldDy = entry.item.y - group.anchorY;
maxWorldRadius = Math.max(maxWorldRadius, Math.hypot(worldDx, worldDy));
}
group.clusterRadiusWorld = maxWorldRadius;
group.labelText = group.entries.length > 1 ? `${group.baseText} x${group.entries.length}` : group.baseText;
}
return {
groups: groups.filter((group) => group.entries.length > 1),
groupedEntries: new Set(groups.filter((group) => group.entries.length > 1).flatMap((group) => group.entries))
};
}
function buildOrdinaryLootLabelGroups(entries) {
if (!state.settings.mergeSameLootLabels) {
return entries
.filter((entry) => getRenderPriority(entry.kind, entry.item) === 1 && entry.label.showLabel)
.map((entry) => ({
labelKey: getLootGroupingKey(entry.item),
entries: [entry],
label: entry.label,
markerColor: entry.markerColor,
baseText: String(entry.item.label || "Unknown"),
anchorX: entry.item.x,
anchorY: entry.item.y,
screenX: (entry.item.x * state.scale) + state.offsetX,
screenY: (entry.item.y * state.scale) + state.offsetY,
clusterRadiusScreen: 0,
clusterRadiusWorld: 0,
labelText: String(entry.item.label || "Unknown"),
labelPlacement: null
}));
}
const groups = [];
const groupableEntries = entries
.filter((entry) => getRenderPriority(entry.kind, entry.item) === 1 && entry.label.showLabel)
.map((entry) => ({
entry,
labelKey: getLootGroupingKey(entry.item),
screenX: (entry.item.x * state.scale) + state.offsetX,
screenY: (entry.item.y * state.scale) + state.offsetY
}))
.sort((lhs, rhs) => {
const byLabel = lhs.labelKey.localeCompare(rhs.labelKey);
if (byLabel !== 0) return byLabel;
if (lhs.screenY !== rhs.screenY) return lhs.screenY - rhs.screenY;
return lhs.screenX - rhs.screenX;
});
const clusterDistance = Number(state.settings.sameLootMergeRadius || defaultSettings.sameLootMergeRadius);
for (const candidate of groupableEntries) {
let bestGroup = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const group of groups) {
if (group.labelKey !== candidate.labelKey) continue;
const dx = candidate.screenX - group.screenX;
const dy = candidate.screenY - group.screenY;
const distance = Math.hypot(dx, dy);
if (distance <= clusterDistance && distance < bestDistance) { bestDistance = distance; bestGroup = group; }
}
if (!bestGroup) {
groups.push({
labelKey: candidate.labelKey,
entries: [candidate.entry],
label: candidate.entry.label,
markerColor: candidate.entry.markerColor,
baseText: String(candidate.entry.item.label || "Unknown"),
anchorX: candidate.entry.item.x,
anchorY: candidate.entry.item.y,
screenX: candidate.screenX,
screenY: candidate.screenY
});
continue;
}
bestGroup.entries.push(candidate.entry);
const count = bestGroup.entries.length;
bestGroup.anchorX = ((bestGroup.anchorX * (count - 1)) + candidate.entry.item.x) / count;
bestGroup.anchorY = ((bestGroup.anchorY * (count - 1)) + candidate.entry.item.y) / count;
bestGroup.screenX = ((bestGroup.screenX * (count - 1)) + candidate.screenX) / count;
bestGroup.screenY = ((bestGroup.screenY * (count - 1)) + candidate.screenY) / count;
}
for (const group of groups) {
let maxScreenRadius = 0;
let maxWorldRadius = 0;
for (const entry of group.entries) {
const screenDx = ((entry.item.x * state.scale) + state.offsetX) - group.screenX;
const screenDy = ((entry.item.y * state.scale) + state.offsetY) - group.screenY;
maxScreenRadius = Math.max(maxScreenRadius, Math.hypot(screenDx, screenDy));
const worldDx = entry.item.x - group.anchorX;
const worldDy = entry.item.y - group.anchorY;
maxWorldRadius = Math.max(maxWorldRadius, Math.hypot(worldDx, worldDy));
}
group.clusterRadiusScreen = maxScreenRadius;
group.clusterRadiusWorld = maxWorldRadius;
group.labelText = group.entries.length > 1 ? `${group.baseText} x${group.entries.length}` : group.baseText;
group.labelPlacement = null;
}
return groups;
}
function buildOrdinaryGroupLabelCandidates(group, size) {
const spreadMultiplier = Number(state.settings.ordinaryLootSpread || 1) * Number(state.settings.groupedLootSpread || 1);
const radius = Math.max(8, group.clusterRadiusWorld + 8);
const primaryGap = Math.round(14 * spreadMultiplier);
const secondaryGap = Math.round(26 * spreadMultiplier);
return [
{ left: radius + primaryGap, top: 2, anchorX: 2, anchorY: 0, distance: primaryGap },
{ left: radius + primaryGap, top: -size.height - 2, anchorX: 2, anchorY: size.height, distance: primaryGap },
{ left: -(size.width + radius + primaryGap), top: 2, anchorX: size.width - 2, anchorY: 0, distance: primaryGap },
{ left: -(size.width + radius + primaryGap), top: -size.height - 2, anchorX: size.width - 2, anchorY: size.height, distance: primaryGap },
{ left: radius + secondaryGap, top: secondaryGap, anchorX: 2, anchorY: 0, distance: secondaryGap + 6 },
{ left: radius + secondaryGap, top: -(size.height + secondaryGap), anchorX: 2, anchorY: size.height, distance: secondaryGap + 6 },
{ left: -(size.width + radius + secondaryGap), top: secondaryGap, anchorX: size.width - 2, anchorY: 0, distance: secondaryGap + 6 },
{ left: -(size.width + radius + secondaryGap), top: -(size.height + secondaryGap), anchorX: size.width - 2, anchorY: size.height, distance: secondaryGap + 6 }
];
}
function getLabelAnchorPoints(labelPlacement, labelSize) {
const width = labelSize.width;
const height = labelSize.height;
const left = labelPlacement.left;
const top = labelPlacement.top;
const middleX = left + (width / 2);
const middleY = top + (height / 2);
const right = left + width;
const bottom = top + height;
return {
"top-left": { x: left, y: top },
"top-center": { x: middleX, y: top },
"top-right": { x: right, y: top },
"middle-left": { x: left, y: middleY },
"middle-right": { x: right, y: middleY },
"bottom-left": { x: left, y: bottom },
"bottom-center": { x: middleX, y: bottom },
"bottom-right": { x: right, y: bottom }
};
}
function getLabelLineTarget(labelPlacement, labelSize, sourceX = 0, sourceY = 0) {
const anchorPoints = getLabelAnchorPoints(labelPlacement, labelSize);
if (state.settings.lineAnchorMode !== "auto-nearest") {
return anchorPoints[state.settings.lineAnchorMode] || anchorPoints[defaultSettings.lineAnchorMode];
}
let bestPoint = anchorPoints["bottom-center"];
let bestDistance = Number.POSITIVE_INFINITY;
for (const point of Object.values(anchorPoints)) {
const dx = point.x - sourceX;
const dy = point.y - sourceY;
const distance = (dx * dx) + (dy * dy);
if (distance < bestDistance) { bestDistance = distance; bestPoint = point; }
}
return bestPoint;
}
function computeLabelPlacements(entries) {
const viewportRect = { left: 0, top: 0, right: elements.viewport.clientWidth, bottom: elements.viewport.clientHeight };
const occupiedRects = [...getBlockedViewportRects()];
const priorityGrouping = buildPriorityLabelGroups(entries);
for (const entry of entries) entry.labelPlacement = null;
for (const entry of entries) {
if (!entry.label.showLabel) continue;
const priority = getRenderPriority(entry.kind, entry.item);
const nearAnchor = priority >= 2;
const size = getScaledLabelLayoutSize(entry.kind, entry.item, entry.labelText, entry.label.textSize);
const screenX = (entry.item.x * state.scale) + state.offsetX;
const screenY = (entry.item.y * state.scale) + state.offsetY;
const useViewportEdgePlacement = entry.kind === "players" && isPointOutsideViewport(screenX, screenY, viewportRect);
if (!nearAnchor) continue;
if (priorityGrouping.groupedEntries.has(entry)) continue;
const candidates = useViewportEdgePlacement
? buildViewportEdgeCandidates(screenX, screenY, size, viewportRect)
: buildPinnedLabelCandidates(entry, size);
let bestPlacement = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const rect = buildScreenRect(screenX, screenY, candidate, size, state.scale);
const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect);
const score = penalty + (candidate.distance * (nearAnchor ? 16 : 8));
if (score < bestScore) { bestScore = score; bestPlacement = { ...candidate, rect }; }
}
if (!bestPlacement) { entry.labelPlacement = null; continue; }
if (!nearAnchor && bestScore > 1400) { entry.labelPlacement = null; continue; }
occupiedRects.push(bestPlacement.rect);
entry.labelPlacement = { left: bestPlacement.left, top: bestPlacement.top, anchorX: bestPlacement.anchorX, anchorY: bestPlacement.anchorY };
}
for (const group of priorityGrouping.groups) {
const size = getScaledLabelLayoutSize(group.kind, group, group.labelText, group.label.textSize);
const groupEntry = group.entries[0];
const candidates = buildPinnedLabelCandidates(groupEntry, size);
let bestPlacement = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const rect = buildScreenRect(group.screenX, group.screenY, candidate, size, state.scale);
const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect);
const score = penalty + (candidate.distance * 20);
if (score < bestScore) { bestScore = score; bestPlacement = { ...candidate, rect }; }
}
if (!bestPlacement) { group.labelPlacement = null; continue; }
occupiedRects.push(bestPlacement.rect);
group.labelPlacement = { left: bestPlacement.left, top: bestPlacement.top, anchorX: bestPlacement.anchorX, anchorY: bestPlacement.anchorY };
}
const groups = buildOrdinaryLootLabelGroups(entries);
for (const group of groups) {
const size = getScaledLabelLayoutSize("loot", group, group.labelText, group.label.textSize);
const candidates = buildOrdinaryGroupLabelCandidates(group, size);
let bestPlacement = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const rect = buildScreenRect(group.screenX, group.screenY, candidate, size, state.scale);
const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect);
const score = penalty + (candidate.distance * 8);
if (score < bestScore) { bestScore = score; bestPlacement = { ...candidate, rect }; }
}
if (!bestPlacement) { group.labelPlacement = null; continue; }
occupiedRects.push(bestPlacement.rect);
group.labelPlacement = { left: bestPlacement.left, top: bestPlacement.top, anchorX: bestPlacement.anchorX, anchorY: bestPlacement.anchorY };
}
return { ordinaryGroups: groups, priorityGroups: priorityGrouping.groups };
}
// ── Filter card rendering ──────────────────────────────────────────────────
function renderFilterCards() {
const buildEntityCard = (definition) => {
const style = state.settings.filterStyles[definition.key];
const visible = !!state.settings[definition.visibleKey];
const expanded = !!state.settings.filterExpanded[definition.key];
const directionControl = definition.key === "players" ? `
<label class="mini-control">
<span>View line</span>
<input class="range-direction" type="range" min="10" max="60" step="1" value="${style.directionLength || 28}">
<strong class="value-direction">${style.directionLength || 28}px</strong>
</label>` : "";
const bulletControls = definition.key === "bullets" ? `
<label class="mini-toggle bullet-toggle-trajectory">
<input type="checkbox" ${style.showTrajectory !== false ? "checked" : ""}>
<span>Show trajectory</span>
</label>
<label class="mini-toggle bullet-toggle-prediction">
<input type="checkbox" ${style.showPrediction !== false ? "checked" : ""}>
<span>Show prediction</span>
</label>
<label class="mini-control">
<span>Phantom</span>
<input class="range-phantom" type="range" min="0" max="60000" step="250" value="${style.phantomLifetimeMs || 5000}">
<strong class="value-phantom">${style.phantomLifetimeMs || 5000}ms</strong>
</label>
<label class="mini-control">
<span>Prediction</span>
<input class="range-prediction" type="range" min="25" max="1000" step="25" value="${style.predictionDistance || 250}">
<strong class="value-prediction">${style.predictionDistance || 250}m</strong>
</label>` : "";
const textSizeControl = definition.key !== "bullets" ? `
<label class="mini-control">
<span>Text size</span>
<input class="range-text" type="range" min="10" max="24" step="1" value="${style.textSize}">
<strong class="value-text">${style.textSize}px</strong>
</label>` : "";
const showLabelControl = definition.key !== "bullets" ? `
<label class="mini-toggle toggle-show-label">
<input type="checkbox" ${style.showLabel ? "checked" : ""}>
<span>Show label</span>
</label>` : "";
const card = document.createElement("section");
card.className = `filter-card${expanded ? " expanded" : ""}`;
card.innerHTML = `
<div class="filter-row">
<input type="checkbox" ${visible ? "checked" : ""}>
<span class="filter-swatch" style="background:${style.color}"></span>
<span class="filter-name">${definition.label}</span>
<span class="filter-count" data-count-key="${definition.key}"></span>
<button class="filter-gear" type="button" aria-label="Configure ${definition.label}">⚙</button>
</div>
<div class="filter-extra">
<label class="mini-control">
<span>Color</span>
<input type="color" value="${style.color}">
</label>
<label class="mini-control">
<span>Marker size</span>
<input class="range-marker" type="range" min="4" max="26" step="1" value="${style.markerSize}">
<strong class="value-marker">${style.markerSize}px</strong>
</label>
${textSizeControl}
${directionControl}
${bulletControls}
${showLabelControl}
</div>`;
const visibleInput = card.querySelector(".filter-row input[type='checkbox']");
const gearButton = card.querySelector(".filter-gear");
const colorInput = card.querySelector('input[type="color"]');
const markerRange = card.querySelector(".range-marker");
const textRange = card.querySelector(".range-text");
const labelInput = card.querySelector(".toggle-show-label input");
const markerValue = card.querySelector(".value-marker");
const textValue = card.querySelector(".value-text");
const directionRange = card.querySelector(".range-direction");
const directionValue = card.querySelector(".value-direction");
const swatch = card.querySelector(".filter-swatch");
const trajectoryInput = card.querySelector(".bullet-toggle-trajectory input");
const predictionInput = card.querySelector(".bullet-toggle-prediction input");
const phantomRange = card.querySelector(".range-phantom");
const phantomValue = card.querySelector(".value-phantom");
const predictionRange = card.querySelector(".range-prediction");
const predictionValue = card.querySelector(".value-prediction");
visibleInput.addEventListener("change", () => {
state.settings[definition.visibleKey] = visibleInput.checked;
state.settings.activePreset = null;
updatePresetButtons();
updateCountBadges();
persistSettings();
state.listDirty = true;
render();
});
gearButton.addEventListener("click", () => {
state.settings.filterExpanded[definition.key] = !state.settings.filterExpanded[definition.key];
persistSettings();
renderFilterCards();
});
colorInput.addEventListener("input", () => {
state.settings.filterStyles[definition.key].color = colorInput.value;
if (swatch) swatch.style.background = colorInput.value;
persistSettings();
render();
});
markerRange.addEventListener("input", () => {
state.settings.filterStyles[definition.key].markerSize = Number(markerRange.value);
markerValue.textContent = `${markerRange.value}px`;
persistSettings();
render();
});
if (textRange && textValue) {
textRange.addEventListener("input", () => {
state.settings.filterStyles[definition.key].textSize = Number(textRange.value);
textValue.textContent = `${textRange.value}px`;
persistSettings();
render();
});
}
if (labelInput) {
labelInput.addEventListener("change", () => {
state.settings.filterStyles[definition.key].showLabel = labelInput.checked;
persistSettings();
render();
});
}
if (directionRange && directionValue) {
directionRange.addEventListener("input", () => {
state.settings.filterStyles[definition.key].directionLength = Number(directionRange.value);
directionValue.textContent = `${directionRange.value}px`;
persistSettings();
render();
});
}
if (trajectoryInput) {
trajectoryInput.addEventListener("change", () => {
state.settings.filterStyles[definition.key].showTrajectory = trajectoryInput.checked;
persistSettings();
render();
});
}
if (predictionInput) {
predictionInput.addEventListener("change", () => {
state.settings.filterStyles[definition.key].showPrediction = predictionInput.checked;
syncBulletVisuals(state.lastSnapshot?.bullets || []);
ensureBulletAnimation();
persistSettings();
render();
});
}
if (phantomRange && phantomValue) {
phantomRange.addEventListener("input", () => {
state.settings.filterStyles[definition.key].phantomLifetimeMs = Number(phantomRange.value);
phantomValue.textContent = `${phantomRange.value}ms`;
persistSettings();
render();
});
}
if (predictionRange && predictionValue) {
predictionRange.addEventListener("input", () => {
state.settings.filterStyles[definition.key].predictionDistance = Number(predictionRange.value);
predictionValue.textContent = `${predictionRange.value}m`;
syncBulletVisuals(state.lastSnapshot?.bullets || []);
ensureBulletAnimation();
persistSettings();
render();
});
}
return card;
};
const buildLootCard = (definition) => {
const style = state.settings.filterStyles[definition.key];
const visible = !!state.settings[definition.visibleKey];
const card = document.createElement("section");
card.className = "filter-card";
card.innerHTML = `
<div class="filter-row">
<input type="checkbox" ${visible ? "checked" : ""}>
<span class="filter-swatch" style="background:${style.color}; cursor:pointer"></span>
<span class="filter-name">${definition.label}</span>
<span class="filter-count" data-count-key="${definition.key}"></span>
</div>`;
const visibleInput = card.querySelector("input[type='checkbox']");
const swatch = card.querySelector(".filter-swatch");
visibleInput.addEventListener("change", () => {
state.settings[definition.visibleKey] = visibleInput.checked;
persistSettings();
state.listDirty = true;
render();
});
// Clicking the swatch opens a native color picker
swatch.addEventListener("click", () => {
const picker = document.createElement("input");
picker.type = "color";
picker.value = style.color;
picker.style.position = "fixed";
picker.style.opacity = "0";
picker.style.pointerEvents = "none";
document.body.appendChild(picker);
picker.addEventListener("input", () => {
state.settings.filterStyles[definition.key].color = picker.value;
swatch.style.background = picker.value;
persistSettings();
render();
});
picker.addEventListener("change", () => picker.remove());
picker.click();
});
return card;
};
elements.entityFilterList.replaceChildren(...filterDefinitions.filter((d) => d.kind !== "loot").map(buildEntityCard));
elements.lootFilterList.replaceChildren(...filterDefinitions.filter((d) => d.kind === "loot").map(buildLootCard));
}
// ── Player / loot list rendering ───────────────────────────────────────────
function renderPlayersList() {
if (!state.lastSnapshot) {
elements.playersList.innerHTML = '<div class="players-empty">No players</div>';
return;
}
const players = Array.isArray(state.lastSnapshot.playerList) ? state.lastSnapshot.playerList.slice() : [];
const visibleOnMapByFilters = (player) => {
if (!player.visibleOnMap) return false;
if (!state.settings.showPlayers) return false;
if (state.settings.distanceFilter > 0 && player.distance >= 0 && player.distance > state.settings.distanceFilter) return false;
return true;
};
const sortVisiblePlayers = (lhs, rhs) => {
if ((lhs.distance || 0) !== (rhs.distance || 0)) return (lhs.distance || 0) - (rhs.distance || 0);
return String(lhs.steamId || "").localeCompare(String(rhs.steamId || ""));
};
const sortHiddenPlayers = (lhs, rhs) => {
const byLabel = String(lhs.label || "").localeCompare(String(rhs.label || ""), undefined, { sensitivity: "base" });
if (byLabel !== 0) return byLabel;
return String(lhs.steamId || "").localeCompare(String(rhs.steamId || ""));
};
const getThreatColor = (player) => {
if (!player.visibleOnMap || player.distance < 0) return "#94a3b8";
if (player.distance < 200) return "#ef4444";
if (player.distance < 500) return "#f97316";
return "#22c55e";
};
const renderPlayerRow = (player) => {
const nameColor = getThreatColor(player);
const steamId = player.steamId && String(player.steamId).trim().length > 0 ? String(player.steamId) : "unknown";
const steamUrl = steamId !== "unknown" ? `https://steamcommunity.com/profiles/${encodeURIComponent(steamId)}` : "";
const distText = player.visibleOnMap && player.distance >= 0 ? `${Math.round(player.distance)}m` : "";
return `
<div class="players-row">
<div class="players-name" style="color:${nameColor}">${renderSafeMultilineHtml(getEntityLabelLines(player))}</div>
<div class="players-detail">
<span>${distText}</span>
<span class="players-steam">${steamUrl ? `<a href="${steamUrl}" target="_blank" rel="noopener noreferrer">${sanitize(steamId)}</a>` : sanitize(steamId)}</span>
</div>
</div>`;
};
const visibleRows = players.filter(visibleOnMapByFilters).sort(sortVisiblePlayers).map(renderPlayerRow).join("");
const hiddenRows = players.filter((p) => !visibleOnMapByFilters(p)).sort(sortHiddenPlayers).map(renderPlayerRow).join("");
let rows = "";
if (visibleRows.length > 0) {
rows += '<div class="players-section-title">On Map</div>';
rows += visibleRows;
}
if (hiddenRows.length > 0) {
rows += `<div class="players-section-title">${rows.length > 0 ? "Off Map" : "Players"}</div>`;
rows += hiddenRows;
}
elements.playersList.innerHTML = rows || '<div class="players-empty">No players</div>';
}
function renderLootList() {
if (!state.lastSnapshot) {
elements.lootList.innerHTML = '<div class="players-empty">No loot</div>';
elements.lootInfo.textContent = "Loot: waiting for snapshot…";
return;
}
const categoryOrder = new Map(filterDefinitions.filter((d) => d.kind === "loot").map((d, i) => [d.category || "other", i]));
const sortLootItems = (lhs, rhs) => {
const lhsCat = String(lhs.lootCategory || "other");
const rhsCat = String(rhs.lootCategory || "other");
const byCategory = (categoryOrder.get(lhsCat) ?? 999) - (categoryOrder.get(rhsCat) ?? 999);
if (byCategory !== 0) return byCategory;
if ((lhs.distance || 0) !== (rhs.distance || 0)) return (lhs.distance || 0) - (rhs.distance || 0);
const byLabel = String(lhs.label || "").localeCompare(String(rhs.label || ""));
if (byLabel !== 0) return byLabel;
return String(lhs.id || "").localeCompare(String(rhs.id || ""));
};
const lootItems = getVisibleCollection("loot", state.lastSnapshot.loot || []).slice().sort(sortLootItems);
const favoriteItems = lootItems.filter(isFavoriteLoot);
const regularItems = lootItems.filter((item) => !isFavoriteLoot(item));
const searchSuffix = state.lootSearchText ? ` matching "${state.lootSearchText}"` : "";
elements.lootInfo.textContent = `${lootItems.length} item${lootItems.length === 1 ? "" : "s"}${searchSuffix}`;
const renderRow = (item) => {
const favorite = isFavoriteLoot(item);
const color = getMarkerColor("loot", item);
const categoryLabel = getLootCategoryDefinition(item)?.label || "Other";
return `
<button class="loot-row${favorite ? " favorite" : ""}" type="button" data-loot-name="${sanitize(item.label || "")}" style="--loot-color:${color}">
<span class="loot-name">${sanitize(item.label || "Unknown")}</span>
<span class="loot-distance">${Math.round(Number(item.distance || 0))}m</span>
<span class="loot-meta">${sanitize(categoryLabel)}${favorite ? " • ★" : ""}</span>
</button>`;
};
let rows = "";
if (favoriteItems.length > 0) {
let currentCategory = "";
rows += '<div class="loot-section-title">Favorites</div>';
for (const item of favoriteItems) {
const category = String(item.lootCategory || "other");
if (category !== currentCategory) {
currentCategory = category;
rows += `<div class="loot-group-title">${sanitize(getLootCategoryDefinition(item)?.label || "Other")}</div>`;
}
rows += renderRow(item);
}
}
if (regularItems.length > 0) {
let currentCategory = "";
if (rows.length > 0) rows += '<div class="loot-section-title">Loot</div>';
for (const item of regularItems) {
const category = String(item.lootCategory || "other");
if (category !== currentCategory) {
currentCategory = category;
rows += `<div class="loot-group-title">${sanitize(getLootCategoryDefinition(item)?.label || "Other")}</div>`;
}
rows += renderRow(item);
}
}
elements.lootList.innerHTML = rows || '<div class="players-empty">No loot matches filters</div>';
}
// ── Count badges and filter counts ─────────────────────────────────────────
function updateCountBadges() {
if (!state.lastSnapshot) return;
const s = state.lastSnapshot;
const counts = {
showPlayers: (s.players || []).length,
showZombies: (s.zombies || []).length,
showAnimals: (s.animals || []).length,
showVehicles: (s.vehicles || []).length,
showBullets: (s.bullets || []).length,
showLoot: (s.loot || []).length,
};
for (const badge of document.querySelectorAll(".count-badge[data-key]")) {
const key = badge.dataset.key;
const span = badge.querySelector("span");
if (span && key && counts[key] !== undefined) span.textContent = String(counts[key]);
badge.classList.toggle("disabled", !state.settings[key]);
}
}
function updateFilterCounts() {
if (!state.lastSnapshot) return;
const s = state.lastSnapshot;
const counts = {
players: (s.players || []).length,
zombies: (s.zombies || []).length,
animals: (s.animals || []).length,
vehicles: (s.vehicles || []).length,
bullets: (s.bullets || []).length,
otherEntities: (s.otherEntities || []).length,
favoriteLoot: (s.loot || []).filter(isFavoriteLoot).length,
};
for (const item of (s.loot || [])) {
const cat = item.lootCategory || "other";
counts[cat] = (counts[cat] || 0) + 1;
}
for (const el of document.querySelectorAll(".filter-count[data-count-key]")) {
const key = el.dataset.countKey;
el.textContent = counts[key] !== undefined ? String(counts[key]) : "";
}
}
// ── Entity marker creation ─────────────────────────────────────────────────
function createMarker(entry) {
const { kind, item, markerColor, labelText, label } = entry;
const filterStyle = label.filterStyle;
const markerSize = filterStyle ? filterStyle.markerSize : (kind === "vehicles" ? 14 : 12);
const borderSize = Math.max(1, Math.round(markerSize * 0.16));
const favorite = kind === "loot" && isFavoriteLoot(item);
const labelPlacement = entry.labelPlacement;
const marker = document.createElement("div");
marker.className = `entity ${getLayerClass(kind, item)}${kind === "players" ? " player-entity" : ""}${kind === "bullets" && item.isPhantom ? " bullet-phantom" : ""}${kind === "bullets" && item.isCompleted ? " bullet-completed" : ""}`;
marker.style.left = `${item.x}px`;
marker.style.top = `${item.y}px`;
const glyph = document.createElement("div");
glyph.className = `marker ${kind}${favorite ? " favorite" : ""}`;
glyph.style.width = `${markerSize}px`;
glyph.style.height = `${markerSize}px`;
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;
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);
}
marker.appendChild(glyph);
if (label.showLabel && labelPlacement && getRenderPriority(kind, item) >= 2) {
const labelNode = document.createElement("span");
labelNode.className = `label${favorite ? " favorite" : ""}`;
labelNode.textContent = labelText;
labelNode.style.fontSize = `${label.textSize}px`;
labelNode.style.color = 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);
}
return marker;
}
function createPriorityLabelGroup(group) {
const labelPlacement = group.labelPlacement;
if (!labelPlacement) return null;
const marker = document.createElement("div");
marker.className = `entity ${group.priority >= 3 ? "layer-entity" : "layer-favorite"}`;
marker.style.left = `${group.anchorX}px`;
marker.style.top = `${group.anchorY}px`;
const labelNode = document.createElement("span");
labelNode.className = `label${group.priority === 2 ? " favorite" : ""}`;
labelNode.textContent = group.labelText;
labelNode.style.fontSize = `${group.label.textSize}px`;
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);
const labelSize = getScaledLabelLayoutSize(group.kind, group, group.labelText, group.label.textSize);
for (const entry of group.entries) {
const labelLine = document.createElement("span");
labelLine.className = `label-line${group.priority === 2 ? " favorite" : ""}`;
const itemDx = entry.item.x - group.anchorX;
const itemDy = entry.item.y - group.anchorY;
const lineTarget = getLabelLineTarget(labelPlacement, labelSize, itemDx, itemDy);
const startX = itemDx;
const startY = itemDy;
const drawX = lineTarget.x - startX;
const drawY = lineTarget.y - startY;
const angle = Math.atan2(drawY, drawX) * (180 / Math.PI);
const length = Math.hypot(drawX, drawY);
labelLine.style.left = `${startX}px`;
labelLine.style.top = `${startY}px`;
labelLine.style.width = `${Math.max(4, length)}px`;
labelLine.style.transform = `rotate(${angle}deg)`;
labelLine.style.color = group.markerColor;
marker.appendChild(labelLine);
}
return marker;
}
function createLootLabelGroup(group) {
const labelPlacement = group.labelPlacement;
if (!labelPlacement) return null;
const labelSize = estimateLabelSize(group.labelText, group.label.textSize);
const marker = document.createElement("div");
marker.className = "item-label-anchor";
marker.style.left = `${group.anchorX}px`;
marker.style.top = `${group.anchorY}px`;
const labelNode = document.createElement("span");
labelNode.className = "label loot-label";
labelNode.textContent = group.labelText;
labelNode.style.fontSize = `${group.label.textSize}px`;
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) {
const labelLine = document.createElement("span");
labelLine.className = "label-line loot-line";
const itemDx = entry.item.x - group.anchorX;
const itemDy = entry.item.y - group.anchorY;
const lineTarget = getLabelLineTarget(labelPlacement, labelSize, itemDx, itemDy);
const startX = itemDx;
const startY = itemDy;
const drawX = lineTarget.x - startX;
const drawY = lineTarget.y - startY;
const angle = Math.atan2(drawY, drawX) * (180 / Math.PI);
const length = Math.hypot(drawX, drawY);
labelLine.style.left = `${startX}px`;
labelLine.style.top = `${startY}px`;
labelLine.style.width = `${Math.max(4, length)}px`;
labelLine.style.transform = `rotate(${angle}deg)`;
labelLine.style.color = group.markerColor;
marker.appendChild(labelLine);
}
return marker;
}
function buildRenderEntries() {
const collections = [
{ kind: "players", items: getVisibleCollection("players", state.lastSnapshot?.players || []) },
{ kind: "vehicles", items: getVisibleCollection("vehicles", state.lastSnapshot?.vehicles || []) },
{ kind: "zombies", items: getVisibleCollection("zombies", state.lastSnapshot?.zombies || []) },
{ kind: "animals", items: getVisibleCollection("animals", state.lastSnapshot?.animals || []) },
{ kind: "bullets", items: getVisibleCollection("bullets", state.lastSnapshot?.bullets || []) },
{ kind: "otherEntities", items: getVisibleCollection("otherEntities", state.lastSnapshot?.otherEntities || []) },
{ kind: "loot", items: getVisibleCollection("loot", state.lastSnapshot?.loot || []) }
];
const entries = [];
for (const collection of collections) {
for (const item of collection.items) {
entries.push({
kind: collection.kind,
item,
markerColor: getMarkerColor(collection.kind, item),
labelText: buildLabelText(item),
label: getLabelConfig(collection.kind, item)
});
}
}
entries.sort((lhs, rhs) => {
const priorityDiff = getRenderPriority(rhs.kind, rhs.item) - getRenderPriority(lhs.kind, lhs.item);
if (priorityDiff !== 0) return priorityDiff;
return Number(lhs.item?.distance || 0) - Number(rhs.item?.distance || 0);
});
return entries;
}
// ── SVG helpers ────────────────────────────────────────────────────────────
function createSvgNode(tagName) {
return document.createElementNS("http://www.w3.org/2000/svg", tagName);
}
function getBulletFilterStyle() {
return state.settings.filterStyles?.bullets || {};
}
function syncBulletVisuals(bullets) {
const now = performance.now();
const visibleIds = new Set();
const bulletStyle = getBulletFilterStyle();
const predictionLimit = bulletStyle.showPrediction === false ? 0 : Number(bulletStyle.predictionDistance || 250);
for (const bullet of bullets || []) {
visibleIds.add(bullet.id);
const visual = state.bulletVisuals.get(bullet.id) || { revealedDistance: 0, lastTick: now, targetDistance: 0, speed: 0 };
visual.lastTick = now;
visual.speed = Math.max(30, Number(bullet.predictionSpeed || 0));
visual.targetDistance = bullet.isCompleted ? 0 : predictionLimit;
if (visual.revealedDistance > visual.targetDistance) visual.revealedDistance = visual.targetDistance;
state.bulletVisuals.set(bullet.id, visual);
}
for (const id of Array.from(state.bulletVisuals.keys())) {
if (!visibleIds.has(id)) state.bulletVisuals.delete(id);
}
}
function advanceBulletVisuals(now) {
let needsFrame = false;
let changed = false;
for (const visual of state.bulletVisuals.values()) {
const elapsedSeconds = Math.max(0, (now - visual.lastTick) / 1000);
visual.lastTick = now;
if (visual.revealedDistance < visual.targetDistance) {
const nextDistance = Math.min(visual.targetDistance, visual.revealedDistance + (visual.speed * elapsedSeconds));
if (nextDistance !== visual.revealedDistance) { visual.revealedDistance = nextDistance; changed = true; }
}
if (visual.revealedDistance + 0.5 < visual.targetDistance) needsFrame = true;
}
return { needsFrame, changed };
}
function ensureBulletAnimation() {
if (state.bulletAnimationFrame) return;
const hasPendingPrediction = Array.from(state.bulletVisuals.values()).some((v) => v.revealedDistance + 0.5 < v.targetDistance);
if (!hasPendingPrediction) return;
const tick = (now) => {
state.bulletAnimationFrame = null;
const { needsFrame, changed } = advanceBulletVisuals(now);
if (changed) render();
if (needsFrame) state.bulletAnimationFrame = window.requestAnimationFrame(tick);
};
state.bulletAnimationFrame = window.requestAnimationFrame(tick);
}
function buildClippedLine(points, maxDistance) {
if (!Array.isArray(points) || points.length < 2 || maxDistance <= 0) return [];
const clipped = [points[0]];
let remaining = maxDistance;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
const dx = Number(current.x || 0) - Number(previous.x || 0);
const dy = Number(current.y || 0) - Number(previous.y || 0);
const segmentLength = Math.hypot(dx, dy);
if (segmentLength <= 0) continue;
if (remaining >= segmentLength) { clipped.push(current); remaining -= segmentLength; continue; }
const ratio = remaining / segmentLength;
clipped.push({ x: Number(previous.x || 0) + (dx * ratio), y: Number(previous.y || 0) + (dy * ratio) });
break;
}
return clipped;
}
function buildBulletPathNodes(bullets) {
const style = getBulletFilterStyle();
if (style.showTrajectory === false && style.showPrediction === false) return [];
const nodes = [];
for (const bullet of bullets || []) {
const color = getMarkerColor("bullets", bullet);
if (style.showTrajectory !== false && Array.isArray(bullet.path) && bullet.path.length >= 2) {
const actual = createSvgNode("polyline");
actual.setAttribute("class", `bullet-path-actual${bullet.isPhantom ? " bullet-path-phantom" : ""}`);
actual.setAttribute("stroke", color);
actual.setAttribute("points", bullet.path.map((p) => `${p.x},${p.y}`).join(" "));
nodes.push(actual);
}
if (style.showPrediction !== false && !bullet.isCompleted && Array.isArray(bullet.predictedPath) && bullet.predictedPath.length >= 2) {
const visual = state.bulletVisuals.get(bullet.id);
const clippedPoints = buildClippedLine(bullet.predictedPath, Number(visual?.revealedDistance || 0));
if (clippedPoints.length >= 2) {
const predicted = createSvgNode("polyline");
predicted.setAttribute("class", `bullet-path-predicted${bullet.isPhantom ? " bullet-path-phantom" : ""}`);
predicted.setAttribute("stroke", color);
predicted.setAttribute("points", clippedPoints.map((p) => `${p.x},${p.y}`).join(" "));
nodes.push(predicted);
}
}
}
return nodes;
}
function buildGridLayer() {
if (!state.settings.showGrid || !state.bootstrap) return [];
const { mapSize } = state.bootstrap;
const gridKey = `mgrs:${mapSize}`;
if (state.cachedGridKey === gridKey && state.cachedGridNode) {
return [state.cachedGridNode];
}
const g = createSvgNode("g");
g.setAttribute("id", "grid-layer");
const addLine = (x1, y1, x2, y2, cls) => {
const l = createSvgNode("line");
l.setAttribute("x1", x1); l.setAttribute("y1", y1);
l.setAttribute("x2", x2); l.setAttribute("y2", y2);
l.setAttribute("class", cls);
g.appendChild(l);
};
for (let wx = 0; wx <= mapSize; wx += MGRS_STEP) addLine(wx, 0, wx, mapSize, "grid-major");
for (let py = 0; py <= mapSize; py += MGRS_STEP) addLine(0, py, mapSize, py, "grid-major");
state.cachedGridKey = gridKey;
state.cachedGridNode = g;
return [g];
}
// Grid labels are HTML divs so they can use var(--inv-scale) for constant screen size.
// Visibility at low zoom is handled via the grid-labels-visible CSS class toggled in applyTransform().
function buildGridLabels() {
if (!state.settings.showGrid || !state.bootstrap) return [];
const { mapSize } = state.bootstrap;
const sqCount = Math.ceil(mapSize / MGRS_STEP);
const nodes = [];
for (let ty = 0; ty < sqCount; ty++) {
for (let tx = 0; tx < sqCount; tx++) {
const wx = tx * MGRS_STEP;
const py = ty * MGRS_STEP;
// MGRS 6-figure: easting and northing of the NW corner (top-left on screen) in 100 m units
const eastStr = String(tx * 10).padStart(3, "0");
const northStr = String(Math.max(0, Math.floor((mapSize - ty * MGRS_STEP) / 100))).padStart(3, "0");
const el = document.createElement("div");
el.className = "grid-label-wrapper";
el.style.left = `${wx + 3}px`;
el.style.top = `${py + 3}px`;
const inner = document.createElement("div");
inner.className = "grid-label-inner";
inner.style.cssText = "position:absolute;left:0;top:0;transform:scale(var(--inv-scale));transform-origin:0 0";
inner.textContent = `${eastStr} ${northStr}`;
el.appendChild(inner);
nodes.push(el);
}
}
return nodes;
}
function buildCombatRings(localPlayer) {
if ((!state.settings.isCombatMode && !state.settings.showDistanceRings) || !localPlayer) return [];
const g = createSvgNode("g");
g.setAttribute("id", "combat-rings");
for (const dist of [100, 200, 300, 500]) {
const c = createSvgNode("circle");
c.setAttribute("cx", localPlayer.x);
c.setAttribute("cy", localPlayer.y);
c.setAttribute("r", dist);
c.setAttribute("class", "combat-ring");
g.appendChild(c);
const t = createSvgNode("text");
t.setAttribute("x", localPlayer.x);
t.setAttribute("y", localPlayer.y - dist - 4);
t.setAttribute("class", "combat-ring-label");
t.textContent = `${dist}m`;
g.appendChild(t);
}
return [g];
}
// ── Tile loading (PRESERVED VERBATIM) ─────────────────────────────────────
function updateVisibleTiles() {
if (!state.bootstrap) return;
const rect = elements.viewport.getBoundingClientRect();
const left = Math.max(0, Math.floor((-state.offsetX) / state.scale));
const top = Math.max(0, Math.floor((-state.offsetY) / state.scale));
const right = Math.min(state.bootstrap.mapSize, Math.ceil((rect.width - state.offsetX) / state.scale));
const bottom = Math.min(state.bootstrap.mapSize, Math.ceil((rect.height - state.offsetY) / state.scale));
const tileSize = state.bootstrap.tileSize;
const tileCountX = state.bootstrap.tileCountX;
const tileCountY = state.bootstrap.tileCountY;
const preloadMargin = 2;
const keepMargin = Math.max(tileCountX, tileCountY);
const satKeepMargin = 4;
const minTileX = Math.max(0, Math.floor(left / tileSize) - preloadMargin);
const minTileY = Math.max(0, Math.floor(top / tileSize) - preloadMargin);
const maxTileX = Math.min(tileCountX - 1, Math.floor(right / tileSize) + preloadMargin);
const maxTileY = Math.min(tileCountY - 1, Math.floor(bottom / tileSize) + preloadMargin);
const keepMinX = Math.max(0, minTileX - keepMargin);
const keepMinY = Math.max(0, minTileY - keepMargin);
const keepMaxX = Math.min(tileCountX - 1, maxTileX + keepMargin);
const keepMaxY = Math.min(tileCountY - 1, maxTileY + keepMargin);
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";
// ── Base tiles (skipped when satmap is active) ───────────────────────────
if (!satmapOn) for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
const key = `${tileX}:${tileY}`;
if (state.tileElements.has(key)) {
const img = state.tileElements.get(key);
if (img.classList.contains("failed")) {
const nextRetry = Number(img.dataset.retry || "0") + 1;
if (nextRetry <= 4) {
img.dataset.retry = String(nextRetry);
img.classList.remove("failed");
img.classList.add("loading");
img.src = tileUrl(tileX, tileY, nextRetry);
}
}
continue;
}
const img = document.createElement("img");
img.className = "map-tile loading";
img.dataset.key = key;
img.dataset.retry = "0";
img.draggable = false;
img.style.cssText = `left:${tileX * tileSize}px;top:${tileY * tileSize}px;width:${tileSize + 1}px;height:${tileSize + 1}px`;
const loadTile = (retryCount) => {
img.dataset.retry = String(retryCount);
img.classList.remove("failed", "loaded");
img.classList.add("loading");
img.src = tileUrl(tileX, tileY, retryCount);
};
img.addEventListener("load", () => {
state.tileState[key] = "loaded";
img.classList.remove("loading", "failed");
img.classList.add("loaded");
});
img.addEventListener("error", () => {
const nextRetry = Number(img.dataset.retry || "0") + 1;
state.tileState[key] = "failed";
img.classList.remove("loading");
img.classList.add("failed");
if (nextRetry <= 4) {
window.setTimeout(() => {
if (state.tileElements.has(key)) loadTile(nextRetry);
}, 300 * nextRetry);
}
});
loadTile(0);
state.tileElements.set(key, img);
elements.tiles.appendChild(img);
}
}
// Evict base tiles outside keep margin; skip when satmap is active so tiles survive mode switches
if (!satmapOn) {
for (const [key, img] of state.tileElements) {
const [tx, ty] = key.split(":").map(Number);
if (tx < keepMinX || tx > keepMaxX || ty < keepMinY || ty > keepMaxY) {
delete state.tileState[key];
img.remove();
state.tileElements.delete(key);
}
}
}
// ── Satmap tiles (loaded when satmap is active) ─────────────────────────
if (satmapOn && state.topoAvailable) {
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
const key = `${tileX}:${tileY}`;
if (state.topoElements.has(key)) continue;
const img = document.createElement("img");
img.className = "map-tile loaded";
img.draggable = false;
img.style.cssText = `left:${tileX * tileSize}px;top:${tileY * tileSize}px;width:${tileSize + 1}px;height:${tileSize + 1}px`;
img.addEventListener("error", () => {
img.remove();
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);
elements.topoTiles.appendChild(img);
}
}
// Evict satmap tiles outside keep margin
for (const [key, img] of state.topoElements) {
const [tx, ty] = key.split(":").map(Number);
if (tx < keepMinX || tx > keepMaxX || ty < keepMinY || ty > keepMaxY) {
img.remove();
state.topoElements.delete(key);
}
}
}
// When satmap is inactive, topoTiles stays hidden (display:none set above) but tiles remain
// in state.topoElements so mode switches are instant without reloading.
// ── Satellite XYZ tiles ─────────────────────────────────────────────────
const satVisible = state.settings.showSatellite && !!state.bootstrap.satUrl;
elements.satTiles.style.display = satVisible ? "" : "none";
if (satVisible) {
const z = satZoomForScale();
const n = 1 << z;
const tileWorldSize = state.bootstrap.mapSize / n; // world units per XYZ tile
const satMinTX = Math.max(0, Math.floor(left / tileWorldSize) - preloadMargin);
const satMinTY = Math.max(0, Math.floor(top / tileWorldSize) - preloadMargin);
const satMaxTX = Math.min(n - 1, Math.floor(right / tileWorldSize) + preloadMargin);
const satMaxTY = Math.min(n - 1, Math.floor(bottom / tileWorldSize) + preloadMargin);
const satKeepMinX = Math.max(0, satMinTX - satKeepMargin);
const satKeepMinY = Math.max(0, satMinTY - satKeepMargin);
const satKeepMaxX = Math.min(n - 1, satMaxTX + satKeepMargin);
const satKeepMaxY = Math.min(n - 1, satMaxTY + satKeepMargin);
for (let ty = satMinTY; ty <= satMaxTY; ty++) {
for (let tx = satMinTX; tx <= satMaxTX; tx++) {
const key = `${z}:${tx}:${ty}`;
if (state.satElements.has(key)) continue;
const img = document.createElement("img");
img.className = "map-tile loaded";
img.draggable = false;
img.style.cssText = `left:${tx * tileWorldSize}px;top:${ty * tileWorldSize}px;width:${tileWorldSize + 1}px;height:${tileWorldSize + 1}px`;
img.addEventListener("error", () => {
img.remove();
state.satElements.delete(key);
});
img.src = satTileUrl(z, tx, ty);
state.satElements.set(key, img);
elements.satTiles.appendChild(img);
}
}
// Evict sat tiles at wrong zoom or outside keep margin
for (const [key, img] of state.satElements) {
const [kz, ktx, kty] = key.split(":").map(Number);
if (kz !== z || ktx < satKeepMinX || ktx > satKeepMaxX || kty < satKeepMinY || kty > satKeepMaxY) {
img.remove();
state.satElements.delete(key);
}
}
} else {
for (const img of state.satElements.values()) img.remove();
state.satElements.clear();
}
}
// ── Main render ────────────────────────────────────────────────────────────
function render() {
if (!PERF.enabled) return renderImpl();
const t = performance.now();
renderImpl();
PERF.onRender(performance.now() - t);
}
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", snap?.bullets || []));
const localPlayer = snap?.hasLocalPlayer ? snap.localPlayer : null;
if (localPlayer) {
const marker = document.createElement("div");
marker.className = "entity local-player-entity";
marker.style.left = `${localPlayer.x}px`;
marker.style.top = `${localPlayer.y}px`;
const pulse = document.createElement("div");
pulse.className = "local-player-pulse";
const glyph = document.createElement("div");
glyph.className = "marker local-player";
const direction = document.createElement("div");
direction.className = "direction entity-direction local-direction";
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (localPlayer.cameraRotation || localPlayer.rotation || 0)}deg)`;
direction.style.height = `${Math.max(10, Number(state.settings.filterStyles?.players?.directionLength || 28))}px`;
marker.appendChild(pulse);
marker.appendChild(direction);
marker.appendChild(glyph);
markerNodes.push(marker);
}
const entries = buildRenderEntries();
const labelGroups = computeLabelPlacements(entries);
for (const entry of entries) {
markerNodes.push(createMarker(entry));
}
for (const group of labelGroups.priorityGroups || []) {
const markerNode = createPriorityLabelGroup(group);
if (markerNode) markerNodes.push(markerNode);
}
for (const group of labelGroups.ordinaryGroups || []) {
const itemLabelNode = createLootLabelGroup(group);
if (itemLabelNode) itemLabelNodes.push(itemLabelNode);
}
// POIs — inner scaler keeps marker + label at constant screen size regardless of zoom.
// Uses CSS var(--inv-scale) set by applyTransform() so updates instantly on every zoom,
// not just on data-driven re-renders.
if (state.settings.showPOIs) {
for (const poi of (state.bootstrap.pois || [])) {
const wrapper = document.createElement("div");
wrapper.className = "entity poi-entity";
wrapper.style.left = `${poi.x}px`;
wrapper.style.top = `${poi.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 poi ${sanitize(poi.type || "")}`;
inner.appendChild(dot);
if (poi.label) {
const lbl = document.createElement("span");
lbl.className = "poi-label";
lbl.textContent = poi.label;
inner.appendChild(lbl);
}
wrapper.appendChild(inner);
markerNodes.push(wrapper);
}
}
// Waypoints — same approach
for (const wp of (state.settings.waypoints || [])) {
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 waypoint";
inner.appendChild(dot);
if (wp.label) {
const lbl = document.createElement("span");
lbl.className = "waypoint-label";
lbl.textContent = wp.label;
inner.appendChild(lbl);
}
wrapper.appendChild(inner);
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);
elements.itemLabels.replaceChildren(...itemLabelNodes);
elements.paths.replaceChildren(...pathNodes, ...gridNodes, ...combatNodes);
elements.markers.replaceChildren(...markerNodes, ...gridLabelNodes);
// Update side-panel lists only when dirty (avoids 60fps DOM churn during bullet animation)
if (state.listDirty) {
state.listDirty = false;
renderPlayersList();
renderLootList();
updateCountBadges();
updateFilterCounts();
}
updateMinimap();
}
// ── Network ────────────────────────────────────────────────────────────────
function stopFallbackPolling() {
if (state.fallbackPollTimer) { window.clearInterval(state.fallbackPollTimer); state.fallbackPollTimer = null; }
}
function startFallbackPolling() {
if (state.fallbackPollTimer) return;
const poll = () => {
fetch(apiUrl("/api/state"), { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error(response.status === 401 ? "Unauthorized" : `HTTP ${response.status}`);
return response.json();
})
.then((snapshot) => applySnapshot(snapshot))
.catch((error) => {
elements.status.textContent = error?.message || String(error);
elements.connectionDot.className = "conn-dot disconnected";
});
};
poll();
state.fallbackPollTimer = window.setInterval(poll, 3000);
}
function clearReconnectTimer() {
if (state.reconnectTimer) { window.clearTimeout(state.reconnectTimer); state.reconnectTimer = null; }
}
function scheduleReconnect() {
if (state.reconnectTimer) return;
state.reconnectTimer = window.setTimeout(() => {
state.reconnectTimer = null;
connectEvents();
}, 3000);
}
function applySnapshot(snapshot) {
const previousSnapshot = state.lastSnapshot;
state.lastSnapshot = snapshot;
const mapChanged = applyMapMetadata({
mapId: snapshot.mapId,
mapName: snapshot.mapName,
serverMapName: snapshot.serverMapName,
mapSize: snapshot.mapSize,
tileSize: snapshot.tileSize,
tileCountX: snapshot.tileCountX,
tileCountY: snapshot.tileCountY
});
if (snapshot.mapId) clearMapNotice();
if (mapChanged) state.initialCentered = false;
syncBulletVisuals(snapshot?.bullets || []);
ensureBulletAnimation();
// Forward snapshot to 3D engine when active
if (engine3d.active && engine3d.pushEntities) {
try { engine3d.pushEntities(JSON.stringify(snapshot)); } catch (_) {}
}
// New player detection
const prevAddresses = state.knownPlayerAddresses;
const currentAddresses = new Set((snapshot.players || []).map((p) => p.steamId || p.address).filter(Boolean));
if (prevAddresses.size > 0 && state.wasConnected) {
let newCount = 0;
for (const player of (snapshot.players || [])) {
const id = player.steamId || player.address;
if (id && !prevAddresses.has(id)) {
if (newCount < 2) showToast(`New player: ${player.label || "Unknown"}`, "info");
newCount++;
}
}
if (newCount > 2) showToast(`+${newCount - 2} more players`, "info");
}
state.knownPlayerAddresses = currentAddresses;
const snapshotChanged = !previousSnapshot
|| previousSnapshot.cacheRefreshSequence !== snapshot.cacheRefreshSequence
|| previousSnapshot.fastCacheRefreshSequence !== snapshot.fastCacheRefreshSequence
|| previousSnapshot.slowCacheRefreshSequence !== snapshot.slowCacheRefreshSequence
|| previousSnapshot.status !== snapshot.status
|| previousSnapshot.serverName !== snapshot.serverName
|| previousSnapshot.mapName !== snapshot.mapName
|| previousSnapshot.serverMapName !== snapshot.serverMapName
|| previousSnapshot.gameVersion !== snapshot.gameVersion
|| previousSnapshot.hasLocalPlayer !== snapshot.hasLocalPlayer
|| mapChanged;
if (!state.initialCentered && snapshot.hasLocalPlayer && snapshot.localPlayer) {
centerOn(snapshot.localPlayer);
state.initialCentered = true;
}
const serverTitle = snapshot.serverName && snapshot.serverName.length > 0 ? snapshot.serverName : "Unavailable";
const versionText = snapshot.gameVersion && snapshot.gameVersion.length > 0 ? snapshot.gameVersion : "n/a";
const mapText = snapshot.serverMapName && snapshot.serverMapName.length > 0 ? snapshot.serverMapName : snapshot.mapName;
elements.serverInfo.textContent = `${serverTitle} | ${mapText} | v${versionText}`;
elements.serverBadge.textContent = serverTitle;
const connected = snapshot.hasLocalPlayer;
elements.status.textContent = connected ? `Live: ${snapshot.mapName}` : "Waiting to detect LocalPlayer…";
elements.connectionDot.className = `conn-dot ${connected ? "connected" : "waiting"}`;
if (!state.wasConnected) {
showToast("Connected", "success");
state.wasConnected = true;
}
if (!snapshotChanged) return;
state.listDirty = true;
render();
}
function showMapNotice(msg) {
const existing = document.getElementById("map-notice");
if (existing) existing.remove();
const el = document.createElement("div");
el.id = "map-notice";
el.style.cssText = "position:fixed;top:var(--header-h);left:0;right:0;background:#b91c1c;color:#fff;padding:10px 16px;font-size:13px;z-index:9999;text-align:center;cursor:pointer";
el.textContent = msg + " (click to dismiss)";
el.addEventListener("click", () => el.remove());
document.body.prepend(el);
}
function clearMapNotice() {
const el = document.getElementById("map-notice");
if (el) el.remove();
}
// ── 3D engine ─────────────────────────────────────────────────────────────────
const engine3d = {
module: null, // WASM module instance (set after first load)
loading: false, // guard against concurrent loads
active: false, // true when 3D mode is displayed
mapId: null, // mapId the engine was last loaded with
pushEntities: null, // cached ccall wrapper
manifestUrl: null, // populated from bootstrap response
};
async function toggle3DMode() {
engine3d.active = !engine3d.active;
document.body.classList.toggle("mode-3d", engine3d.active);
elements.toggle3d.classList.toggle("active", engine3d.active);
if (!engine3d.active) return;
const mapId = state.bootstrap?.mapId;
if (!mapId || !engine3d.manifestUrl) return;
if (!engine3d.module && !engine3d.loading) {
engine3d.loading = true;
try {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "engine.js";
script.onload = resolve;
script.onerror = () => reject(new Error("engine.js failed to load"));
document.head.appendChild(script);
});
const canvas = elements.canvas3d;
const mod = await window.createEngine({ canvas });
engine3d.module = mod;
const init = mod.cwrap("engine_init", null, ["number", "number"]);
init(canvas.clientWidth, canvas.clientHeight);
const resize = mod.cwrap("engine_resize", null, ["number", "number"]);
window.addEventListener("resize", () => {
resize(canvas.clientWidth, canvas.clientHeight);
});
engine3d.pushEntities = mod.cwrap("engine_push_entities", null, ["string"]);
const loadMap = mod.cwrap("engine_load_map", null, ["string", "string"]);
loadMap(mapId, serverOrigin || window.location.origin);
engine3d.mapId = mapId;
} catch (err) {
console.error("[3D] Engine load failed:", err);
showToast("3D engine failed to load", "danger");
engine3d.active = false;
engine3d.loading = false;
document.body.classList.remove("mode-3d");
elements.toggle3d.classList.remove("active");
return;
}
engine3d.loading = false;
} else if (engine3d.module && mapId !== engine3d.mapId) {
const loadMap = engine3d.module.cwrap("engine_load_map", null, ["string", "string"]);
loadMap(mapId, serverOrigin || window.location.origin);
engine3d.mapId = mapId;
}
if (state.lastSnapshot && engine3d.pushEntities) {
engine3d.pushEntities(JSON.stringify(state.lastSnapshot));
}
}
async function bootstrap() {
const response = await fetch(apiUrl("/api/bootstrap"), { cache: "no-store" });
if (!response.ok) throw new Error(response.status === 401 ? "Unauthorized" : `HTTP ${response.status}`);
const bootstrapData = await response.json();
applyMapMetadata(bootstrapData, true);
const mapsWithImages = (bootstrapData.maps || []).filter((m) => m.hasImage);
if (!bootstrapData.mapId) {
if (mapsWithImages.length === 0) {
showMapNotice("No map PNG found — place a PNG named after a map ID in a maps/ folder next to the exe.");
} else {
showMapNotice(`Map PNGs exist (${mapsWithImages.map((m) => m.id).join(", ")}) but server returned no mapId — open /api/debug for details.`);
}
}
filterDefinitions = [...entityFilterDefinitions, ...buildLootFilterDefinitions((state.bootstrap.filters || []).filter((d) => d.kind === "loot"))];
rebuildFilterCaches();
ensureFilterSettings();
renderFilterCards();
centerOn({ x: state.bootstrap.mapSize / 2, y: state.bootstrap.mapSize / 2 });
setSidebarState(state.settings.sidebarState);
switchTab(state.settings.activeTab);
setTheme(state.settings.theme);
syncLabelSettingsUi();
loadMinimapImage();
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;
elements.toggle3d.disabled = !engine3d.manifestUrl;
if (!engine3d.manifestUrl) {
elements.toggle3d.title = "3D map (no processed data — run: py -m dayzmap fakedata)";
} else {
elements.toggle3d.title = "Toggle 3D map mode";
}
}
function connectEvents() {
if (typeof window.EventSource !== "function") { startFallbackPolling(); return; }
clearReconnectTimer();
if (state.eventSource) state.eventSource.close();
const source = new EventSource(apiUrl("/events"));
state.eventSource = source;
source.addEventListener("state", (event) => {
stopFallbackPolling();
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 = () => {
if (state.eventSource === source) { state.eventSource.close(); state.eventSource = null; }
elements.status.textContent = "Connection lost";
elements.connectionDot.className = "conn-dot disconnected";
if (state.wasConnected) {
showToast("Connection lost", "danger");
state.wasConnected = false;
}
startFallbackPolling();
scheduleReconnect();
};
}
// ── 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`;
elements.groupedLootSpread.value = String(state.settings.groupedLootSpread);
elements.groupedLootSpreadValue.textContent = `${Number(state.settings.groupedLootSpread).toFixed(1)}x`;
elements.sameLootMergeRadius.value = String(state.settings.sameLootMergeRadius);
elements.sameLootMergeRadiusValue.textContent = `${Math.round(Number(state.settings.sameLootMergeRadius))} px`;
elements.lineAnchorMode.value = state.settings.lineAnchorMode;
elements.mergeSameLootLabels.checked = !!state.settings.mergeSameLootLabels;
}
function resetLabelSettings() {
state.settings.ordinaryLootSpread = defaultSettings.ordinaryLootSpread;
state.settings.groupedLootSpread = defaultSettings.groupedLootSpread;
state.settings.sameLootMergeRadius = defaultSettings.sameLootMergeRadius;
state.settings.lineAnchorMode = defaultSettings.lineAnchorMode;
state.settings.mergeSameLootLabels = defaultSettings.mergeSameLootLabels;
}
function updateCombatToggleUi() {
elements.combatToggle.classList.toggle("active", !!state.settings.isCombatMode);
}
function syncAllCheckboxes() {
const map = {
followPlayer: elements.followPlayer,
showLabels: elements.showLabels,
showPOIs: elements.showPOIs,
showGrid: elements.showGrid,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
showLoot: elements.showLoot,
};
for (const [key, el] of Object.entries(map)) {
if (el) el.checked = !!state.settings[key];
}
updateCombatToggleUi();
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
function toggleSetting(key) {
state.settings[key] = !state.settings[key];
state.settings.activePreset = null;
persistSettings();
updatePresetButtons();
updateCountBadges();
syncAllCheckboxes();
state.listDirty = true;
render();
}
function toggleCombatMode() {
state.settings.isCombatMode = !state.settings.isCombatMode;
persistSettings();
updateCombatToggleUi();
state.listDirty = true;
render();
}
function bindKeyboard() {
document.addEventListener("keydown", (event) => {
const tag = event.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
if (event.key === "Escape" && event.target === elements.lootSearch) {
elements.lootSearch.value = "";
state.lootSearchText = "";
state.listDirty = true;
render();
elements.lootSearch.blur();
}
return;
}
switch (event.key.toLowerCase()) {
case "p": toggleSetting("showPlayers"); break;
case "z": toggleSetting("showZombies"); break;
case "a": toggleSetting("showAnimals"); break;
case "v": toggleSetting("showVehicles"); break;
case "l": toggleSetting("showLoot"); break;
case "b": toggleSetting("showBullets"); break;
case "c": toggleCombatMode(); break;
case "f":
state.settings.followPlayer = !state.settings.followPlayer;
persistSettings();
elements.followPlayer.checked = state.settings.followPlayer;
render();
break;
case "g":
state.settings.showGrid = !state.settings.showGrid;
persistSettings();
elements.showGrid.checked = state.settings.showGrid;
updateVisibleTiles();
render();
break;
case "m":
toggleMeasureMode(!state.measureMode);
event.preventDefault();
break;
case " ":
if (state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot?.localPlayer) {
centerOn(state.lastSnapshot.localPlayer);
}
event.preventDefault();
break;
case "escape":
if (state.measureMode) {
toggleMeasureMode(false);
} else {
const sideStates = ["full", "icons", "hidden"];
const idx = sideStates.indexOf(state.settings.sidebarState);
setSidebarState(sideStates[(idx + 1) % sideStates.length]);
}
hideContextMenu();
event.preventDefault();
break;
case "1": loadPreset(0); break;
case "2": loadPreset(1); break;
case "3": loadPreset(2); break;
case "4": loadPreset(3); break;
}
});
}
// ── UI binding ─────────────────────────────────────────────────────────────
function bindUi() {
// Sidebar toggle (cycle full → icons → hidden → full)
elements.sidebarToggle.addEventListener("click", () => {
const states = ["full", "icons", "hidden"];
const idx = states.indexOf(state.settings.sidebarState);
setSidebarState(states[(idx + 1) % states.length]);
});
// Tab navigation
for (const btn of document.querySelectorAll(".tab-btn")) {
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
}
// Combat toggle
elements.combatToggle.addEventListener("click", () => toggleCombatMode());
// 3D mode toggle
elements.toggle3d.addEventListener("click", () => toggle3DMode());
// Header count badges
for (const badge of document.querySelectorAll(".count-badge[data-key]")) {
badge.addEventListener("click", () => {
const key = badge.dataset.key;
if (key) toggleSetting(key);
});
}
// Preset buttons
for (const btn of document.querySelectorAll(".preset-btn")) {
btn.addEventListener("click", (event) => {
const idx = Number(btn.dataset.preset);
if (event.shiftKey) savePreset(idx);
else loadPreset(idx);
});
}
// Quick all/none
document.getElementById("showAllEntities").addEventListener("click", () => {
for (const def of filterDefinitions) state.settings[def.visibleKey] = true;
state.settings.showLoot = true;
state.settings.showFavoriteLoot = true;
persistSettings();
syncAllCheckboxes();
state.listDirty = true;
render();
});
document.getElementById("hideAllEntities").addEventListener("click", () => {
for (const def of filterDefinitions) state.settings[def.visibleKey] = false;
state.settings.showLoot = false;
state.settings.showFavoriteLoot = false;
persistSettings();
syncAllCheckboxes();
state.listDirty = true;
render();
});
// Map tab checkboxes
for (const [key, el] of Object.entries({
followPlayer: elements.followPlayer,
showLabels: elements.showLabels,
showPOIs: elements.showPOIs,
showGrid: elements.showGrid,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
})) {
el.addEventListener("change", () => {
state.settings[key] = el.checked;
persistSettings();
if (key === "showSatmap") {
if (el.checked && !state.topoAvailable && state.bootstrap?.mapId) {
checkTopoAvailable(state.bootstrap.mapId);
} else {
updateVisibleTiles();
}
}
else if (key === "showMinimap") updateMinimap();
else render();
});
}
// Loot tab show-all toggle
elements.showLoot.addEventListener("change", () => {
state.settings.showLoot = elements.showLoot.checked;
persistSettings();
state.listDirty = true;
render();
});
// Distance filter
elements.distanceFilter.value = String(state.settings.distanceFilter);
elements.distanceValue.textContent = `${state.settings.distanceFilter} m`;
elements.distanceFilter.addEventListener("input", () => {
state.settings.distanceFilter = Number(elements.distanceFilter.value);
elements.distanceValue.textContent = `${state.settings.distanceFilter} m`;
persistSettings();
state.listDirty = true;
render();
});
// Text size
elements.textSize.value = String(state.settings.textSize);
elements.textSizeValue.textContent = `${state.settings.textSize} px`;
elements.textSize.addEventListener("input", () => {
state.settings.textSize = Number(elements.textSize.value);
elements.textSizeValue.textContent = `${state.settings.textSize} px`;
persistSettings();
render();
});
// 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`;
persistSettings();
render();
});
elements.groupedLootSpread.addEventListener("input", () => {
state.settings.groupedLootSpread = Number(elements.groupedLootSpread.value);
elements.groupedLootSpreadValue.textContent = `${Number(state.settings.groupedLootSpread).toFixed(1)}x`;
persistSettings();
render();
});
elements.sameLootMergeRadius.addEventListener("input", () => {
state.settings.sameLootMergeRadius = Number(elements.sameLootMergeRadius.value);
elements.sameLootMergeRadiusValue.textContent = `${Math.round(Number(state.settings.sameLootMergeRadius))} px`;
persistSettings();
render();
});
elements.lineAnchorMode.addEventListener("change", () => {
state.settings.lineAnchorMode = elements.lineAnchorMode.value;
persistSettings();
render();
});
elements.mergeSameLootLabels.addEventListener("change", () => {
state.settings.mergeSameLootLabels = elements.mergeSameLootLabels.checked;
persistSettings();
render();
});
elements.labelSettingsReset.addEventListener("click", () => {
resetLabelSettings();
ensureFilterSettings();
syncLabelSettingsUi();
persistSettings();
render();
});
// Theme buttons
for (const btn of document.querySelectorAll(".theme-btn")) {
btn.addEventListener("click", () => setTheme(btn.dataset.theme));
}
// Loot search
elements.lootSearch.addEventListener("input", () => {
state.lootSearchText = elements.lootSearch.value.toLowerCase().trim();
state.listDirty = true;
render();
});
elements.lootSearch.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
elements.lootSearch.value = "";
state.lootSearchText = "";
state.listDirty = true;
render();
elements.lootSearch.blur();
}
});
// Loot list favorites
elements.lootList.addEventListener("click", (event) => {
const row = event.target.closest(".loot-row");
if (!row) return;
toggleFavoriteLoot(row.dataset.lootName || "");
});
// Sidebar wheel stop
elements.sidebar.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
// Viewport pointer events
elements.viewport.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
elements.viewport.setPointerCapture(event.pointerId);
state.activePointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
if (state.activePointers.size === 1) {
state.dragging = !state.settings.followPlayer && !state.measureMode;
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();
if (pair) { state.dragging = false; state.pinchDistance = getPointerDistance(pair[0], pair[1]); }
});
elements.viewport.addEventListener("pointermove", (event) => {
// Track coords for non-dragging hover
if (!state.activePointers.has(event.pointerId)) {
if (state.bootstrap && state.bootstrap.mapSize > 0) {
const w = screenToWorld(event.clientX, event.clientY);
updateCoordDisplay(w.x, w.y);
}
if (state.measureMode) drawMeasureLayer(event.clientX, event.clientY);
return;
}
state.activePointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
const pair = getActivePointerPair();
if (pair) {
const nextDistance = getPointerDistance(pair[0], pair[1]);
if (state.pinchDistance > 0 && nextDistance > 0) {
const mid = getPointerMidpoint(pair[0], pair[1]);
zoomAt(mid.clientX, mid.clientY, state.scale * (nextDistance / state.pinchDistance));
}
state.pinchDistance = nextDistance;
return;
}
if (!state.dragging) return;
updateMapDrag(event.clientX, event.clientY);
if (state.bootstrap && state.bootstrap.mapSize > 0) {
const w = screenToWorld(event.clientX, event.clientY);
updateCoordDisplay(w.x, w.y);
}
if (state.measureMode) drawMeasureLayer(event.clientX, event.clientY);
});
const stopPointerGesture = (event) => {
state.activePointers.delete(event.pointerId);
if (state.activePointers.size === 0) { state.dragging = false; state.pinchDistance = 0; return; }
const [remaining] = state.activePointers.values();
if (remaining) { state.dragging = true; state.lastX = remaining.clientX; state.lastY = remaining.clientY; }
state.pinchDistance = 0;
};
elements.viewport.addEventListener("pointerup", stopPointerGesture);
elements.viewport.addEventListener("pointercancel", stopPointerGesture);
// Click for measure mode
elements.viewport.addEventListener("click", (event) => {
if (!state.measureMode) return;
const world = screenToWorld(event.clientX, event.clientY);
if (!state.measurePointA) {
state.measurePointA = world;
state.measurePointB = null;
} else if (!state.measurePointB) {
state.measurePointB = world;
} else {
state.measurePointA = world;
state.measurePointB = null;
}
drawMeasureLayer(event.clientX, event.clientY);
});
// 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 });
// Mouse leave coord reset
elements.viewport.addEventListener("mouseleave", () => {
elements.coordDisplay.textContent = "";
elements.gridDisplay.textContent = "";
});
// Right-click context menu
elements.viewport.addEventListener("contextmenu", (event) => {
event.preventDefault();
const world = screenToWorld(event.clientX, event.clientY);
const rect = elements.viewport.getBoundingClientRect();
showContextMenu(event.clientX - rect.left, event.clientY - rect.top, world.x, world.y);
});
elements.ctxAddWaypoint.addEventListener("click", () => {
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
const label = (window.prompt("Waypoint label (optional):") ?? "").trim();
const maxId = (state.settings.waypoints || []).reduce((m, w) => Math.max(m, Number(w.id || 0)), 0);
if (!Array.isArray(state.settings.waypoints)) state.settings.waypoints = [];
state.settings.waypoints.push({ id: maxId + 1, x: state.contextMenuWorldPos.x, y: state.contextMenuWorldPos.y, label });
persistSettings();
hideContextMenu();
state.listDirty = true;
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;
const wx = Math.round(state.contextMenuWorldPos.x);
const wz = Math.round(mapSize - state.contextMenuWorldPos.y);
navigator.clipboard?.writeText(`${wx}, ${wz}`).catch(() => {});
hideContextMenu();
showToast(`Copied: ${wx}, ${wz}`, "");
});
document.addEventListener("click", (event) => {
if (elements.ctxMenu.classList.contains("open") && !elements.ctxMenu.contains(event.target)) {
hideContextMenu();
}
});
// Measure toggle button
elements.measureToggle.addEventListener("click", () => toggleMeasureMode(!state.measureMode));
// Resize
window.addEventListener("resize", () => {
resizeMeasureLayer();
if (state.settings.followPlayer && state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot.localPlayer) {
centerOn(state.lastSnapshot.localPlayer);
return;
}
applyTransform();
});
// Init UI values from state
syncAllCheckboxes();
updatePresetButtons();
updateZoomDisplay();
bindKeyboard();
}
// ── Bootstrap & init ───────────────────────────────────────────────────────
rebuildFilterCaches();
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) => {
if (!response.ok) throw new Error("Unauthorized");
return response.json();
})
.then((snapshot) => {
applySnapshot(snapshot);
connectEvents();
})
.catch((err) => {
const msg = (err && err.message) || "unknown error";
if (msg === "Unauthorized") {
document.body.innerHTML = '<div class="unauthorized">Unauthorized — check password</div>';
} else {
document.body.innerHTML =
`<div class="unauthorized">Failed to connect: ${msg}.<br>` +
`Is the server running? Open <a href="/api/debug" style="color:#93c5fd">/api/debug</a> for diagnostics.</div>`;
}
});