const storageKey = "dayz-web-map-settings"; const params = new URLSearchParams(window.location.search); const password = params.get("password") || ""; 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 = { settingsCollapsed: false, labelSettingsOpen: false, followPlayer: false, showPlayers: true, showZombies: true, showAnimals: true, showLoot: true, showVehicles: true, showBullets: true, showOtherEntities: true, showFavoriteLoot: true, showLabels: true, distanceFilter: 2000, textSize: 14, ordinaryLootSpread: 2, groupedLootSpread: 2, sameLootMergeRadius: 110, lineAnchorMode: "bottom-center", mergeSameLootLabels: true, favoriteLootNames: [], filterStyles: {}, filterExpanded: {} }; const savedSettings = (() => { try { const parsed = JSON.parse(localStorage.getItem(storageKey) || "{}"); return { ...defaultSettings, ...parsed, filterStyles: { ...(defaultSettings.filterStyles || {}), ...(parsed.filterStyles || {}) }, filterExpanded: { ...(defaultSettings.filterExpanded || {}), ...(parsed.filterExpanded || {}) } }; } 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 }; 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"), playersToggle: document.getElementById("playersToggle"), playersPanel: document.getElementById("playersPanel"), playersClose: document.getElementById("playersClose"), playersList: document.getElementById("playersList"), lootToggle: document.getElementById("lootToggle"), lootPanel: document.getElementById("lootPanel"), lootClose: document.getElementById("lootClose"), lootInfo: document.getElementById("lootInfo"), lootList: document.getElementById("lootList"), serverInfo: document.getElementById("serverInfo"), serverBadge: document.getElementById("serverBadge"), settingsPanel: document.getElementById("settingsPanel"), settingsPanelBody: document.getElementById("settingsPanelBody"), settingsToggle: document.getElementById("settingsToggle"), collapseButton: document.getElementById("collapseButton"), labelSettingsToggle: document.getElementById("labelSettingsToggle"), labelSettingsPanel: document.getElementById("labelSettingsPanel"), labelSettingsBody: document.getElementById("labelSettingsBody"), labelSettingsClose: document.getElementById("labelSettingsClose"), labelSettingsReset: document.getElementById("labelSettingsReset"), entityFilterList: document.getElementById("entityFilterList"), lootFilterList: document.getElementById("lootFilterList"), followPlayer: document.getElementById("followPlayer"), showLoot: document.getElementById("showLoot"), showLabels: document.getElementById("showLabels"), distanceFilter: document.getElementById("distanceFilter"), distanceValue: document.getElementById("distanceValue"), textSize: document.getElementById("textSize"), textSizeValue: document.getElementById("textSizeValue"), 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") }; 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 ? `💀${baseLabel}${distanceText}ðŸŠĶ` : `${baseLabel}${distanceText}`; return handItem.length > 0 ? [firstLine.trim(), `🖐ïļ${handItem}ðŸ”Ŧ`] : [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; state.settings.labelSettingsOpen = !!state.settings.labelSettingsOpen; } 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(); render(); } function apiUrl(path) { return password ? `${path}?password=${encodeURIComponent(password)}` : path; } 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 `/tile?${query.toString()}`; } 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 applyTransform() { constrainViewState(); elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`; updateVisibleTiles(); } 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.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 = {}; elements.tiles.replaceChildren(); } applyTransform(); return changed; } function setSettingsCollapsed(collapsed) { state.settings.settingsCollapsed = collapsed; persistSettings(); elements.settingsPanel.classList.toggle("collapsed", collapsed); elements.settingsToggle.classList.toggle("visible", collapsed); } function setPanelOpen(panelElement, open) { if (open && panelElement === elements.playersPanel) { elements.lootPanel.classList.remove("open"); } if (open && panelElement === elements.lootPanel) { elements.playersPanel.classList.remove("open"); } panelElement.classList.toggle("open", open); } function setLabelSettingsOpen(open) { state.settings.labelSettingsOpen = open; persistSettings(); elements.labelSettingsPanel.classList.toggle("open", open); } 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 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; elements.labelSettingsPanel.classList.toggle("open", !!state.settings.labelSettingsOpen); } 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 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 (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 (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)); } 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 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 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 clampNumber(value, min, max) { return Math.min(max, Math.max(min, value)); } function isPointOutsideViewport(screenX, screenY, viewportRect) { return screenX < viewportRect.left || screenX > viewportRect.right || screenY < viewportRect.top || screenY > viewportRect.bottom; } function getBlockedViewportRects() { const viewportRect = elements.viewport.getBoundingClientRect(); const blockedElements = []; if (!elements.settingsPanel.classList.contains("collapsed")) { blockedElements.push(elements.settingsPanel); } if (elements.playersPanel.classList.contains("open")) { blockedElements.push(elements.playersPanel); } if (elements.lootPanel.classList.contains("open")) { blockedElements.push(elements.lootPanel); } if (elements.labelSettingsPanel.classList.contains("open")) { blockedElements.push(elements.labelSettingsPanel); } return blockedElements .map((element) => element.getBoundingClientRect()) .map((rect) => ({ left: rect.left - viewportRect.left, top: rect.top - viewportRect.top, right: rect.right - viewportRect.left, bottom: rect.bottom - viewportRect.top })); } 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 }; } function renderFilterCards() { const buildCard = (definition) => { const style = state.settings.filterStyles[definition.key]; const visible = !!state.settings[definition.visibleKey]; const expanded = !!state.settings.filterExpanded[definition.key]; const card = document.createElement("section"); card.className = `filter-card${expanded ? " expanded" : ""}`; const directionControl = definition.key === "players" ? ` ` : ""; const bulletControls = definition.key === "bullets" ? ` ` : ""; const textSizeControl = definition.key !== "bullets" ? ` ` : ""; const showLabelControl = definition.key !== "bullets" ? ` ` : ""; card.innerHTML = `
${textSizeControl} ${directionControl} ${bulletControls} ${showLabelControl}
`; const visibleInput = card.querySelector(".filter-main input"); 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; persistSettings(); 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; persistSettings(); if (swatch) { swatch.style.background = colorInput.value; } 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; }; elements.entityFilterList.replaceChildren(...filterDefinitions.filter((definition) => definition.kind !== "loot").map(buildCard)); elements.lootFilterList.replaceChildren(...filterDefinitions.filter((definition) => definition.kind === "loot").map(buildCard)); } function renderPlayersList() { if (!state.lastSnapshot) { 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 visiblePlayers = players.filter((player) => player.visibleOnMap && player.distance >= 0); const maxDistance = visiblePlayers.reduce((value, player) => Math.max(value, Number(player.distance || 0)), 1); 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 renderPlayerRow = (player) => { const normalized = player.visibleOnMap && player.distance >= 0 ? Math.max(0, Math.min(1, 1 - ((player.distance || 0) / maxDistance))) : 0; const red = player.visibleOnMap ? 170 + Math.round(85 * normalized) : 180; const greenBlue = player.visibleOnMap ? 35 + Math.round(60 * (1 - normalized)) : 140; const nameColor = `rgb(${red},${greenBlue},${greenBlue})`; const steamId = player.steamId && String(player.steamId).trim().length > 0 ? String(player.steamId) : "unknown"; const steamUrl = steamId !== "unknown" ? `https://steamcommunity.com/profiles/${encodeURIComponent(steamId)}` : ""; return `
${renderSafeMultilineHtml(getEntityLabelLines(player))}
${steamUrl ? `${sanitize(steamId)}` : sanitize(steamId)}
`; }; const visibleRows = players.filter(visibleOnMapByFilters).sort(sortVisiblePlayers).map(renderPlayerRow).join(""); const hiddenRows = players.filter((player) => !visibleOnMapByFilters(player)).sort(sortHiddenPlayers).map(renderPlayerRow).join(""); let rows = ""; if (visibleRows.length > 0) { rows += '
Visible On Map
'; rows += visibleRows; } if (hiddenRows.length > 0) { rows += `${rows.length > 0 ? '
Hidden / 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((definition) => definition.kind === "loot").map((definition, index) => [definition.category || "other", index])); const sortLootItems = (lhs, rhs) => { const lhsCategory = String(lhs.lootCategory || "other"); const rhsCategory = String(rhs.lootCategory || "other"); const byCategory = (categoryOrder.get(lhsCategory) ?? 999) - (categoryOrder.get(rhsCategory) ?? 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((item) => isFavoriteLoot(item)); const regularItems = lootItems.filter((item) => !isFavoriteLoot(item)); elements.lootInfo.textContent = `Loot: ${lootItems.length} visible item${lootItems.length === 1 ? "" : "s"} (filters respected)`; 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 += '
Visible 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 current filters
'; } 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; } 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((visual) => visual.revealedDistance + 0.5 < visual.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((point) => `${point.x},${point.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((point) => `${point.x},${point.y}`).join(" ")); nodes.push(predicted); } } } return nodes; } 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 preloadMargin = 2; const keepMargin = 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(state.bootstrap.tileCountX - 1, Math.floor(right / tileSize) + preloadMargin); const maxTileY = Math.min(state.bootstrap.tileCountY - 1, Math.floor(bottom / tileSize) + preloadMargin); const ensureTile = (tileX, tileY, key) => { let img = elements.tiles.querySelector(`[data-key="${key}"]`); if (!img) { img = document.createElement("img"); img.className = "map-tile loading"; img.dataset.key = key; img.dataset.x = String(tileX); img.dataset.y = String(tileY); img.dataset.retry = "0"; img.draggable = false; img.style.left = `${tileX * tileSize}px`; img.style.top = `${tileY * tileSize}px`; img.style.width = `${tileSize}px`; img.style.height = `${tileSize}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 (!document.body.contains(img)) { return; } loadTile(nextRetry); }, 300 * nextRetry); } }); loadTile(0); elements.tiles.appendChild(img); return; } 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); } } }; for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) { for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) { const key = `${tileX}:${tileY}`; ensureTile(tileX, tileY, key); } } for (const node of Array.from(elements.tiles.querySelectorAll(".map-tile"))) { const tileX = Number(node.dataset.x || "0"); const tileY = Number(node.dataset.y || "0"); const keep = tileX >= Math.max(0, minTileX - keepMargin) && tileX <= Math.min(state.bootstrap.tileCountX - 1, maxTileX + keepMargin) && tileY >= Math.max(0, minTileY - keepMargin) && tileY <= Math.min(state.bootstrap.tileCountY - 1, maxTileY + keepMargin); if (!keep) { delete state.tileState[node.dataset.key]; node.remove(); } } } 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 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(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); } } elements.itemLabels.replaceChildren(...itemLabelNodes); elements.paths.replaceChildren(...pathNodes); elements.markers.replaceChildren(...markerNodes); if (elements.playersPanel.classList.contains("open")) { renderPlayersList(); } if (elements.lootPanel.classList.contains("open")) { renderLootList(); } } 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); }); }; 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(); 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 = `Server: ${serverTitle} | Map: ${mapText} | Version: ${versionText}`; elements.serverBadge.textContent = `Server: ${serverTitle}`; elements.status.textContent = snapshot.hasLocalPlayer ? `Live: ${snapshot.mapName}` : "Waiting for local player..."; if (!snapshotChanged) { return; } 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:0", "left:0", "right:0", "background:#b91c1c", "color:#fff", "padding:10px 16px", "font-size:13px", "z-index:9999", "text-align:center", "cursor:pointer" ].join(";"); 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(); } 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); // Surface missing-map-image problems immediately with a visible red banner. 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 next to the exe in a maps/ folder " + "(e.g. maps/chernarusplus.png). Open /api/debug for the exact expected path." ); } 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((definition) => definition.kind === "loot"))]; rebuildFilterCaches(); ensureFilterSettings(); renderFilterCards(); centerOn({ x: state.bootstrap.mapSize / 2, y: state.bootstrap.mapSize / 2 }); setSettingsCollapsed(!!state.settings.settingsCollapsed); syncLabelSettingsUi(); } 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"; startFallbackPolling(); scheduleReconnect(); }; } function bindUi() { for (const [key, element] of Object.entries({ followPlayer: elements.followPlayer, showLoot: elements.showLoot, showLabels: elements.showLabels })) { element.checked = !!state.settings[key]; element.addEventListener("change", () => { state.settings[key] = element.checked; persistSettings(); render(); }); } 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(); render(); }); 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(); }); syncLabelSettingsUi(); elements.labelSettingsToggle.addEventListener("click", () => setLabelSettingsOpen(!elements.labelSettingsPanel.classList.contains("open"))); elements.labelSettingsClose.addEventListener("click", () => setLabelSettingsOpen(false)); elements.labelSettingsReset.addEventListener("click", () => { resetLabelSettings(); ensureFilterSettings(); syncLabelSettingsUi(); persistSettings(); render(); }); elements.ordinaryLootSpread.addEventListener("input", () => { state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value); syncLabelSettingsUi(); persistSettings(); render(); }); elements.groupedLootSpread.addEventListener("input", () => { state.settings.groupedLootSpread = Number(elements.groupedLootSpread.value); syncLabelSettingsUi(); persistSettings(); render(); }); elements.sameLootMergeRadius.addEventListener("input", () => { state.settings.sameLootMergeRadius = Number(elements.sameLootMergeRadius.value); syncLabelSettingsUi(); persistSettings(); render(); }); elements.lineAnchorMode.addEventListener("change", () => { state.settings.lineAnchorMode = elements.lineAnchorMode.value; syncLabelSettingsUi(); persistSettings(); render(); }); elements.mergeSameLootLabels.addEventListener("change", () => { state.settings.mergeSameLootLabels = elements.mergeSameLootLabels.checked; syncLabelSettingsUi(); persistSettings(); render(); }); elements.settingsToggle.addEventListener("click", () => setSettingsCollapsed(false)); elements.collapseButton.addEventListener("click", () => setSettingsCollapsed(!state.settings.settingsCollapsed)); elements.playersToggle.addEventListener("click", () => setPanelOpen(elements.playersPanel, !elements.playersPanel.classList.contains("open"))); elements.playersClose.addEventListener("click", () => setPanelOpen(elements.playersPanel, false)); elements.lootToggle.addEventListener("click", () => setPanelOpen(elements.lootPanel, !elements.lootPanel.classList.contains("open"))); elements.lootClose.addEventListener("click", () => setPanelOpen(elements.lootPanel, false)); elements.settingsPanelBody.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); elements.labelSettingsBody.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); elements.playersPanel.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); elements.lootPanel.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true }); elements.lootList.addEventListener("click", (event) => { const row = event.target.closest(".loot-row"); if (!row) { return; } toggleFavoriteLoot(row.dataset.lootName || ""); }); 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.lastX = event.clientX; state.lastY = event.clientY; state.pinchDistance = 0; return; } const pointerPair = getActivePointerPair(); if (pointerPair) { state.dragging = false; state.pinchDistance = getPointerDistance(pointerPair[0], pointerPair[1]); } }); elements.viewport.addEventListener("pointermove", (event) => { if (!state.activePointers.has(event.pointerId)) { return; } state.activePointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY }); const pointerPair = getActivePointerPair(); if (pointerPair) { const nextDistance = getPointerDistance(pointerPair[0], pointerPair[1]); if (state.pinchDistance > 0 && nextDistance > 0) { const midpoint = getPointerMidpoint(pointerPair[0], pointerPair[1]); zoomAt(midpoint.clientX, midpoint.clientY, state.scale * (nextDistance / state.pinchDistance)); } state.pinchDistance = nextDistance; return; } if (!state.dragging) { return; } updateMapDrag(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 [remainingPointer] = state.activePointers.values(); if (remainingPointer) { state.dragging = true; state.lastX = remainingPointer.clientX; state.lastY = remainingPointer.clientY; } state.pinchDistance = 0; }; elements.viewport.addEventListener("pointerup", stopPointerGesture); elements.viewport.addEventListener("pointercancel", stopPointerGesture); elements.viewport.addEventListener("wheel", (event) => { if (event.target.closest("#settingsPanel") || event.target.closest("#playersPanel") || event.target.closest("#lootPanel")) { return; } event.preventDefault(); zoomAt(event.clientX, event.clientY, state.scale * (event.deltaY < 0 ? 1.1 : 0.9)); }, { passive: false }); window.addEventListener("resize", () => { if (state.settings.followPlayer && state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot.localPlayer) { centerOn(state.lastSnapshot.localPlayer); return; } applyTransform(); }); } 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.
`; } });