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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

function renderSafeMultilineHtml(lines) {
  return (Array.isArray(lines) ? lines : [String(lines || "")])
    .filter((line) => String(line || "").trim().length > 0)
    .map((line) => sanitize(line))
    .join("<br>");
}

function normalizeInlineText(value) {
  return String(value || "")
    .replace(/\s*\r?\n\s*/g, " ")
    .replace(/\s+/g, " ")
    .replace(/\s*-\s*/g, "-")
    .trim();
}

function getEntityLabelLines(item) {
  const baseLabel = normalizeInlineText(item?.label || "Unknown");
  const handItem = normalizeInlineText(item?.handItem || "");
  const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : "";
  const firstLine = item?.dead
    ? `💀${baseLabel}${distanceText}🪦`
    : `${baseLabel}${distanceText}`;

  return handItem.length > 0
    ? [firstLine.trim(), `🖐️${handItem}🔫`]
    : [firstLine.trim()];
}

function ensureFilterSettings() {
  for (const definition of filterDefinitions) {
    const current = state.settings.filterStyles[definition.key] || {};
    const currentColor = String(current.color || "").toLowerCase();
    const previousDefaultColor = previousDefaultFilterColors[definition.key];
    state.settings.filterStyles[definition.key] = {
      color: !currentColor || currentColor === previousDefaultColor ? definition.color : current.color,
      markerSize: Number(current.markerSize || definition.markerSize),
      textSize: Number(current.textSize || definition.textSize),
      showLabel: current.showLabel !== false
    };
    if (definition.key === "players") {
      const rawDirectionLength = Number(current.directionLength || definition.directionLength || 28);
      const safeDirectionLength = Number.isFinite(rawDirectionLength) ? rawDirectionLength : 28;
      state.settings.filterStyles[definition.key].directionLength = Math.min(60, Math.max(10, safeDirectionLength));
    }
    if (definition.key === "bullets") {
      const rawPhantomLifetimeMs = Number(current.phantomLifetimeMs || definition.phantomLifetimeMs || 5000);
      const rawPredictionDistance = Number(current.predictionDistance || definition.predictionDistance || 250);
      state.settings.filterStyles[definition.key].showTrajectory = current.showTrajectory !== false;
      state.settings.filterStyles[definition.key].showPrediction = current.showPrediction !== false;
      state.settings.filterStyles[definition.key].phantomLifetimeMs = Math.min(60000, Math.max(0, Number.isFinite(rawPhantomLifetimeMs) ? rawPhantomLifetimeMs : 5000));
      state.settings.filterStyles[definition.key].predictionDistance = Math.min(1000, Math.max(25, Number.isFinite(rawPredictionDistance) ? rawPredictionDistance : 250));
    }
    state.settings.filterExpanded[definition.key] = !!state.settings.filterExpanded[definition.key];
    if (typeof state.settings[definition.visibleKey] !== "boolean") {
      state.settings[definition.visibleKey] = true;
    }
  }
  if (!Array.isArray(state.settings.favoriteLootNames)) {
    state.settings.favoriteLootNames = [];
  }
  state.settings.favoriteLootNames = Array.from(new Set(state.settings.favoriteLootNames.map((value) => String(value || "").trim().toLowerCase()).filter(Boolean)));
  state.settings.ordinaryLootSpread = Math.min(4, Math.max(1, Number(state.settings.ordinaryLootSpread || defaultSettings.ordinaryLootSpread)));
  state.settings.groupedLootSpread = Math.min(4, Math.max(1, Number(state.settings.groupedLootSpread || defaultSettings.groupedLootSpread)));
  state.settings.sameLootMergeRadius = Math.min(240, Math.max(20, Number(state.settings.sameLootMergeRadius || defaultSettings.sameLootMergeRadius)));
  state.settings.lineAnchorMode = ["auto-nearest", "top-left", "top-center", "top-right", "middle-left", "middle-right", "bottom-left", "bottom-center", "bottom-right"].includes(state.settings.lineAnchorMode)
    ? state.settings.lineAnchorMode
    : defaultSettings.lineAnchorMode;
  state.settings.mergeSameLootLabels = state.settings.mergeSameLootLabels !== false;
  state.settings.labelSettingsOpen = !!state.settings.labelSettingsOpen;
}

function persistSettings() {
  localStorage.setItem(storageKey, JSON.stringify(state.settings));
}

function normalizeLootName(name) {
  return String(name || "").trim().toLowerCase();
}

function getFavoriteLootNames() {
  return new Set(state.settings.favoriteLootNames || []);
}

function isFavoriteLoot(item) {
  return !!(item && getFavoriteLootNames().has(normalizeLootName(item.label)));
}

function toggleFavoriteLoot(itemName) {
  const normalized = normalizeLootName(itemName);
  if (!normalized) {
    return;
  }
  const favorites = getFavoriteLootNames();
  if (favorites.has(normalized)) {
    favorites.delete(normalized);
  } else {
    favorites.add(normalized);
  }
  state.settings.favoriteLootNames = Array.from(favorites);
  persistSettings();
  render();
}

function apiUrl(path) {
  return password ? `${path}?password=${encodeURIComponent(password)}` : path;
}

function tileUrl(tileX, tileY, retry = 0) {
  const query = new URLSearchParams({ x: String(tileX), y: String(tileY), retry: String(retry) });
  if (state.bootstrap?.mapId) {
    query.set("mapId", state.bootstrap.mapId);
  }
  if (password) {
    query.set("password", password);
  }
  return `/tile?${query.toString()}`;
}

function getViewportMetrics() {
  const rect = elements.viewport.getBoundingClientRect();
  const mapSize = Number(state.bootstrap?.mapSize || 0);
  return {
    width: Math.max(0, rect.width),
    height: Math.max(0, rect.height),
    mapSize
  };
}

function constrainViewState() {
  const metrics = getViewportMetrics();
  if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) {
    return;
  }

  const minScaleForWidth = metrics.width / metrics.mapSize;
  const minScaleForHeight = metrics.height / metrics.mapSize;
  const effectiveMinScale = Math.max(minMapScale, minScaleForWidth, minScaleForHeight);
  state.scale = Math.min(maxMapScale, Math.max(effectiveMinScale, state.scale));

  const scaledMapWidth = metrics.mapSize * state.scale;
  const scaledMapHeight = metrics.mapSize * state.scale;
  const minOffsetX = metrics.width - scaledMapWidth;
  const minOffsetY = metrics.height - scaledMapHeight;

  state.offsetX = clampNumber(state.offsetX, Math.min(minOffsetX, 0), 0);
  state.offsetY = clampNumber(state.offsetY, Math.min(minOffsetY, 0), 0);
}

