4f5df4d1c9
The web radar component is undergoing refactoring and is non-functional. Core memory reading and overlay systems remain operational.
3234 lines
132 KiB
JavaScript
3234 lines
132 KiB
JavaScript
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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
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>`;
|
||
}
|
||
});
|