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 = `