function applyTransform() {
  constrainViewState();
  elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`;
  updateVisibleTiles();
}

function applyMapMetadata(metadata, clearTiles = false) {
  if (!metadata) {
    return false;
  }

  const previous = state.bootstrap;
  const next = {
    ...(previous || {}),
    ...metadata,
    mapId:      metadata.mapId      || previous?.mapId      || "",
    mapName:    metadata.mapName    || previous?.mapName    || "",
    mapSize: Number(metadata.mapSize || previous?.mapSize || 0),
    tileSize: Number(metadata.tileSize || previous?.tileSize || 512),
    tileCountX: Number(metadata.tileCountX || previous?.tileCountX || 0),
    tileCountY: Number(metadata.tileCountY || previous?.tileCountY || 0)
  };
  const changed = !previous
    || previous.mapId !== next.mapId
    || previous.mapName !== next.mapName
    || previous.mapSize !== next.mapSize
    || previous.tileSize !== next.tileSize
    || previous.tileCountX !== next.tileCountX
    || previous.tileCountY !== next.tileCountY;

  state.bootstrap = next;
  elements.canvas.style.width = `${next.mapSize}px`;
  elements.canvas.style.height = `${next.mapSize}px`;
  elements.tiles.style.width = `${next.mapSize}px`;
  elements.tiles.style.height = `${next.mapSize}px`;
  elements.paths.setAttribute("viewBox", `0 0 ${next.mapSize} ${next.mapSize}`);
  elements.paths.setAttribute("width", `${next.mapSize}`);
  elements.paths.setAttribute("height", `${next.mapSize}`);
  elements.itemLabels.style.width = `${next.mapSize}px`;
  elements.itemLabels.style.height = `${next.mapSize}px`;
  elements.markers.style.width = `${next.mapSize}px`;
  elements.markers.style.height = `${next.mapSize}px`;

  if (clearTiles || changed) {
    state.tileState = {};
    elements.tiles.replaceChildren();
  }

  applyTransform();
  return changed;
}

function setSettingsCollapsed(collapsed) {
  state.settings.settingsCollapsed = collapsed;
  persistSettings();
  elements.settingsPanel.classList.toggle("collapsed", collapsed);
  elements.settingsToggle.classList.toggle("visible", collapsed);
}

function setPanelOpen(panelElement, open) {
  if (open && panelElement === elements.playersPanel) {
    elements.lootPanel.classList.remove("open");
  }
  if (open && panelElement === elements.lootPanel) {
    elements.playersPanel.classList.remove("open");
  }
  panelElement.classList.toggle("open", open);
}

function setLabelSettingsOpen(open) {
  state.settings.labelSettingsOpen = open;
  persistSettings();
  elements.labelSettingsPanel.classList.toggle("open", open);
}

function resetLabelSettings() {
  state.settings.ordinaryLootSpread = defaultSettings.ordinaryLootSpread;
  state.settings.groupedLootSpread = defaultSettings.groupedLootSpread;
  state.settings.sameLootMergeRadius = defaultSettings.sameLootMergeRadius;
  state.settings.lineAnchorMode = defaultSettings.lineAnchorMode;
  state.settings.mergeSameLootLabels = defaultSettings.mergeSameLootLabels;
}

function syncLabelSettingsUi() {
  elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread);
  elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
  elements.groupedLootSpread.value = String(state.settings.groupedLootSpread);
  elements.groupedLootSpreadValue.textContent = `${Number(state.settings.groupedLootSpread).toFixed(1)}x`;
  elements.sameLootMergeRadius.value = String(state.settings.sameLootMergeRadius);
  elements.sameLootMergeRadiusValue.textContent = `${Math.round(Number(state.settings.sameLootMergeRadius))} px`;
  elements.lineAnchorMode.value = state.settings.lineAnchorMode;
  elements.mergeSameLootLabels.checked = !!state.settings.mergeSameLootLabels;
  elements.labelSettingsPanel.classList.toggle("open", !!state.settings.labelSettingsOpen);
}

function centerOn(point) {
  if (!point) {
    return;
  }
  const rect = elements.viewport.getBoundingClientRect();
  state.offsetX = (rect.width / 2) - (point.x * state.scale);
  state.offsetY = (rect.height / 2) - (point.y * state.scale);
  applyTransform();
}

function getFilterDefinition(kind, item) {
  if (kind === "loot") {
    if (isFavoriteLoot(item)) {
      return filtersByKey.favoriteLoot || null;
    }
    const filterKey = lootFilterKeyByCategory[item.lootCategory || "other"] || "lootOther";
    return filtersByKey[filterKey] || null;
  }
  return filtersByKey[filterKeyByKind[kind]] || null;
}

function getLootCategoryDefinition(item) {
  const filterKey = lootFilterKeyByCategory[item?.lootCategory || "other"] || "lootOther";
  return filtersByKey[filterKey] || null;
}

function getMarkerColor(kind, item) {
  const filterDefinition = getFilterDefinition(kind, item);
  const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null;
  return filterStyle ? filterStyle.color : (kind === "loot" && isFavoriteLoot(item) ? favoriteLootColor : "#f8fafc");
}

function isLootCategoryEnabled(category) {
  if (!category) {
    return true;
  }

  if (typeof state.settings[category] !== "boolean") {
    state.settings[category] = true;
  }

  return state.settings[category];
}

function isItemVisibleByFilters(kind, item) {
  if (!item) {
    return false;
  }
  if (kind === "loot" && isFavoriteLoot(item)) {
    return state.settings.showFavoriteLoot !== false;
  }
  if (state.settings.distanceFilter > 0 && item.distance > state.settings.distanceFilter) {
    return false;
  }
  if (kind === "loot") {
    if (!state.settings.showLoot) {
      return false;
    }
    if (!isLootCategoryEnabled(item.lootCategory)) {
      return false;
    }
  }
  if (kind === "players" && !state.settings.showPlayers) {
    return false;
  }
  if (kind === "zombies" && !state.settings.showZombies) {
    return false;
  }
  if (kind === "animals" && !state.settings.showAnimals) {
    return false;
  }
  if (kind === "vehicles" && !state.settings.showVehicles) {
    return false;
  }
  if (kind === "bullets" && !state.settings.showBullets) {
    return false;
  }
  if (kind === "otherEntities" && !state.settings.showOtherEntities) {
    return false;
  }
  if (kind === "bullets") {
    const phantomLifetimeMs = Number(state.settings.filterStyles?.bullets?.phantomLifetimeMs || 0);
    if (item.isPhantom && phantomLifetimeMs >= 0) {
      const lastSeenAtUtcMs = Number(item.lastSeenAtUtcMs || 0);
      if (lastSeenAtUtcMs > 0 && (Date.now() - lastSeenAtUtcMs) > phantomLifetimeMs) {
        return false;
      }
    }
  }
  if (kind === "players" && state.lastSnapshot?.localPlayer) {
    const dx = item.x - state.lastSnapshot.localPlayer.x;
    const dy = item.y - state.lastSnapshot.localPlayer.y;
    if ((dx * dx + dy * dy) <= 9.0) {
      return false;
    }
  }
  return true;
}

function getVisibleCollection(kind, list) {
  return (list || []).filter((item) => isItemVisibleByFilters(kind, item));
}

function buildLabelText(item) {
  const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : "";
  if (item?.kind === "player") {
    return getEntityLabelLines(item).join("\n");
  }
  return `${String(item?.label || "Unknown")}${distanceText}`;
}

function isEntityKind(kind) {
  return kind !== "loot";
}

function getRenderPriority(kind, item) {
  if (isEntityKind(kind)) {
    return 3;
  }
  return isFavoriteLoot(item) ? 2 : 1;
}

function getLayerClass(kind, item) {
  const priority = getRenderPriority(kind, item);
  if (priority === 3) {
    return "layer-entity";
  }
  if (priority === 2) {
    return "layer-favorite";
  }
  return "layer-loot";
}

function getLabelConfig(kind, item) {
  const filterDefinition = getFilterDefinition(kind, item);
  const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null;
  if (kind === "bullets") {
    return {
      filterDefinition,
      filterStyle,
      showLabel: false,
      textSize: filterStyle ? filterStyle.textSize : state.settings.textSize
    };
  }
  const showLabel = !!(state.settings.showLabels && item.label && (!filterStyle || filterStyle.showLabel));
  return {
    filterDefinition,
    filterStyle,
    showLabel,
    textSize: filterStyle ? filterStyle.textSize : state.settings.textSize
  };
}

function useFixedScreenLabel(kind, itemOrGroup) {
  if (kind === "players") {
    return true;
  }
  if (kind === "loot") {
    if (Object.prototype.hasOwnProperty.call(itemOrGroup || {}, "priority")) {
      return itemOrGroup?.priority === 2;
    }
    return isFavoriteLoot(itemOrGroup);
  }
  return false;
}

function clampScale(value) {
  const metrics = getViewportMetrics();
  if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) {
    return Math.min(maxMapScale, Math.max(minMapScale, value));
  }

  const minScaleForWidth = metrics.width / metrics.mapSize;
  const minScaleForHeight = metrics.height / metrics.mapSize;
  return Math.min(maxMapScale, Math.max(minMapScale, minScaleForWidth, minScaleForHeight, value));
}

function zoomAt(clientX, clientY, nextScale) {
  const rect = elements.viewport.getBoundingClientRect();
  const localPlayer = state.settings.followPlayer && state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot?.localPlayer
    ? state.lastSnapshot.localPlayer
    : null;
  const originX = localPlayer
    ? ((localPlayer.x * state.scale) + state.offsetX)
    : (clientX - rect.left);
  const originY = localPlayer
    ? ((localPlayer.y * state.scale) + state.offsetY)
    : (clientY - rect.top);
  const worldX = (originX - state.offsetX) / state.scale;
  const worldY = (originY - state.offsetY) / state.scale;
  state.scale = clampScale(nextScale);
  state.offsetX = originX - (worldX * state.scale);
  state.offsetY = originY - (worldY * state.scale);
  applyTransform();
}

function getActivePointerPair() {
  const pointers = Array.from(state.activePointers.values());
  return pointers.length >= 2 ? [pointers[0], pointers[1]] : null;
}

function getPointerDistance(first, second) {
  const dx = second.clientX - first.clientX;
  const dy = second.clientY - first.clientY;
  return Math.hypot(dx, dy);
}

function getPointerMidpoint(first, second) {
  return {
    clientX: (first.clientX + second.clientX) / 2,
    clientY: (first.clientY + second.clientY) / 2
  };
}

function updateMapDrag(clientX, clientY) {
  state.offsetX += clientX - state.lastX;
  state.offsetY += clientY - state.lastY;
  state.lastX = clientX;
  state.lastY = clientY;
  applyTransform();
}

function getRectIntersectionArea(first, second) {
  const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
  const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));
  return width * height;
}

function getPlacementPenalty(rect, occupiedRects, viewportRect) {
  let overlapArea = 0;
  for (const occupied of occupiedRects) {
    overlapArea += getRectIntersectionArea(rect, occupied);
  }

  const overflowX = Math.max(0, viewportRect.left - rect.left) + Math.max(0, rect.right - viewportRect.right);
  const overflowY = Math.max(0, viewportRect.top - rect.top) + Math.max(0, rect.bottom - viewportRect.bottom);
  return (overlapArea * 10) + ((overflowX + overflowY) * 120);
}

function estimateLabelSize(text, fontSize) {
  const safeFontSize = Math.max(10, Number(fontSize) || 14);
  const safeText = String(text || "");
  const lines = safeText.split("\n");
  const lineHeight = Math.max(1, Math.ceil(safeFontSize * 1.15));
  if (textMeasureContext) {
    textMeasureContext.font = `700 ${safeFontSize}px Arial, sans-serif`;
    let width = 1;
    let ascent = Math.ceil(safeFontSize * 0.8);
    let descent = Math.ceil(safeFontSize * 0.2);
    for (const line of lines) {
      const metrics = textMeasureContext.measureText(line || " ");
      width = Math.max(width, Math.ceil(metrics.width));
      ascent = Math.max(ascent, Math.ceil(metrics.actualBoundingBoxAscent || (safeFontSize * 0.8)));
      descent = Math.max(descent, Math.ceil(metrics.actualBoundingBoxDescent || (safeFontSize * 0.2)));
    }
    return {
      width: Math.max(1, width),
      height: Math.max(1, ascent + descent + ((lines.length - 1) * lineHeight))
    };
  }

  const longestLineLength = lines.reduce((maxLength, line) => Math.max(maxLength, String(line || "").length), 0);
  return {
    width: Math.max(1, Math.round(longestLineLength * safeFontSize * 0.52)),
    height: Math.max(1, Math.round(safeFontSize + ((lines.length - 1) * lineHeight)))
  };
}

function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
  const size = estimateLabelSize(text, fontSize);
  if (!useFixedScreenLabel(kind, itemOrGroup)) {
    return size;
  }

  const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
  return {
    width: size.width / effectiveScale,
    height: size.height / effectiveScale
  };
}

function buildPinnedLabelCandidates(entry, size) {
  const markerSize = entry.label.filterStyle ? entry.label.filterStyle.markerSize : (entry.kind === "vehicles" ? 14 : 12);
  const radius = Math.max(6, markerSize * 0.7);
  const gap = Math.max(2, Math.round(markerSize * 0.2));
  const sideCandidates = {
    "middle-right": { left: radius + gap, top: -Math.round(size.height / 2), distance: gap },
    "middle-left": { left: -(size.width + radius + gap), top: -Math.round(size.height / 2), distance: gap },
    "top-center": { left: -Math.round(size.width / 2), top: -(size.height + radius + gap), distance: gap },
    "bottom-center": { left: -Math.round(size.width / 2), top: radius + gap, distance: gap }
  };

  return [
    sideCandidates["middle-left"],
    sideCandidates["middle-right"],
    sideCandidates["top-center"],
    sideCandidates["bottom-center"]
  ];
}

function buildScreenRect(screenX, screenY, candidate, size, scale) {
  return {
    left: screenX + (candidate.left * scale),
    top: screenY + (candidate.top * scale),
    right: screenX + ((candidate.left + size.width) * scale),
    bottom: screenY + ((candidate.top + size.height) * scale)
  };
}

function clampNumber(value, min, max) {
  return Math.min(max, Math.max(min, value));
}

function isPointOutsideViewport(screenX, screenY, viewportRect) {
  return screenX < viewportRect.left
    || screenX > viewportRect.right
    || screenY < viewportRect.top
    || screenY > viewportRect.bottom;
}

function getBlockedViewportRects() {
  const viewportRect = elements.viewport.getBoundingClientRect();
  const blockedElements = [];
  if (!elements.settingsPanel.classList.contains("collapsed")) {
    blockedElements.push(elements.settingsPanel);
  }
  if (elements.playersPanel.classList.contains("open")) {
    blockedElements.push(elements.playersPanel);
  }
  if (elements.lootPanel.classList.contains("open")) {
    blockedElements.push(elements.lootPanel);
  }
  if (elements.labelSettingsPanel.classList.contains("open")) {
    blockedElements.push(elements.labelSettingsPanel);
  }

  return blockedElements
    .map((element) => element.getBoundingClientRect())
    .map((rect) => ({
      left: rect.left - viewportRect.left,
      top: rect.top - viewportRect.top,
      right: rect.right - viewportRect.left,
      bottom: rect.bottom - viewportRect.top
    }));
}

function buildViewportEdgeCandidates(screenX, screenY, size, viewportRect) {
  const padding = 10;
  const labelScreenWidth = size.width * state.scale;
  const labelScreenHeight = size.height * state.scale;
  const availableLeft = viewportRect.left + padding;
  const availableTop = viewportRect.top + padding;
  const availableRight = viewportRect.right - labelScreenWidth - padding;
  const availableBottom = viewportRect.bottom - labelScreenHeight - padding;
  const overflowLeft = screenX < viewportRect.left ? viewportRect.left - screenX : Number.POSITIVE_INFINITY;
  const overflowRight = screenX > viewportRect.right ? screenX - viewportRect.right : Number.POSITIVE_INFINITY;
  const overflowTop = screenY < viewportRect.top ? viewportRect.top - screenY : Number.POSITIVE_INFINITY;
  const overflowBottom = screenY > viewportRect.bottom ? screenY - viewportRect.bottom : Number.POSITIVE_INFINITY;
  const edgeCandidates = [
    {
      edge: "left",
      overflow: overflowLeft,
      left: availableLeft,
      top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom))
    },
    {
      edge: "right",
      overflow: overflowRight,
      left: availableRight,
      top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom))
    },
    {
      edge: "top",
      overflow: overflowTop,
      left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)),
      top: availableTop
    },
    {
      edge: "bottom",
      overflow: overflowBottom,
      left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)),
      top: availableBottom
    }
  ];

  return edgeCandidates
    .sort((lhs, rhs) => lhs.overflow - rhs.overflow)
    .map((candidate) => ({
      left: (candidate.left - screenX) / state.scale,
      top: (candidate.top - screenY) / state.scale,
      anchorX: 0,
      anchorY: 0,
      distance: Number.isFinite(candidate.overflow) ? candidate.overflow : 100000
    }));
}

function normalizeGroupLootLabel(label) {
  return String(label || "").trim().toLowerCase();
}

function getLootGroupingKey(item) {
  return `${normalizeGroupLootLabel(item?.label)}:${String(item?.lootCategory || "other").trim().toLowerCase()}`;
}

function canUsePriorityGrouping(entry) {
  return getRenderPriority(entry.kind, entry.item) >= 2 && entry.kind !== "players";
}

function buildPriorityLabelGroups(entries) {
  const groups = [];
  const groupableEntries = entries
    .filter((entry) => canUsePriorityGrouping(entry) && entry.label.showLabel)
    .map((entry) => ({
      entry,
      key: `${entry.kind}:${getRenderPriority(entry.kind, entry.item)}:${entry.kind === "loot" ? getLootGroupingKey(entry.item) : normalizeGroupLootLabel(entry.item.label)}`,
      screenX: (entry.item.x * state.scale) + state.offsetX,
      screenY: (entry.item.y * state.scale) + state.offsetY
    }))
    .sort((lhs, rhs) => {
      const byKey = lhs.key.localeCompare(rhs.key);
      if (byKey !== 0) {
        return byKey;
      }
      if (lhs.screenY !== rhs.screenY) {
        return lhs.screenY - rhs.screenY;
      }
      return lhs.screenX - rhs.screenX;
    });

  const clusterDistance = Math.max(24, Math.round(Number(state.settings.sameLootMergeRadius || defaultSettings.sameLootMergeRadius) * 0.55));

  for (const candidate of groupableEntries) {
    let bestGroup = null;
    let bestDistance = Number.POSITIVE_INFINITY;

    for (const group of groups) {
      if (group.key !== candidate.key) {
        continue;
      }
      const dx = candidate.screenX - group.screenX;
      const dy = candidate.screenY - group.screenY;
      const distance = Math.hypot(dx, dy);
      if (distance <= clusterDistance && distance < bestDistance) {
        bestDistance = distance;
        bestGroup = group;
      }
    }

    if (!bestGroup) {
      groups.push({
        key: candidate.key,
        kind: candidate.entry.kind,
        priority: getRenderPriority(candidate.entry.kind, candidate.entry.item),
        entries: [candidate.entry],
        label: candidate.entry.label,
        markerColor: candidate.entry.markerColor,
        baseText: String(candidate.entry.item.label || "Unknown"),
        anchorX: candidate.entry.item.x,
        anchorY: candidate.entry.item.y,
        screenX: candidate.screenX,
        screenY: candidate.screenY,
        labelText: String(candidate.entry.item.label || "Unknown"),
        labelPlacement: null
      });
      continue;
    }

    bestGroup.entries.push(candidate.entry);
    const count = bestGroup.entries.length;
    bestGroup.anchorX = ((bestGroup.anchorX * (count - 1)) + candidate.entry.item.x) / count;
    bestGroup.anchorY = ((bestGroup.anchorY * (count - 1)) + candidate.entry.item.y) / count;
    bestGroup.screenX = ((bestGroup.screenX * (count - 1)) + candidate.screenX) / count;
    bestGroup.screenY = ((bestGroup.screenY * (count - 1)) + candidate.screenY) / count;
  }

  for (const group of groups) {
    let maxWorldRadius = 0;
    for (const entry of group.entries) {
      const worldDx = entry.item.x - group.anchorX;
      const worldDy = entry.item.y - group.anchorY;
      maxWorldRadius = Math.max(maxWorldRadius, Math.hypot(worldDx, worldDy));
    }
    group.clusterRadiusWorld = maxWorldRadius;
    group.labelText = group.entries.length > 1 ? `${group.baseText} x${group.entries.length}` : group.baseText;
  }

  return {
    groups: groups.filter((group) => group.entries.length > 1),
    groupedEntries: new Set(groups.filter((group) => group.entries.length > 1).flatMap((group) => group.entries))
  };
}

function buildOrdinaryLootLabelGroups(entries) {
  if (!state.settings.mergeSameLootLabels) {
    return entries
      .filter((entry) => getRenderPriority(entry.kind, entry.item) === 1 && entry.label.showLabel)
      .map((entry) => ({
        labelKey: getLootGroupingKey(entry.item),
        entries: [entry],
        label: entry.label,
        markerColor: entry.markerColor,
        baseText: String(entry.item.label || "Unknown"),
        anchorX: entry.item.x,
        anchorY: entry.item.y,
        screenX: (entry.item.x * state.scale) + state.offsetX,
        screenY: (entry.item.y * state.scale) + state.offsetY,
        clusterRadiusScreen: 0,
        clusterRadiusWorld: 0,
        labelText: String(entry.item.label || "Unknown"),
        labelPlacement: null
      }));
  }

  const groups = [];
  const groupableEntries = entries
    .filter((entry) => getRenderPriority(entry.kind, entry.item) === 1 && entry.label.showLabel)
    .map((entry) => ({
      entry,
      labelKey: getLootGroupingKey(entry.item),
      screenX: (entry.item.x * state.scale) + state.offsetX,
      screenY: (entry.item.y * state.scale) + state.offsetY
    }))
    .sort((lhs, rhs) => {
      const byLabel = lhs.labelKey.localeCompare(rhs.labelKey);
      if (byLabel !== 0) {
        return byLabel;
      }
      if (lhs.screenY !== rhs.screenY) {
        return lhs.screenY - rhs.screenY;
      }
      return lhs.screenX - rhs.screenX;
    });

  const clusterDistance = Number(state.settings.sameLootMergeRadius || defaultSettings.sameLootMergeRadius);

  for (const candidate of groupableEntries) {
    let bestGroup = null;
    let bestDistance = Number.POSITIVE_INFINITY;

    for (const group of groups) {
      if (group.labelKey !== candidate.labelKey) {
        continue;
      }
      const dx = candidate.screenX - group.screenX;
      const dy = candidate.screenY - group.screenY;
      const distance = Math.hypot(dx, dy);
      if (distance <= clusterDistance && distance < bestDistance) {
        bestDistance = distance;
        bestGroup = group;
      }
    }

    if (!bestGroup) {
      groups.push({
        labelKey: candidate.labelKey,
        entries: [candidate.entry],
        label: candidate.entry.label,
        markerColor: candidate.entry.markerColor,
        baseText: String(candidate.entry.item.label || "Unknown"),
        anchorX: candidate.entry.item.x,
        anchorY: candidate.entry.item.y,
        screenX: candidate.screenX,
        screenY: candidate.screenY
      });
      continue;
    }

    bestGroup.entries.push(candidate.entry);
    const count = bestGroup.entries.length;
    bestGroup.anchorX = ((bestGroup.anchorX * (count - 1)) + candidate.entry.item.x) / count;
    bestGroup.anchorY = ((bestGroup.anchorY * (count - 1)) + candidate.entry.item.y) / count;
    bestGroup.screenX = ((bestGroup.screenX * (count - 1)) + candidate.screenX) / count;
    bestGroup.screenY = ((bestGroup.screenY * (count - 1)) + candidate.screenY) / count;
  }

  for (const group of groups) {
    let maxScreenRadius = 0;
    let maxWorldRadius = 0;
    for (const entry of group.entries) {
      const screenDx = ((entry.item.x * state.scale) + state.offsetX) - group.screenX;
      const screenDy = ((entry.item.y * state.scale) + state.offsetY) - group.screenY;
      maxScreenRadius = Math.max(maxScreenRadius, Math.hypot(screenDx, screenDy));

      const worldDx = entry.item.x - group.anchorX;
      const worldDy = entry.item.y - group.anchorY;
      maxWorldRadius = Math.max(maxWorldRadius, Math.hypot(worldDx, worldDy));
    }

    group.clusterRadiusScreen = maxScreenRadius;
    group.clusterRadiusWorld = maxWorldRadius;
    group.labelText = group.entries.length > 1 ? `${group.baseText} x${group.entries.length}` : group.baseText;
    group.labelPlacement = null;
  }

  return groups;
}

function buildOrdinaryGroupLabelCandidates(group, size) {
  const spreadMultiplier = Number(state.settings.ordinaryLootSpread || 1) * Number(state.settings.groupedLootSpread || 1);
  const radius = Math.max(8, group.clusterRadiusWorld + 8);
  const primaryGap = Math.round(14 * spreadMultiplier);
  const secondaryGap = Math.round(26 * spreadMultiplier);

  return [
    { left: radius + primaryGap, top: 2, anchorX: 2, anchorY: 0, distance: primaryGap },
    { left: radius + primaryGap, top: -size.height - 2, anchorX: 2, anchorY: size.height, distance: primaryGap },
    { left: -(size.width + radius + primaryGap), top: 2, anchorX: size.width - 2, anchorY: 0, distance: primaryGap },
    { left: -(size.width + radius + primaryGap), top: -size.height - 2, anchorX: size.width - 2, anchorY: size.height, distance: primaryGap },
    { left: radius + secondaryGap, top: secondaryGap, anchorX: 2, anchorY: 0, distance: secondaryGap + 6 },
    { left: radius + secondaryGap, top: -(size.height + secondaryGap), anchorX: 2, anchorY: size.height, distance: secondaryGap + 6 },
    { left: -(size.width + radius + secondaryGap), top: secondaryGap, anchorX: size.width - 2, anchorY: 0, distance: secondaryGap + 6 },
    { left: -(size.width + radius + secondaryGap), top: -(size.height + secondaryGap), anchorX: size.width - 2, anchorY: size.height, distance: secondaryGap + 6 }
  ];
}

function getLabelAnchorPoints(labelPlacement, labelSize) {
  const width = labelSize.width;
  const height = labelSize.height;
  const left = labelPlacement.left;
  const top = labelPlacement.top;
  const middleX = left + (width / 2);
  const middleY = top + (height / 2);
  const right = left + width;
  const bottom = top + height;

  return {
    "top-left": { x: left, y: top },
    "top-center": { x: middleX, y: top },
    "top-right": { x: right, y: top },
    "middle-left": { x: left, y: middleY },
    "middle-right": { x: right, y: middleY },
    "bottom-left": { x: left, y: bottom },
    "bottom-center": { x: middleX, y: bottom },
    "bottom-right": { x: right, y: bottom }
  };
}

function getLabelLineTarget(labelPlacement, labelSize, sourceX = 0, sourceY = 0) {
  const anchorPoints = getLabelAnchorPoints(labelPlacement, labelSize);
  if (state.settings.lineAnchorMode !== "auto-nearest") {
    return anchorPoints[state.settings.lineAnchorMode] || anchorPoints[defaultSettings.lineAnchorMode];
  }

  let bestPoint = anchorPoints["bottom-center"];
  let bestDistance = Number.POSITIVE_INFINITY;

  for (const point of Object.values(anchorPoints)) {
    const dx = point.x - sourceX;
    const dy = point.y - sourceY;
    const distance = (dx * dx) + (dy * dy);
    if (distance < bestDistance) {
      bestDistance = distance;
      bestPoint = point;
    }
  }

  return bestPoint;
}

function computeLabelPlacements(entries) {
  const viewportRect = {
    left: 0,
    top: 0,
    right: elements.viewport.clientWidth,
    bottom: elements.viewport.clientHeight
  };
  const occupiedRects = [...getBlockedViewportRects()];
  const priorityGrouping = buildPriorityLabelGroups(entries);

  for (const entry of entries) {
    entry.labelPlacement = null;
  }

  for (const entry of entries) {
    if (!entry.label.showLabel) {
      continue;
    }

    const priority = getRenderPriority(entry.kind, entry.item);
    const nearAnchor = priority >= 2;
    const size = getScaledLabelLayoutSize(entry.kind, entry.item, entry.labelText, entry.label.textSize);
    const screenX = (entry.item.x * state.scale) + state.offsetX;
    const screenY = (entry.item.y * state.scale) + state.offsetY;
    const useViewportEdgePlacement = entry.kind === "players" && isPointOutsideViewport(screenX, screenY, viewportRect);

    if (!nearAnchor) {
      continue;
    }

    if (priorityGrouping.groupedEntries.has(entry)) {
      continue;
    }

    const candidates = useViewportEdgePlacement
      ? buildViewportEdgeCandidates(screenX, screenY, size, viewportRect)
      : buildPinnedLabelCandidates(entry, size);
    let bestPlacement = null;
    let bestScore = Number.POSITIVE_INFINITY;

    for (const candidate of candidates) {
      const rect = buildScreenRect(screenX, screenY, candidate, size, state.scale);
      const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect);
      const score = penalty + (candidate.distance * (nearAnchor ? 16 : 8));
      if (score < bestScore) {
        bestScore = score;
        bestPlacement = { ...candidate, rect };
      }
    }

    if (!bestPlacement) {
      entry.labelPlacement = null;
      continue;
    }

    if (!nearAnchor && bestScore > 1400) {
      entry.labelPlacement = null;
      continue;
    }

    occupiedRects.push(bestPlacement.rect);
    entry.labelPlacement = {
      left: bestPlacement.left,
      top: bestPlacement.top,
      anchorX: bestPlacement.anchorX,
      anchorY: bestPlacement.anchorY
    };
  }

  for (const group of priorityGrouping.groups) {
    const size = getScaledLabelLayoutSize(group.kind, group, group.labelText, group.label.textSize);
    const groupEntry = group.entries[0];
    const candidates = buildPinnedLabelCandidates(groupEntry, size);
    let bestPlacement = null;
    let bestScore = Number.POSITIVE_INFINITY;

    for (const candidate of candidates) {
      const rect = buildScreenRect(group.screenX, group.screenY, candidate, size, state.scale);
      const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect);
      const score = penalty + (candidate.distance * 20);
      if (score < bestScore) {
        bestScore = score;
        bestPlacement = { ...candidate, rect };
      }
    }

    if (!bestPlacement) {
      group.labelPlacement = null;
      continue;
    }

    occupiedRects.push(bestPlacement.rect);
    group.labelPlacement = {
      left: bestPlacement.left,
      top: bestPlacement.top,
      anchorX: bestPlacement.anchorX,
      anchorY: bestPlacement.anchorY
    };
  }

  const groups = buildOrdinaryLootLabelGroups(entries);

  for (const group of groups) {
    const size = estimateLabelSize(group.labelText, group.label.textSize);
    const candidates = buildOrdinaryGroupLabelCandidates(group, size);
    let bestPlacement = null;
    let bestScore = Number.POSITIVE_INFINITY;

    for (const candidate of candidates) {
      const rect = buildScreenRect(group.screenX, group.screenY, candidate, size, state.scale);
      const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect);
      const score = penalty + (candidate.distance * 8);
      if (score < bestScore) {
        bestScore = score;
        bestPlacement = { ...candidate, rect };
      }
    }

    if (!bestPlacement) {
      group.labelPlacement = null;
      continue;
    }

    occupiedRects.push(bestPlacement.rect);
    group.labelPlacement = {
      left: bestPlacement.left,
      top: bestPlacement.top,
      anchorX: bestPlacement.anchorX,
      anchorY: bestPlacement.anchorY
    };
  }

  return {
    ordinaryGroups: groups,
    priorityGroups: priorityGrouping.groups
  };
}

function renderFilterCards() {
  const buildCard = (definition) => {
    const style = state.settings.filterStyles[definition.key];
    const visible = !!state.settings[definition.visibleKey];
    const expanded = !!state.settings.filterExpanded[definition.key];

    const card = document.createElement("section");
    card.className = `filter-card${expanded ? " expanded" : ""}`;
    const directionControl = definition.key === "players" ? `
      <label class="mini-control">
        <span>View line</span>
        <input class="range-direction" type="range" min="10" max="60" step="1" value="${style.directionLength || 28}">
        <strong class="value-direction">${style.directionLength || 28}px</strong>
      </label>` : "";
    const bulletControls = definition.key === "bullets" ? `
      <label class="toggle-row compact bullet-toggle-trajectory">
        <input type="checkbox" ${style.showTrajectory !== false ? "checked" : ""}>
        <span>Show trajectory</span>
      </label>
      <label class="toggle-row compact bullet-toggle-prediction">
        <input type="checkbox" ${style.showPrediction !== false ? "checked" : ""}>
        <span>Show prediction</span>
      </label>
      <label class="mini-control">
        <span>Phantom</span>
        <input class="range-phantom" type="range" min="0" max="60000" step="250" value="${style.phantomLifetimeMs || 5000}">
        <strong class="value-phantom">${style.phantomLifetimeMs || 5000}ms</strong>
      </label>
      <label class="mini-control">
        <span>Prediction</span>
        <input class="range-prediction" type="range" min="25" max="1000" step="25" value="${style.predictionDistance || 250}">
        <strong class="value-prediction">${style.predictionDistance || 250}m</strong>
      </label>` : "";
    const textSizeControl = definition.key !== "bullets" ? `
        <label class="mini-control">
          <span>Text size</span>
          <input class="range-text" type="range" min="10" max="24" step="1" value="${style.textSize}">
          <strong class="value-text">${style.textSize}px</strong>
        </label>` : "";
    const showLabelControl = definition.key !== "bullets" ? `
        <label class="toggle-row compact toggle-show-label">
          <input type="checkbox" ${style.showLabel ? "checked" : ""}>
          <span>Show label</span>
        </label>` : "";

    card.innerHTML = `
      <div class="filter-row">
        <label class="filter-main">
          <input type="checkbox" ${visible ? "checked" : ""}>
          <span class="filter-swatch" style="background:${style.color}"></span>
          <span class="filter-name">${definition.label}</span>
        </label>
        <button class="filter-gear" type="button" aria-label="Configure ${definition.label}">⚙</button>
      </div>
      <div class="filter-extra">
        <label class="mini-control">
          <span>Color</span>
          <input type="color" value="${style.color}">
        </label>
        <label class="mini-control">
          <span>Marker size</span>
          <input class="range-marker" type="range" min="4" max="26" step="1" value="${style.markerSize}">
          <strong class="value-marker">${style.markerSize}px</strong>
        </label>
        ${textSizeControl}
        ${directionControl}
        ${bulletControls}
        ${showLabelControl}
      </div>`;

    const visibleInput = card.querySelector(".filter-main input");
    const gearButton = card.querySelector(".filter-gear");
    const colorInput = card.querySelector('input[type="color"]');
    const markerRange = card.querySelector(".range-marker");
    const textRange = card.querySelector(".range-text");
    const labelInput = card.querySelector(".toggle-show-label input");
    const markerValue = card.querySelector(".value-marker");
    const textValue = card.querySelector(".value-text");
    const directionRange = card.querySelector(".range-direction");
    const directionValue = card.querySelector(".value-direction");
    const swatch = card.querySelector(".filter-swatch");
    const trajectoryInput = card.querySelector(".bullet-toggle-trajectory input");
    const predictionInput = card.querySelector(".bullet-toggle-prediction input");
    const phantomRange = card.querySelector(".range-phantom");
    const phantomValue = card.querySelector(".value-phantom");
    const predictionRange = card.querySelector(".range-prediction");
    const predictionValue = card.querySelector(".value-prediction");

    visibleInput.addEventListener("change", () => {
      state.settings[definition.visibleKey] = visibleInput.checked;
      persistSettings();
      render();
    });
    gearButton.addEventListener("click", () => {
      state.settings.filterExpanded[definition.key] = !state.settings.filterExpanded[definition.key];
      persistSettings();
      renderFilterCards();
    });
    colorInput.addEventListener("input", () => {
      state.settings.filterStyles[definition.key].color = colorInput.value;
      persistSettings();
      if (swatch) {
        swatch.style.background = colorInput.value;
      }
      render();
    });
    markerRange.addEventListener("input", () => {
      state.settings.filterStyles[definition.key].markerSize = Number(markerRange.value);
      markerValue.textContent = `${markerRange.value}px`;
      persistSettings();
      render();
    });
    if (textRange && textValue) {
      textRange.addEventListener("input", () => {
        state.settings.filterStyles[definition.key].textSize = Number(textRange.value);
        textValue.textContent = `${textRange.value}px`;
        persistSettings();
        render();
      });
    }
    if (labelInput) {
      labelInput.addEventListener("change", () => {
        state.settings.filterStyles[definition.key].showLabel = labelInput.checked;
        persistSettings();
        render();
      });
    }
    if (directionRange && directionValue) {
      directionRange.addEventListener("input", () => {
        state.settings.filterStyles[definition.key].directionLength = Number(directionRange.value);
        directionValue.textContent = `${directionRange.value}px`;
        persistSettings();
        render();
      });
    }
    if (trajectoryInput) {
      trajectoryInput.addEventListener("change", () => {
        state.settings.filterStyles[definition.key].showTrajectory = trajectoryInput.checked;
        persistSettings();
        render();
      });
    }
    if (predictionInput) {
      predictionInput.addEventListener("change", () => {
        state.settings.filterStyles[definition.key].showPrediction = predictionInput.checked;
        syncBulletVisuals(state.lastSnapshot?.bullets || []);
        ensureBulletAnimation();
        persistSettings();
        render();
      });
    }
    if (phantomRange && phantomValue) {
      phantomRange.addEventListener("input", () => {
        state.settings.filterStyles[definition.key].phantomLifetimeMs = Number(phantomRange.value);
        phantomValue.textContent = `${phantomRange.value}ms`;
        persistSettings();
        render();
      });
    }
    if (predictionRange && predictionValue) {
      predictionRange.addEventListener("input", () => {
        state.settings.filterStyles[definition.key].predictionDistance = Number(predictionRange.value);
        predictionValue.textContent = `${predictionRange.value}m`;
        syncBulletVisuals(state.lastSnapshot?.bullets || []);
        ensureBulletAnimation();
        persistSettings();
        render();
      });
    }
    return card;
  };

  elements.entityFilterList.replaceChildren(...filterDefinitions.filter((definition) => definition.kind !== "loot").map(buildCard));
  elements.lootFilterList.replaceChildren(...filterDefinitions.filter((definition) => definition.kind === "loot").map(buildCard));
}

function renderPlayersList() {
  if (!state.lastSnapshot) {
    return;
  }

  const players = Array.isArray(state.lastSnapshot.playerList) ? state.lastSnapshot.playerList.slice() : [];
  const visibleOnMapByFilters = (player) => {
    if (!player.visibleOnMap) {
      return false;
    }
    if (!state.settings.showPlayers) {
      return false;
    }
    if (state.settings.distanceFilter > 0 && player.distance >= 0 && player.distance > state.settings.distanceFilter) {
      return false;
    }
    return true;
  };

  const visiblePlayers = players.filter((player) => player.visibleOnMap && player.distance >= 0);
  const maxDistance = visiblePlayers.reduce((value, player) => Math.max(value, Number(player.distance || 0)), 1);
  const sortVisiblePlayers = (lhs, rhs) => {
    if ((lhs.distance || 0) !== (rhs.distance || 0)) {
      return (lhs.distance || 0) - (rhs.distance || 0);
    }
    return String(lhs.steamId || "").localeCompare(String(rhs.steamId || ""));
  };
  const sortHiddenPlayers = (lhs, rhs) => {
    const byLabel = String(lhs.label || "").localeCompare(String(rhs.label || ""), undefined, { sensitivity: "base" });
    if (byLabel !== 0) {
      return byLabel;
    }
    return String(lhs.steamId || "").localeCompare(String(rhs.steamId || ""));
  };
  const renderPlayerRow = (player) => {
    const normalized = player.visibleOnMap && player.distance >= 0
      ? Math.max(0, Math.min(1, 1 - ((player.distance || 0) / maxDistance)))
      : 0;
    const red = player.visibleOnMap ? 170 + Math.round(85 * normalized) : 180;
    const greenBlue = player.visibleOnMap ? 35 + Math.round(60 * (1 - normalized)) : 140;
    const nameColor = `rgb(${red},${greenBlue},${greenBlue})`;
    const steamId = player.steamId && String(player.steamId).trim().length > 0 ? String(player.steamId) : "unknown";
    const steamUrl = steamId !== "unknown" ? `https://steamcommunity.com/profiles/${encodeURIComponent(steamId)}` : "";
    return `
      <div class="players-row">
        <div class="players-name" style="color:${nameColor}">${renderSafeMultilineHtml(getEntityLabelLines(player))}</div>
        <div class="players-steam">${steamUrl ? `<a href="${steamUrl}" target="_blank" rel="noopener noreferrer">${sanitize(steamId)}</a>` : sanitize(steamId)}</div>
      </div>`;
  };

  const visibleRows = players.filter(visibleOnMapByFilters).sort(sortVisiblePlayers).map(renderPlayerRow).join("");
  const hiddenRows = players.filter((player) => !visibleOnMapByFilters(player)).sort(sortHiddenPlayers).map(renderPlayerRow).join("");

  let rows = "";
  if (visibleRows.length > 0) {
    rows += '<div class="loot-section-title">Visible On Map</div>';
    rows += visibleRows;
  }
  if (hiddenRows.length > 0) {
    rows += `${rows.length > 0 ? '<div class="loot-section-title">Hidden / Off Map</div>' : '<div class="loot-section-title">Players</div>'}`;
    rows += hiddenRows;
  }

  elements.playersList.innerHTML = rows || '<div class="players-empty">No players</div>';
}

