Files
dayz-dma/webroot/app.js
T
67 4f5df4d1c9 WIP: Web radar implementation in progress — currently broken
The web radar component is undergoing refactoring and is non-functional.
Core memory reading and overlay systems remain operational.
2026-06-22 16:15:09 +08:00

3234 lines
132 KiB
JavaScript
Raw 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: {},
// 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
cachedGridKey: null, // detects when grid needs rebuild
cachedGridNode: null, // cached SVG <g> for the grid
};
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"),
showSatellite: document.getElementById("showSatellite"),
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"),
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"),
ctxCopyCoords: document.getElementById("ctxCopyCoords"),
toastStack: document.getElementById("toastStack"),
toggle3d: document.getElementById("toggle3d"),
canvas3d: document.getElementById("canvas3d"),
};
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.classList.toggle("grid-labels-visible", state.scale * MGRS_STEP >= 80);
scheduleVisibleTilesUpdate();
updateZoomDisplay();
if (state.measureMode) drawMeasureLayer();
}
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 = {};
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 showContextMenu(x, y, worldX, worldY) {
state.contextMenuWorldPos = { x: worldX, y: worldY };
const old = document.getElementById("ctxRemoveWaypoint");
if (old) old.remove();
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");
const old = document.getElementById("ctxRemoveWaypoint");
if (old) old.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();
}
}
// ── 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);
if (!useFixedScreenLabel(kind, itemOrGroup)) return size;
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
return { width: size.width / effectiveScale, height: size.height / 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 = estimateLabelSize(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;
if (kind === "players") {
const direction = document.createElement("div");
direction.className = "direction entity-direction";
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
direction.style.color = markerColor;
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28))}px`;
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`;
if (useFixedScreenLabel(kind, item)) {
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / 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`;
if (useFixedScreenLabel(group.kind, group)) {
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / effectiveScale})`;
}
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`;
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;
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) {
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();
// keep key in state.topoElements as a "do not retry" sentinel
});
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 (!state.lastSnapshot || !state.bootstrap) return;
if (state.settings.followPlayer && state.lastSnapshot.hasLocalPlayer && state.lastSnapshot.localPlayer) {
centerOn(state.lastSnapshot.localPlayer);
}
const markerNodes = [];
const itemLabelNodes = [];
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", state.lastSnapshot?.bullets || []));
const localPlayer = state.lastSnapshot.hasLocalPlayer ? state.lastSnapshot.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);
}
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();
// Enable/disable satellite checkbox based on whether tiles exist.
const hasSat = !!bootstrapData.satUrl;
elements.showSatellite.disabled = !hasSat;
elements.showSatellite.title = hasSat ? "" : "No satellite data — run: py -m dayzmap satellite --map <mapId>";
// 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();
applySnapshot(JSON.parse(event.data));
});
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 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,
showSatellite: elements.showSatellite,
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,
showSatellite: elements.showSatellite,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
})) {
el.addEventListener("change", () => {
state.settings[key] = el.checked;
persistSettings();
if (key === "showSatmap") 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();
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;
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
elements.viewport.addEventListener("wheel", (event) => {
if (event.target.closest("#ctxMenu")) return;
event.preventDefault();
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.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();
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>`;
}
});