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 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("
"); } 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" ? ` ` : ""; const bulletControls = definition.key === "bullets" ? ` ` : ""; const textSizeControl = definition.key !== "bullets" ? ` ` : ""; const showLabelControl = definition.key !== "bullets" ? ` ` : ""; const card = document.createElement("section"); card.className = `filter-card${expanded ? " expanded" : ""}`; card.innerHTML = `
${definition.label}
${textSizeControl} ${directionControl} ${bulletControls} ${showLabelControl}
`; 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 = `
${definition.label}
`; 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 = '
No players
'; 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 `
${renderSafeMultilineHtml(getEntityLabelLines(player))}
${distText} ${steamUrl ? `${sanitize(steamId)}` : sanitize(steamId)}
`; }; 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 += '
On Map
'; rows += visibleRows; } if (hiddenRows.length > 0) { rows += `
${rows.length > 0 ? "Off Map" : "Players"}
`; rows += hiddenRows; } elements.playersList.innerHTML = rows || '
No players
'; } function renderLootList() { if (!state.lastSnapshot) { elements.lootList.innerHTML = '
No loot
'; 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 ` `; }; let rows = ""; if (favoriteItems.length > 0) { let currentCategory = ""; rows += '
Favorites
'; for (const item of favoriteItems) { const category = String(item.lootCategory || "other"); if (category !== currentCategory) { currentCategory = category; rows += `
${sanitize(getLootCategoryDefinition(item)?.label || "Other")}
`; } rows += renderRow(item); } } if (regularItems.length > 0) { let currentCategory = ""; if (rows.length > 0) rows += '
Loot
'; for (const item of regularItems) { const category = String(item.lootCategory || "other"); if (category !== currentCategory) { currentCategory = category; rows += `
${sanitize(getLootCategoryDefinition(item)?.label || "Other")}
`; } rows += renderRow(item); } } elements.lootList.innerHTML = rows || '
No loot matches filters
'; } // ── 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 "; // 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 = '
Unauthorized — check password
'; } else { document.body.innerHTML = `
Failed to connect: ${msg}.
` + `Is the server running? Open /api/debug for diagnostics.
`; } });