function renderLootList() {
  if (!state.lastSnapshot) {
    elements.lootList.innerHTML = '<div class="players-empty">No loot</div>';
    elements.lootInfo.textContent = "Loot: waiting for snapshot...";
    return;
  }

  const categoryOrder = new Map(filterDefinitions.filter((definition) => definition.kind === "loot").map((definition, index) => [definition.category || "other", index]));
  const sortLootItems = (lhs, rhs) => {
    const lhsCategory = String(lhs.lootCategory || "other");
    const rhsCategory = String(rhs.lootCategory || "other");
    const byCategory = (categoryOrder.get(lhsCategory) ?? 999) - (categoryOrder.get(rhsCategory) ?? 999);
    if (byCategory !== 0) {
      return byCategory;
    }
    if ((lhs.distance || 0) !== (rhs.distance || 0)) {
      return (lhs.distance || 0) - (rhs.distance || 0);
    }
    const byLabel = String(lhs.label || "").localeCompare(String(rhs.label || ""));
    if (byLabel !== 0) {
      return byLabel;
    }
    return String(lhs.id || "").localeCompare(String(rhs.id || ""));
  };

  const lootItems = getVisibleCollection("loot", state.lastSnapshot.loot || []).slice().sort(sortLootItems);

  const favoriteItems = lootItems.filter((item) => isFavoriteLoot(item));
  const regularItems = lootItems.filter((item) => !isFavoriteLoot(item));

  elements.lootInfo.textContent = `Loot: ${lootItems.length} visible item${lootItems.length === 1 ? "" : "s"} (filters respected)`;

  const renderRow = (item) => {
    const favorite = isFavoriteLoot(item);
    const color = getMarkerColor("loot", item);
    const categoryLabel = getLootCategoryDefinition(item)?.label || "Other";
    return `
      <button class="loot-row${favorite ? " favorite" : ""}" type="button" data-loot-name="${sanitize(item.label || "")}" style="--loot-color:${color}">
        <span class="loot-name">${sanitize(item.label || "Unknown")}</span>
        <span class="loot-distance">${Math.round(Number(item.distance || 0))}m</span>
        <span class="loot-meta">${sanitize(categoryLabel)}${favorite ? " • favorite" : ""}</span>
      </button>`;
  };

  let rows = "";
  if (favoriteItems.length > 0) {
    let currentCategory = "";
    rows += '<div class="loot-section-title">Favorites</div>';
    for (const item of favoriteItems) {
      const category = String(item.lootCategory || "other");
      if (category !== currentCategory) {
        currentCategory = category;
        rows += `<div class="loot-group-title">${sanitize(getLootCategoryDefinition(item)?.label || "Other")}</div>`;
      }
      rows += renderRow(item);
    }
  }
  if (regularItems.length > 0) {
    let currentCategory = "";
    if (rows.length > 0) {
      rows += '<div class="loot-section-title">Visible Loot</div>';
    }
    for (const item of regularItems) {
      const category = String(item.lootCategory || "other");
      if (category !== currentCategory) {
        currentCategory = category;
        rows += `<div class="loot-group-title">${sanitize(getLootCategoryDefinition(item)?.label || "Other")}</div>`;
      }
      rows += renderRow(item);
    }
  }
  elements.lootList.innerHTML = rows || '<div class="players-empty">No loot matches current filters</div>';
}

