Files
dayz-dma/webroot/app.js
T

2425 lines
90 KiB
JavaScript

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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderSafeMultilineHtml(lines) {
return (Array.isArray(lines) ? lines : [String(lines || "")])
.filter((line) => String(line || "").trim().length > 0)
.map((line) => sanitize(line))
.join("<br>");
}
function normalizeInlineText(value) {
return String(value || "")
.replace(/\s*\r?\n\s*/g, " ")
.replace(/\s+/g, " ")
.replace(/\s*-\s*/g, "-")
.trim();
}
function getEntityLabelLines(item) {
const baseLabel = normalizeInlineText(item?.label || "Unknown");
const handItem = normalizeInlineText(item?.handItem || "");
const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : "";
const firstLine = item?.dead
? `💀${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" ? `
<label class="mini-control">
<span>View line</span>
<input class="range-direction" type="range" min="10" max="60" step="1" value="${style.directionLength || 28}">
<strong class="value-direction">${style.directionLength || 28}px</strong>
</label>` : "";
const bulletControls = definition.key === "bullets" ? `
<label class="toggle-row compact bullet-toggle-trajectory">
<input type="checkbox" ${style.showTrajectory !== false ? "checked" : ""}>
<span>Show trajectory</span>
</label>
<label class="toggle-row compact bullet-toggle-prediction">
<input type="checkbox" ${style.showPrediction !== false ? "checked" : ""}>
<span>Show prediction</span>
</label>
<label class="mini-control">
<span>Phantom</span>
<input class="range-phantom" type="range" min="0" max="60000" step="250" value="${style.phantomLifetimeMs || 5000}">
<strong class="value-phantom">${style.phantomLifetimeMs || 5000}ms</strong>
</label>
<label class="mini-control">
<span>Prediction</span>
<input class="range-prediction" type="range" min="25" max="1000" step="25" value="${style.predictionDistance || 250}">
<strong class="value-prediction">${style.predictionDistance || 250}m</strong>
</label>` : "";
const textSizeControl = definition.key !== "bullets" ? `
<label class="mini-control">
<span>Text size</span>
<input class="range-text" type="range" min="10" max="24" step="1" value="${style.textSize}">
<strong class="value-text">${style.textSize}px</strong>
</label>` : "";
const showLabelControl = definition.key !== "bullets" ? `
<label class="toggle-row compact toggle-show-label">
<input type="checkbox" ${style.showLabel ? "checked" : ""}>
<span>Show label</span>
</label>` : "";
card.innerHTML = `
<div class="filter-row">
<label class="filter-main">
<input type="checkbox" ${visible ? "checked" : ""}>
<span class="filter-swatch" style="background:${style.color}"></span>
<span class="filter-name">${definition.label}</span>
</label>
<button class="filter-gear" type="button" aria-label="Configure ${definition.label}">⚙</button>
</div>
<div class="filter-extra">
<label class="mini-control">
<span>Color</span>
<input type="color" value="${style.color}">
</label>
<label class="mini-control">
<span>Marker size</span>
<input class="range-marker" type="range" min="4" max="26" step="1" value="${style.markerSize}">
<strong class="value-marker">${style.markerSize}px</strong>
</label>
${textSizeControl}
${directionControl}
${bulletControls}
${showLabelControl}
</div>`;
const visibleInput = card.querySelector(".filter-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 `
<div class="players-row">
<div class="players-name" style="color:${nameColor}">${renderSafeMultilineHtml(getEntityLabelLines(player))}</div>
<div class="players-steam">${steamUrl ? `<a href="${steamUrl}" target="_blank" rel="noopener noreferrer">${sanitize(steamId)}</a>` : sanitize(steamId)}</div>
</div>`;
};
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 += '<div class="loot-section-title">Visible On Map</div>';
rows += visibleRows;
}
if (hiddenRows.length > 0) {
rows += `${rows.length > 0 ? '<div class="loot-section-title">Hidden / Off Map</div>' : '<div class="loot-section-title">Players</div>'}`;
rows += hiddenRows;
}
elements.playersList.innerHTML = rows || '<div class="players-empty">No players</div>';
}
function renderLootList() {
if (!state.lastSnapshot) {
elements.lootList.innerHTML = '<div class="players-empty">No loot</div>';
elements.lootInfo.textContent = "Loot: waiting for snapshot...";
return;
}
const categoryOrder = new Map(filterDefinitions.filter((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 `
<button class="loot-row${favorite ? " favorite" : ""}" type="button" data-loot-name="${sanitize(item.label || "")}" style="--loot-color:${color}">
<span class="loot-name">${sanitize(item.label || "Unknown")}</span>
<span class="loot-distance">${Math.round(Number(item.distance || 0))}m</span>
<span class="loot-meta">${sanitize(categoryLabel)}${favorite ? " • favorite" : ""}</span>
</button>`;
};
let rows = "";
if (favoriteItems.length > 0) {
let currentCategory = "";
rows += '<div class="loot-section-title">Favorites</div>';
for (const item of favoriteItems) {
const category = String(item.lootCategory || "other");
if (category !== currentCategory) {
currentCategory = category;
rows += `<div class="loot-group-title">${sanitize(getLootCategoryDefinition(item)?.label || "Other")}</div>`;
}
rows += renderRow(item);
}
}
if (regularItems.length > 0) {
let currentCategory = "";
if (rows.length > 0) {
rows += '<div class="loot-section-title">Visible Loot</div>';
}
for (const item of regularItems) {
const category = String(item.lootCategory || "other");
if (category !== currentCategory) {
currentCategory = category;
rows += `<div class="loot-group-title">${sanitize(getLootCategoryDefinition(item)?.label || "Other")}</div>`;
}
rows += renderRow(item);
}
}
elements.lootList.innerHTML = rows || '<div class="players-empty">No loot matches current filters</div>';
}
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 = '<div class="unauthorized">Unauthorized — check password</div>';
} else {
document.body.innerHTML =
`<div class="unauthorized">Failed to connect: ${msg}.<br>` +
`Is the server running? Open <a href="/api/debug" style="color:#93c5fd">/api/debug</a> for diagnostics.</div>`;
}
});