2425 lines
90 KiB
JavaScript
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function renderSafeMultilineHtml(lines) {
|
|
return (Array.isArray(lines) ? lines : [String(lines || "")])
|
|
.filter((line) => String(line || "").trim().length > 0)
|
|
.map((line) => sanitize(line))
|
|
.join("<br>");
|
|
}
|
|
|
|
function normalizeInlineText(value) {
|
|
return String(value || "")
|
|
.replace(/\s*\r?\n\s*/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.replace(/\s*-\s*/g, "-")
|
|
.trim();
|
|
}
|
|
|
|
function getEntityLabelLines(item) {
|
|
const baseLabel = normalizeInlineText(item?.label || "Unknown");
|
|
const handItem = normalizeInlineText(item?.handItem || "");
|
|
const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : "";
|
|
const firstLine = item?.dead
|
|
? `💀${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>`;
|
|
}
|
|
});
|