function createMarker(entry) {
  const { kind, item, markerColor, labelText, label } = entry;
  const filterStyle = label.filterStyle;
  const markerSize = filterStyle ? filterStyle.markerSize : (kind === "vehicles" ? 14 : 12);
  const borderSize = Math.max(1, Math.round(markerSize * 0.16));
  const favorite = kind === "loot" && isFavoriteLoot(item);
  const labelPlacement = entry.labelPlacement;

  const marker = document.createElement("div");
  marker.className = `entity ${getLayerClass(kind, item)}${kind === "players" ? " player-entity" : ""}${kind === "bullets" && item.isPhantom ? " bullet-phantom" : ""}${kind === "bullets" && item.isCompleted ? " bullet-completed" : ""}`;
  marker.style.left = `${item.x}px`;
  marker.style.top = `${item.y}px`;

  const glyph = document.createElement("div");
  glyph.className = `marker ${kind}${favorite ? " favorite" : ""}`;
  glyph.style.width = `${markerSize}px`;
  glyph.style.height = `${markerSize}px`;
  glyph.style.background = markerColor;
  glyph.style.borderWidth = `${borderSize}px`;
  glyph.style.color = markerColor;

  if (kind === "players") {
    const direction = document.createElement("div");
    direction.className = "direction entity-direction";
    direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
    direction.style.color = markerColor;
    direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28))}px`;
    marker.appendChild(direction);
  }

  marker.appendChild(glyph);

  if (label.showLabel && labelPlacement && getRenderPriority(kind, item) >= 2) {
    const labelNode = document.createElement("span");
    labelNode.className = `label${favorite ? " favorite" : ""}`;
    labelNode.textContent = labelText;
    labelNode.style.fontSize = `${label.textSize}px`;
    labelNode.style.color = markerColor;
    labelNode.style.left = `${labelPlacement.left}px`;
    labelNode.style.top = `${labelPlacement.top}px`;
    if (useFixedScreenLabel(kind, item)) {
      const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
      labelNode.style.transformOrigin = "0 0";
      labelNode.style.transform = `scale(${1 / effectiveScale})`;
    }
    marker.appendChild(labelNode);
  }

  return marker;
}

function createPriorityLabelGroup(group) {
  const labelPlacement = group.labelPlacement;
  if (!labelPlacement) {
    return null;
  }

  const marker = document.createElement("div");
  marker.className = `entity ${group.priority >= 3 ? "layer-entity" : "layer-favorite"}`;
  marker.style.left = `${group.anchorX}px`;
  marker.style.top = `${group.anchorY}px`;

  const labelNode = document.createElement("span");
  labelNode.className = `label${group.priority === 2 ? " favorite" : ""}`;
  labelNode.textContent = group.labelText;
  labelNode.style.fontSize = `${group.label.textSize}px`;
  labelNode.style.color = group.markerColor;
  labelNode.style.left = `${labelPlacement.left}px`;
  labelNode.style.top = `${labelPlacement.top}px`;
  if (useFixedScreenLabel(group.kind, group)) {
    const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
    labelNode.style.transformOrigin = "0 0";
    labelNode.style.transform = `scale(${1 / effectiveScale})`;
  }
  marker.appendChild(labelNode);

  const labelSize = getScaledLabelLayoutSize(group.kind, group, group.labelText, group.label.textSize);
  for (const entry of group.entries) {
    const labelLine = document.createElement("span");
    labelLine.className = `label-line${group.priority === 2 ? " favorite" : ""}`;
    const itemDx = entry.item.x - group.anchorX;
    const itemDy = entry.item.y - group.anchorY;
    const lineTarget = getLabelLineTarget(labelPlacement, labelSize, itemDx, itemDy);
    const startX = itemDx;
    const startY = itemDy;
    const drawX = lineTarget.x - startX;
    const drawY = lineTarget.y - startY;
    const angle = Math.atan2(drawY, drawX) * (180 / Math.PI);
    const length = Math.hypot(drawX, drawY);
    labelLine.style.left = `${startX}px`;
    labelLine.style.top = `${startY}px`;
    labelLine.style.width = `${Math.max(4, length)}px`;
    labelLine.style.transform = `rotate(${angle}deg)`;
    labelLine.style.color = group.markerColor;
    marker.appendChild(labelLine);
  }

  return marker;
}

function createLootLabelGroup(group) {
  const labelPlacement = group.labelPlacement;
  if (!labelPlacement) {
    return null;
  }
  const labelSize = estimateLabelSize(group.labelText, group.label.textSize);

  const marker = document.createElement("div");
  marker.className = "item-label-anchor";
  marker.style.left = `${group.anchorX}px`;
  marker.style.top = `${group.anchorY}px`;

  const labelNode = document.createElement("span");
  labelNode.className = "label loot-label";
  labelNode.textContent = group.labelText;
  labelNode.style.fontSize = `${group.label.textSize}px`;
  labelNode.style.color = group.markerColor;
  labelNode.style.left = `${labelPlacement.left}px`;
  labelNode.style.top = `${labelPlacement.top}px`;
  marker.appendChild(labelNode);

  for (const entry of group.entries) {
    const labelLine = document.createElement("span");
    labelLine.className = "label-line loot-line";
    const itemDx = entry.item.x - group.anchorX;
    const itemDy = entry.item.y - group.anchorY;
    const lineTarget = getLabelLineTarget(labelPlacement, labelSize, itemDx, itemDy);
    const startX = itemDx;
    const startY = itemDy;
    const drawX = lineTarget.x - startX;
    const drawY = lineTarget.y - startY;
    const angle = Math.atan2(drawY, drawX) * (180 / Math.PI);
    const length = Math.hypot(drawX, drawY);
    labelLine.style.left = `${startX}px`;
    labelLine.style.top = `${startY}px`;
    labelLine.style.width = `${Math.max(4, length)}px`;
    labelLine.style.transform = `rotate(${angle}deg)`;
    labelLine.style.color = group.markerColor;
    marker.appendChild(labelLine);
  }

  return marker;
}

function buildRenderEntries() {
  const collections = [
    { kind: "players", items: getVisibleCollection("players", state.lastSnapshot?.players || []) },
    { kind: "vehicles", items: getVisibleCollection("vehicles", state.lastSnapshot?.vehicles || []) },
    { kind: "zombies", items: getVisibleCollection("zombies", state.lastSnapshot?.zombies || []) },
    { kind: "animals", items: getVisibleCollection("animals", state.lastSnapshot?.animals || []) },
    { kind: "bullets", items: getVisibleCollection("bullets", state.lastSnapshot?.bullets || []) },
    { kind: "otherEntities", items: getVisibleCollection("otherEntities", state.lastSnapshot?.otherEntities || []) },
    { kind: "loot", items: getVisibleCollection("loot", state.lastSnapshot?.loot || []) }
  ];

  const entries = [];
  for (const collection of collections) {
    for (const item of collection.items) {
      entries.push({
        kind: collection.kind,
        item,
        markerColor: getMarkerColor(collection.kind, item),
        labelText: buildLabelText(item),
        label: getLabelConfig(collection.kind, item)
      });
    }
  }
  entries.sort((lhs, rhs) => {
    const priorityDiff = getRenderPriority(rhs.kind, rhs.item) - getRenderPriority(lhs.kind, lhs.item);
    if (priorityDiff !== 0) {
      return priorityDiff;
    }
    return Number(lhs.item?.distance || 0) - Number(rhs.item?.distance || 0);
  });
  return entries;
}

function createSvgNode(tagName) {
  return document.createElementNS("http://www.w3.org/2000/svg", tagName);
}

function getBulletFilterStyle() {
  return state.settings.filterStyles?.bullets || {};
}

function syncBulletVisuals(bullets) {
  const now = performance.now();
  const visibleIds = new Set();
  const bulletStyle = getBulletFilterStyle();
  const predictionLimit = bulletStyle.showPrediction === false ? 0 : Number(bulletStyle.predictionDistance || 250);

  for (const bullet of bullets || []) {
    visibleIds.add(bullet.id);
    const visual = state.bulletVisuals.get(bullet.id) || { revealedDistance: 0, lastTick: now, targetDistance: 0, speed: 0 };
    visual.lastTick = now;
    visual.speed = Math.max(30, Number(bullet.predictionSpeed || 0));
    visual.targetDistance = bullet.isCompleted ? 0 : predictionLimit;
    if (visual.revealedDistance > visual.targetDistance) {
      visual.revealedDistance = visual.targetDistance;
    }
    state.bulletVisuals.set(bullet.id, visual);
  }

  for (const id of Array.from(state.bulletVisuals.keys())) {
    if (!visibleIds.has(id)) {
      state.bulletVisuals.delete(id);
    }
  }
}

function advanceBulletVisuals(now) {
  let needsFrame = false;
  let changed = false;

  for (const visual of state.bulletVisuals.values()) {
    const elapsedSeconds = Math.max(0, (now - visual.lastTick) / 1000);
    visual.lastTick = now;
    if (visual.revealedDistance < visual.targetDistance) {
      const nextDistance = Math.min(visual.targetDistance, visual.revealedDistance + (visual.speed * elapsedSeconds));
      if (nextDistance !== visual.revealedDistance) {
        visual.revealedDistance = nextDistance;
        changed = true;
      }
    }
    if (visual.revealedDistance + 0.5 < visual.targetDistance) {
      needsFrame = true;
    }
  }

  return { needsFrame, changed };
}

function ensureBulletAnimation() {
  if (state.bulletAnimationFrame) {
    return;
  }

  const hasPendingPrediction = Array.from(state.bulletVisuals.values()).some((visual) => visual.revealedDistance + 0.5 < visual.targetDistance);
  if (!hasPendingPrediction) {
    return;
  }

  const tick = (now) => {
    state.bulletAnimationFrame = null;
    const { needsFrame, changed } = advanceBulletVisuals(now);
    if (changed) {
      render();
    }
    if (needsFrame) {
      state.bulletAnimationFrame = window.requestAnimationFrame(tick);
    }
  };

  state.bulletAnimationFrame = window.requestAnimationFrame(tick);
}

function buildClippedLine(points, maxDistance) {
  if (!Array.isArray(points) || points.length < 2 || maxDistance <= 0) {
    return [];
  }

  const clipped = [points[0]];
  let remaining = maxDistance;
  for (let index = 1; index < points.length; index += 1) {
    const previous = points[index - 1];
    const current = points[index];
    const dx = Number(current.x || 0) - Number(previous.x || 0);
    const dy = Number(current.y || 0) - Number(previous.y || 0);
    const segmentLength = Math.hypot(dx, dy);
    if (segmentLength <= 0) {
      continue;
    }
    if (remaining >= segmentLength) {
      clipped.push(current);
      remaining -= segmentLength;
      continue;
    }
    const ratio = remaining / segmentLength;
    clipped.push({ x: Number(previous.x || 0) + (dx * ratio), y: Number(previous.y || 0) + (dy * ratio) });
    break;
  }

  return clipped;
}

function buildBulletPathNodes(bullets) {
  const style = getBulletFilterStyle();
  if (style.showTrajectory === false && style.showPrediction === false) {
    return [];
  }

  const nodes = [];
  for (const bullet of bullets || []) {
    const color = getMarkerColor("bullets", bullet);

    if (style.showTrajectory !== false && Array.isArray(bullet.path) && bullet.path.length >= 2) {
      const actual = createSvgNode("polyline");
      actual.setAttribute("class", `bullet-path-actual${bullet.isPhantom ? " bullet-path-phantom" : ""}`);
      actual.setAttribute("stroke", color);
      actual.setAttribute("points", bullet.path.map((point) => `${point.x},${point.y}`).join(" "));
      nodes.push(actual);
    }

    if (style.showPrediction !== false && !bullet.isCompleted && Array.isArray(bullet.predictedPath) && bullet.predictedPath.length >= 2) {
      const visual = state.bulletVisuals.get(bullet.id);
      const clippedPoints = buildClippedLine(bullet.predictedPath, Number(visual?.revealedDistance || 0));
      if (clippedPoints.length >= 2) {
        const predicted = createSvgNode("polyline");
        predicted.setAttribute("class", `bullet-path-predicted${bullet.isPhantom ? " bullet-path-phantom" : ""}`);
        predicted.setAttribute("stroke", color);
        predicted.setAttribute("points", clippedPoints.map((point) => `${point.x},${point.y}`).join(" "));
        nodes.push(predicted);
      }
    }
  }

  return nodes;
}

function updateVisibleTiles() {
  if (!state.bootstrap) {
    return;
  }

  const rect = elements.viewport.getBoundingClientRect();
  const left = Math.max(0, Math.floor((-state.offsetX) / state.scale));
  const top = Math.max(0, Math.floor((-state.offsetY) / state.scale));
  const right = Math.min(state.bootstrap.mapSize, Math.ceil((rect.width - state.offsetX) / state.scale));
  const bottom = Math.min(state.bootstrap.mapSize, Math.ceil((rect.height - state.offsetY) / state.scale));

  const tileSize = state.bootstrap.tileSize;
  const preloadMargin = 2;
  const keepMargin = 4;
  const minTileX = Math.max(0, Math.floor(left / tileSize) - preloadMargin);
  const minTileY = Math.max(0, Math.floor(top / tileSize) - preloadMargin);
  const maxTileX = Math.min(state.bootstrap.tileCountX - 1, Math.floor(right / tileSize) + preloadMargin);
  const maxTileY = Math.min(state.bootstrap.tileCountY - 1, Math.floor(bottom / tileSize) + preloadMargin);

  const ensureTile = (tileX, tileY, key) => {
    let img = elements.tiles.querySelector(`[data-key="${key}"]`);
    if (!img) {
      img = document.createElement("img");
      img.className = "map-tile loading";
      img.dataset.key = key;
      img.dataset.x = String(tileX);
      img.dataset.y = String(tileY);
      img.dataset.retry = "0";
      img.draggable = false;
      img.style.left = `${tileX * tileSize}px`;
      img.style.top = `${tileY * tileSize}px`;
      img.style.width = `${tileSize}px`;
      img.style.height = `${tileSize}px`;

      const loadTile = (retryCount) => {
        img.dataset.retry = String(retryCount);
        img.classList.remove("failed", "loaded");
        img.classList.add("loading");
        img.src = tileUrl(tileX, tileY, retryCount);
      };

      img.addEventListener("load", () => {
        state.tileState[key] = "loaded";
        img.classList.remove("loading", "failed");
        img.classList.add("loaded");
      });

      img.addEventListener("error", () => {
        const nextRetry = Number(img.dataset.retry || "0") + 1;
        state.tileState[key] = "failed";
        img.classList.remove("loading");
        img.classList.add("failed");
        if (nextRetry <= 4) {
          window.setTimeout(() => {
            if (!document.body.contains(img)) {
              return;
            }
            loadTile(nextRetry);
          }, 300 * nextRetry);
        }
      });

      loadTile(0);
      elements.tiles.appendChild(img);
      return;
    }

    if (img.classList.contains("failed")) {
      const nextRetry = Number(img.dataset.retry || "0") + 1;
      if (nextRetry <= 4) {
        img.dataset.retry = String(nextRetry);
        img.classList.remove("failed");
        img.classList.add("loading");
        img.src = tileUrl(tileX, tileY, nextRetry);
      }
    }
  };

  for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
    for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
      const key = `${tileX}:${tileY}`;
      ensureTile(tileX, tileY, key);
    }
  }

  for (const node of Array.from(elements.tiles.querySelectorAll(".map-tile"))) {
    const tileX = Number(node.dataset.x || "0");
    const tileY = Number(node.dataset.y || "0");
    const keep = tileX >= Math.max(0, minTileX - keepMargin)
      && tileX <= Math.min(state.bootstrap.tileCountX - 1, maxTileX + keepMargin)
      && tileY >= Math.max(0, minTileY - keepMargin)
      && tileY <= Math.min(state.bootstrap.tileCountY - 1, maxTileY + keepMargin);
    if (!keep) {
      delete state.tileState[node.dataset.key];
      node.remove();
    }
  }
}

function render() {
  if (!state.lastSnapshot || !state.bootstrap) {
    return;
  }

  if (state.settings.followPlayer && state.lastSnapshot.hasLocalPlayer && state.lastSnapshot.localPlayer) {
    centerOn(state.lastSnapshot.localPlayer);
  }

  const markerNodes = [];
  const itemLabelNodes = [];
  const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", state.lastSnapshot?.bullets || []));

  const localPlayer = state.lastSnapshot.hasLocalPlayer ? state.lastSnapshot.localPlayer : null;
  if (localPlayer) {
    const marker = document.createElement("div");
    marker.className = "entity local-player-entity";
    marker.style.left = `${localPlayer.x}px`;
    marker.style.top = `${localPlayer.y}px`;

    const glyph = document.createElement("div");
    glyph.className = "marker local-player";
    const direction = document.createElement("div");
    direction.className = "direction entity-direction local-direction";
    direction.style.transform = `translate(-50%, -100%) rotate(${180 - (localPlayer.cameraRotation || localPlayer.rotation || 0)}deg)`;
    direction.style.height = `${Math.max(10, Number(state.settings.filterStyles?.players?.directionLength || 28))}px`;
    marker.appendChild(direction);
    marker.appendChild(glyph);
    markerNodes.push(marker);
  }

  const entries = buildRenderEntries();
  const labelGroups = computeLabelPlacements(entries);

  for (const entry of entries) {
    markerNodes.push(createMarker(entry));
  }

  for (const group of labelGroups.priorityGroups || []) {
    const markerNode = createPriorityLabelGroup(group);
    if (markerNode) {
      markerNodes.push(markerNode);
    }
  }

  for (const group of labelGroups.ordinaryGroups || []) {
    const itemLabelNode = createLootLabelGroup(group);
    if (itemLabelNode) {
      itemLabelNodes.push(itemLabelNode);
    }
  }

  elements.itemLabels.replaceChildren(...itemLabelNodes);
  elements.paths.replaceChildren(...pathNodes);
  elements.markers.replaceChildren(...markerNodes);

  if (elements.playersPanel.classList.contains("open")) {
    renderPlayersList();
  }

  if (elements.lootPanel.classList.contains("open")) {
    renderLootList();
  }
}

function stopFallbackPolling() {
  if (state.fallbackPollTimer) {
    window.clearInterval(state.fallbackPollTimer);
    state.fallbackPollTimer = null;
  }
}

function startFallbackPolling() {
  if (state.fallbackPollTimer) {
    return;
  }

  const poll = () => {
    fetch(apiUrl("/api/state"), { cache: "no-store" })
      .then((response) => {
        if (!response.ok) {
          throw new Error(response.status === 401 ? "Unauthorized" : `HTTP ${response.status}`);
        }
        return response.json();
      })
      .then((snapshot) => applySnapshot(snapshot))
      .catch((error) => {
        elements.status.textContent = error?.message || String(error);
      });
  };

  poll();
  state.fallbackPollTimer = window.setInterval(poll, 3000);
}

function clearReconnectTimer() {
  if (state.reconnectTimer) {
    window.clearTimeout(state.reconnectTimer);
    state.reconnectTimer = null;
  }
}

function scheduleReconnect() {
  if (state.reconnectTimer) {
    return;
  }
  state.reconnectTimer = window.setTimeout(() => {
    state.reconnectTimer = null;
    connectEvents();
  }, 3000);
}

function applySnapshot(snapshot) {
  const previousSnapshot = state.lastSnapshot;
  state.lastSnapshot = snapshot;
  const mapChanged = applyMapMetadata({
    mapId: snapshot.mapId,
    mapName: snapshot.mapName,
    serverMapName: snapshot.serverMapName,
    mapSize: snapshot.mapSize,
    tileSize: snapshot.tileSize,
    tileCountX: snapshot.tileCountX,
    tileCountY: snapshot.tileCountY
  });
  if (snapshot.mapId) clearMapNotice();
  if (mapChanged) {
    state.initialCentered = false;
  }
  syncBulletVisuals(snapshot?.bullets || []);
  ensureBulletAnimation();

  const snapshotChanged = !previousSnapshot
    || previousSnapshot.cacheRefreshSequence !== snapshot.cacheRefreshSequence
    || previousSnapshot.fastCacheRefreshSequence !== snapshot.fastCacheRefreshSequence
    || previousSnapshot.slowCacheRefreshSequence !== snapshot.slowCacheRefreshSequence
    || previousSnapshot.status !== snapshot.status
    || previousSnapshot.serverName !== snapshot.serverName
    || previousSnapshot.mapName !== snapshot.mapName
    || previousSnapshot.serverMapName !== snapshot.serverMapName
    || previousSnapshot.gameVersion !== snapshot.gameVersion
    || previousSnapshot.hasLocalPlayer !== snapshot.hasLocalPlayer
    || mapChanged;

  if (!state.initialCentered && snapshot.hasLocalPlayer && snapshot.localPlayer) {
    centerOn(snapshot.localPlayer);
    state.initialCentered = true;
  }

  const serverTitle = snapshot.serverName && snapshot.serverName.length > 0 ? snapshot.serverName : "Unavailable";
  const versionText = snapshot.gameVersion && snapshot.gameVersion.length > 0 ? snapshot.gameVersion : "n/a";
  const mapText = snapshot.serverMapName && snapshot.serverMapName.length > 0 ? snapshot.serverMapName : snapshot.mapName;

  elements.serverInfo.textContent = `Server: ${serverTitle} | Map: ${mapText} | Version: ${versionText}`;
  elements.serverBadge.textContent = `Server: ${serverTitle}`;
  elements.status.textContent = snapshot.hasLocalPlayer ? `Live: ${snapshot.mapName}` : "Waiting for local player...";

  if (!snapshotChanged) {
    return;
  }

  render();
}

function showMapNotice(msg) {
  const existing = document.getElementById("map-notice");
  if (existing) existing.remove();
  const el = document.createElement("div");
  el.id = "map-notice";
  el.style.cssText = [
    "position:fixed", "top:0", "left:0", "right:0",
    "background:#b91c1c", "color:#fff",
    "padding:10px 16px", "font-size:13px",
    "z-index:9999", "text-align:center",
    "cursor:pointer"
  ].join(";");
  el.textContent = msg + "  (click to dismiss)";
  el.addEventListener("click", () => el.remove());
  document.body.prepend(el);
}

function clearMapNotice() {
  const el = document.getElementById("map-notice");
  if (el) el.remove();
}

async function bootstrap() {
  const response = await fetch(apiUrl("/api/bootstrap"), { cache: "no-store" });
  if (!response.ok) {
    throw new Error(response.status === 401 ? "Unauthorized" : `HTTP ${response.status}`);
  }
  const bootstrapData = await response.json();
  applyMapMetadata(bootstrapData, true);

  // Surface missing-map-image problems immediately with a visible red banner.
  const mapsWithImages = (bootstrapData.maps || []).filter(m => m.hasImage);
  if (!bootstrapData.mapId) {
    if (mapsWithImages.length === 0) {
      showMapNotice(
        "No map PNG found — place a PNG named after a map ID next to the exe in a maps/ folder " +
        "(e.g. maps/chernarusplus.png). Open /api/debug for the exact expected path."
      );
    } else {
      showMapNotice(
        "Map PNGs exist (" + mapsWithImages.map(m => m.id).join(", ") + ") but server returned no mapId — " +
        "open /api/debug for details."
      );
    }
  }

  filterDefinitions = [...entityFilterDefinitions, ...buildLootFilterDefinitions((state.bootstrap.filters || []).filter((definition) => definition.kind === "loot"))];
  rebuildFilterCaches();
  ensureFilterSettings();
  renderFilterCards();
  centerOn({ x: state.bootstrap.mapSize / 2, y: state.bootstrap.mapSize / 2 });
  setSettingsCollapsed(!!state.settings.settingsCollapsed);
  syncLabelSettingsUi();
}

function connectEvents() {
  if (typeof window.EventSource !== "function") {
    startFallbackPolling();
    return;
  }

  clearReconnectTimer();

  if (state.eventSource) {
    state.eventSource.close();
  }

  const source = new EventSource(apiUrl("/events"));
  state.eventSource = source;

  source.addEventListener("state", (event) => {
    stopFallbackPolling();
    applySnapshot(JSON.parse(event.data));
  });

  source.onerror = () => {
    if (state.eventSource === source) {
      state.eventSource.close();
      state.eventSource = null;
    }
    elements.status.textContent = "Connection lost";
    startFallbackPolling();
    scheduleReconnect();
  };
}

function bindUi() {
  for (const [key, element] of Object.entries({
    followPlayer: elements.followPlayer,
    showLoot: elements.showLoot,
    showLabels: elements.showLabels
  })) {
    element.checked = !!state.settings[key];
    element.addEventListener("change", () => {
      state.settings[key] = element.checked;
      persistSettings();
      render();
    });
  }

  elements.distanceFilter.value = String(state.settings.distanceFilter);
  elements.distanceValue.textContent = `${state.settings.distanceFilter} m`;
  elements.distanceFilter.addEventListener("input", () => {
    state.settings.distanceFilter = Number(elements.distanceFilter.value);
    elements.distanceValue.textContent = `${state.settings.distanceFilter} m`;
    persistSettings();
    render();
  });

  elements.textSize.value = String(state.settings.textSize);
  elements.textSizeValue.textContent = `${state.settings.textSize} px`;
  elements.textSize.addEventListener("input", () => {
    state.settings.textSize = Number(elements.textSize.value);
    elements.textSizeValue.textContent = `${state.settings.textSize} px`;
    persistSettings();
    render();
  });

  syncLabelSettingsUi();
  elements.labelSettingsToggle.addEventListener("click", () => setLabelSettingsOpen(!elements.labelSettingsPanel.classList.contains("open")));
  elements.labelSettingsClose.addEventListener("click", () => setLabelSettingsOpen(false));
  elements.labelSettingsReset.addEventListener("click", () => {
    resetLabelSettings();
    ensureFilterSettings();
    syncLabelSettingsUi();
    persistSettings();
    render();
  });

  elements.ordinaryLootSpread.addEventListener("input", () => {
    state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value);
    syncLabelSettingsUi();
    persistSettings();
    render();
  });

  elements.groupedLootSpread.addEventListener("input", () => {
    state.settings.groupedLootSpread = Number(elements.groupedLootSpread.value);
    syncLabelSettingsUi();
    persistSettings();
    render();
  });

  elements.sameLootMergeRadius.addEventListener("input", () => {
    state.settings.sameLootMergeRadius = Number(elements.sameLootMergeRadius.value);
    syncLabelSettingsUi();
    persistSettings();
    render();
  });

  elements.lineAnchorMode.addEventListener("change", () => {
    state.settings.lineAnchorMode = elements.lineAnchorMode.value;
    syncLabelSettingsUi();
    persistSettings();
    render();
  });

  elements.mergeSameLootLabels.addEventListener("change", () => {
    state.settings.mergeSameLootLabels = elements.mergeSameLootLabels.checked;
    syncLabelSettingsUi();
    persistSettings();
    render();
  });

  elements.settingsToggle.addEventListener("click", () => setSettingsCollapsed(false));
  elements.collapseButton.addEventListener("click", () => setSettingsCollapsed(!state.settings.settingsCollapsed));
  elements.playersToggle.addEventListener("click", () => setPanelOpen(elements.playersPanel, !elements.playersPanel.classList.contains("open")));
  elements.playersClose.addEventListener("click", () => setPanelOpen(elements.playersPanel, false));
  elements.lootToggle.addEventListener("click", () => setPanelOpen(elements.lootPanel, !elements.lootPanel.classList.contains("open")));
  elements.lootClose.addEventListener("click", () => setPanelOpen(elements.lootPanel, false));
  elements.settingsPanelBody.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
  elements.labelSettingsBody.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
  elements.playersPanel.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
  elements.lootPanel.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
  elements.lootList.addEventListener("click", (event) => {
    const row = event.target.closest(".loot-row");
    if (!row) {
      return;
    }
    toggleFavoriteLoot(row.dataset.lootName || "");
  });

  elements.viewport.addEventListener("pointerdown", (event) => {
    if (event.pointerType === "mouse" && event.button !== 0) {
      return;
    }
    elements.viewport.setPointerCapture(event.pointerId);
    state.activePointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
    if (state.activePointers.size === 1) {
      state.dragging = !state.settings.followPlayer;
      state.lastX = event.clientX;
      state.lastY = event.clientY;
      state.pinchDistance = 0;
      return;
    }
    const pointerPair = getActivePointerPair();
    if (pointerPair) {
      state.dragging = false;
      state.pinchDistance = getPointerDistance(pointerPair[0], pointerPair[1]);
    }
  });

  elements.viewport.addEventListener("pointermove", (event) => {
    if (!state.activePointers.has(event.pointerId)) {
      return;
    }

    state.activePointers.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
    const pointerPair = getActivePointerPair();
    if (pointerPair) {
      const nextDistance = getPointerDistance(pointerPair[0], pointerPair[1]);
      if (state.pinchDistance > 0 && nextDistance > 0) {
        const midpoint = getPointerMidpoint(pointerPair[0], pointerPair[1]);
        zoomAt(midpoint.clientX, midpoint.clientY, state.scale * (nextDistance / state.pinchDistance));
      }
      state.pinchDistance = nextDistance;
      return;
    }

    if (!state.dragging) {
      return;
    }
    updateMapDrag(event.clientX, event.clientY);
  });

  const stopPointerGesture = (event) => {
    state.activePointers.delete(event.pointerId);
    if (state.activePointers.size === 0) {
      state.dragging = false;
      state.pinchDistance = 0;
      return;
    }

    const [remainingPointer] = state.activePointers.values();
    if (remainingPointer) {
      state.dragging = true;
      state.lastX = remainingPointer.clientX;
      state.lastY = remainingPointer.clientY;
    }
    state.pinchDistance = 0;
  };

  elements.viewport.addEventListener("pointerup", stopPointerGesture);
  elements.viewport.addEventListener("pointercancel", stopPointerGesture);

  elements.viewport.addEventListener("wheel", (event) => {
    if (event.target.closest("#settingsPanel") || event.target.closest("#playersPanel") || event.target.closest("#lootPanel")) {
      return;
    }
    event.preventDefault();
    zoomAt(event.clientX, event.clientY, state.scale * (event.deltaY < 0 ? 1.1 : 0.9));
  }, { passive: false });

  window.addEventListener("resize", () => {
    if (state.settings.followPlayer && state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot.localPlayer) {
      centerOn(state.lastSnapshot.localPlayer);
      return;
    }
    applyTransform();
  });
}

rebuildFilterCaches();
ensureFilterSettings();
renderFilterCards();
bindUi();

bootstrap()
  .then(() => fetch(apiUrl("/api/state"), { cache: "no-store" }))
  .then((response) => {
    if (!response.ok) {
      throw new Error("Unauthorized");
    }
    return response.json();
  })
  .then((snapshot) => {
    applySnapshot(snapshot);
    connectEvents();
  })
  .catch((err) => {
    const msg = (err && err.message) || "unknown error";
    if (msg === "Unauthorized") {
      document.body.innerHTML = '<div class="unauthorized">Unauthorized — check password</div>';
    } else {
      document.body.innerHTML =
        `<div class="unauthorized">Failed to connect: ${msg}.<br>` +
        `Is the server running? Open <a href="/api/debug" style="color:#93c5fd">/api/debug</a> for diagnostics.</div>`;
    }
  });
