/*
 * script.js - Frontend logic for Dereth Tracker Single-Page Application.
 * Handles WebSocket communication, UI rendering of player lists, map display,
 * and user interactions (filtering, sorting, chat, stats windows).
 */
/**
 * script.js - Frontend controller for Dereth Tracker SPA
 *
 * Responsibilities:
 *   - Establish WebSocket connections to receive live telemetry and chat data
 *   - Fetch and render live player lists, trails, and map dots
 *   - Handle user interactions: filtering, sorting, selecting players
 *   - Manage dynamic UI components: chat windows, stats panels, tooltips
 *   - Provide smooth pan/zoom of map overlay using CSS transforms
 *
 * Structure:
 *   1. DOM references and constant definitions
 *   2. Color palette and assignment logic
 *   3. Sorting and filtering setup
 *   4. Utility functions (coordinate mapping, color hashing)
 *   5. UI window creation (stats, chat)
 *   6. Rendering functions for list and map
 *   7. Event listeners for map interactions and WebSocket messages
 */
/* ---------- DOM references --------------------------------------- */
const wrap         = document.getElementById('mapContainer');
const group        = document.getElementById('mapGroup');
const img          = document.getElementById('map');
const dots         = document.getElementById('dots');
const trailsContainer = document.getElementById('trails');
const list         = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons');
const tooltip      = document.getElementById('tooltip');
// Filter input for player names (starts-with filter)
let currentFilter = '';
const filterInput = document.getElementById('playerFilter');
if (filterInput) {
  filterInput.addEventListener('input', e => {
    currentFilter = e.target.value.toLowerCase().trim();
    renderList();
  });
}

// WebSocket for chat and commands
let socket;
// Keep track of open chat windows: character_name -> DOM element
const chatWindows = {};
// Keep track of open stats windows: character_name -> DOM element
const statsWindows = {};

/**
 * ---------- Application Constants -----------------------------
 * Defines key parameters for map rendering, data polling, and UI limits.
 *
 * MAX_Z: Maximum altitude difference considered (filter out outliers by Z)
 * FOCUS_ZOOM: Zoom level when focusing on a selected character
 * POLL_MS: Millisecond interval to fetch live player data and trails
 * MAP_BOUNDS: World coordinate bounds for the game map (used for projection)
 * API_BASE: Prefix for AJAX endpoints (set when behind a proxy)
 * MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage
 * CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code
 */
/* ---------- constants ------------------------------------------- */
const MAX_Z       = 10;
const FOCUS_ZOOM  = 3;       // zoom level when you click a name
const POLL_MS     = 2000;
const MAP_BOUNDS  = {
  west : -102.04,
  east :  102.19,
  north:  102.16,
  south: -102.00
};

// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
// If serving APIs at root, leave empty
const API_BASE = '';
// Maximum number of lines to retain in each chat window scrollback
const MAX_CHAT_LINES = 1000;
// Map numeric chat color codes to CSS hex colors
const CHAT_COLOR_MAP = {
  0:  '#00FF00', // Broadcast
  2:  '#FFFFFF', // Speech
  3:  '#FFD700', // Tell
  4:  '#CCCC00', // OutgoingTell
  5:  '#FF00FF', // System
  6:  '#FF0000', // Combat
  7:  '#00CCFF', // Magic
  8:  '#DDDDDD', // Channel
  9:  '#FF9999', // ChannelSend
 10:  '#FFFF33', // Social
 11:  '#CCFF33', // SocialSend
 12:  '#FFFFFF', // Emote
 13:  '#00FFFF', // Advancement
 14:  '#66CCFF', // Abuse
 15:  '#FF0000', // Help
 16:  '#33FF00', // Appraisal
 17:  '#0099FF', // Spellcasting
 18:  '#FF6600', // Allegiance
 19:  '#CC66FF', // Fellowship
 20:  '#00FF00', // WorldBroadcast
 21:  '#FF0000', // CombatEnemy
 22:  '#FF33CC', // CombatSelf
 23:  '#00CC00', // Recall
 24:  '#00FF00', // Craft
 25:  '#00FF66', // Salvaging
 27:  '#FFFFFF', // General
 28:  '#33FF33', // Trade
 29:  '#CCCCCC', // LFG
 30:  '#CC00CC', // Roleplay
 31:  '#FFFF00'  // AdminTell
};

/**
 * ---------- Player Color Assignment ----------------------------
 * Uses a predefined accessible color palette for player dots to ensure
 * high contrast and colorblind-friendly display. Once the palette
 * is exhausted, falls back to a deterministic hash-to-hue function.
 */
/* ---------- player/dot color assignment ------------------------- */
// A base palette of distinct, color-blind-friendly colors
const PALETTE = [
  '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
  '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
];
// Map from character name to assigned color
const colorMap = {};
// Next index to pick from PALETTE
let nextPaletteIndex = 0;
/**
 * Assigns or returns a consistent color for a given name.
 * Uses a fixed palette first, then falls back to hue hashing.
 */
function getColorFor(name) {
  if (colorMap[name]) {
    return colorMap[name];
  }
  let color;
  if (nextPaletteIndex < PALETTE.length) {
    color = PALETTE[nextPaletteIndex++];
  } else {
    // Fallback: hash to HSL hue
    color = hue(name);
  }
  colorMap[name] = color;
  return color;
}

/*
 * ---------- Sort Configuration -------------------------------
 * Defines available sort criteria for the active player list:
 *  - name: alphabetical ascending
 *  - kph: kills per hour descending
 *  - kills: total kills descending
 *  - rares: rare events found during current session descending
 * Each option includes a label for UI display and a comparator function.
 */
/* ---------- sort configuration ---------------------------------- */
const sortOptions = [
  {
    value: "name",
    label: "Name ↑",
    comparator: (a, b) => a.character_name.localeCompare(b.character_name)
  },
  {
    value: "kph",
    label: "KPH ↓",
    comparator: (a, b) => b.kills_per_hour - a.kills_per_hour
  },
  {
    value: "kills",
    label: "Kills ↓",
    comparator: (a, b) => b.kills - a.kills
  },
  {
    value: "rares",
    label: "Session Rares ↓",
    comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0)
  }
];

let currentSort    = sortOptions[0];
let currentPlayers = [];

/* ---------- generate segmented buttons -------------------------- */
sortOptions.forEach(opt => {
  const btn = document.createElement('div');
  btn.className = 'btn';
  btn.textContent = opt.label;
  btn.dataset.value = opt.value;
  if (opt.value === currentSort.value) btn.classList.add('active');

  btn.addEventListener('click', () => {
    btnContainer.querySelectorAll('.btn')
      .forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    currentSort = opt;
    renderList();
  });

  btnContainer.appendChild(btn);
});

/* ---------- map & state variables ------------------------------- */
let imgW = 0, imgH = 0;
let scale = 1, offX = 0, offY = 0, minScale = 1;
let dragging = false, sx = 0, sy = 0;
let selected = "";
let pollID = null;

/* ---------- utility functions ----------------------------------- */
const hue = name => {
  let h = 0;
  for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h);
  return `hsl(${Math.abs(h) % 360},72%,50%)`;
};

const loc = (ns, ew) =>
  `${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} `
  + `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`;

function worldToPx(ew, ns) {
  const x = ((ew - MAP_BOUNDS.west)
           / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
  const y = ((MAP_BOUNDS.north - ns)
           / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
  return { x, y };
}

// Show or create a stats window for a character
function showStatsWindow(name) {
  if (statsWindows[name]) {
    const existing = statsWindows[name];
    existing.style.display = 'flex';
    return;
  }
  const win = document.createElement('div');
  win.className = 'stats-window';
  win.dataset.character = name;
  // Header (reuses chat-header styling)
  const header = document.createElement('div');
  header.className = 'chat-header';
  const title = document.createElement('span');
  title.textContent = `Stats: ${name}`;
  const closeBtn = document.createElement('button');
  closeBtn.className = 'chat-close-btn';
  closeBtn.textContent = '×';
  closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
  header.appendChild(title);
  header.appendChild(closeBtn);
  win.appendChild(header);
  // Content container
  const content = document.createElement('div');
  content.className = 'chat-messages';
  content.textContent = 'Loading stats...';
  win.appendChild(content);
  document.body.appendChild(win);
  statsWindows[name] = win;
  // Embed a 2×2 grid of Grafana solo-panel iframes for this character
  content.innerHTML = '';
  const panels = [
    { title: 'Kills per Hour', id: 1 },
    { title: 'Memory (MB)',    id: 2 },
    { title: 'CPU (%)',        id: 3 },
    { title: 'Mem Handles',    id: 4 }
  ];
  panels.forEach(p => {
    const iframe = document.createElement('iframe');
    iframe.src =
      `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` +
      `?panelId=${p.id}` +
      `&var-character=${encodeURIComponent(name)}` +
      `&theme=light`;
    iframe.setAttribute('title', p.title);
    iframe.width = '350';
    iframe.height = '200';
    iframe.frameBorder = '0';
    iframe.allowFullscreen = true;
    content.appendChild(iframe);
  });
  // Enable dragging of the stats window via its header
  if (!window.__chatZ) window.__chatZ = 10000;
  let drag = false;
  let startX = 0, startY = 0, startLeft = 0, startTop = 0;
  header.style.cursor = 'move';
  const bringToFront = () => {
    window.__chatZ += 1;
    win.style.zIndex = window.__chatZ;
  };
  header.addEventListener('mousedown', e => {
    if (e.target.closest('button')) return;
    e.preventDefault();
    drag = true;
    bringToFront();
    startX = e.clientX; startY = e.clientY;
    startLeft = win.offsetLeft; startTop = win.offsetTop;
    document.body.classList.add('noselect');
  });
  window.addEventListener('mousemove', e => {
    if (!drag) return;
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    win.style.left = `${startLeft + dx}px`;
    win.style.top  = `${startTop + dy}px`;
  });
  window.addEventListener('mouseup', () => {
    drag = false;
    document.body.classList.remove('noselect');
  });
  // Touch support for dragging
  header.addEventListener('touchstart', e => {
    if (e.touches.length !== 1 || e.target.closest('button')) return;
    drag = true;
    bringToFront();
    const t = e.touches[0];
    startX = t.clientX; startY = t.clientY;
    startLeft = win.offsetLeft; startTop = win.offsetTop;
  });
  window.addEventListener('touchmove', e => {
    if (!drag || e.touches.length !== 1) return;
    const t = e.touches[0];
    const dx = t.clientX - startX;
    const dy = t.clientY - startY;
    win.style.left = `${startLeft + dx}px`;
    win.style.top  = `${startTop + dy}px`;
  });
  window.addEventListener('touchend', () => { drag = false; });
}

const applyTransform = () =>
  group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;

function clampPan() {
  if (!imgW) return;
  const r = wrap.getBoundingClientRect();
  const vw = r.width, vh = r.height;
  const mw = imgW * scale, mh = imgH * scale;

  offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX));
  offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY));
}

function updateView() {
  clampPan();
  applyTransform();
}

function fitToWindow() {
  const r = wrap.getBoundingClientRect();
  scale = Math.min(r.width / imgW, r.height / imgH);
  minScale = scale;
  updateView();
}

/* ---------- tooltip handlers ------------------------------------ */
function showTooltip(evt, p) {
  tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`;
  const r = wrap.getBoundingClientRect();
  tooltip.style.left    = `${evt.clientX - r.left + 10}px`;
  tooltip.style.top     = `${evt.clientY - r.top  + 10}px`;
  tooltip.style.display = 'block';
}
function hideTooltip() {
  tooltip.style.display = 'none';
}

/* ---------- polling and initialization -------------------------- */
async function pollLive() {
  try {
    const [liveRes, trailsRes] = await Promise.all([
      fetch(`${API_BASE}/live/`),
      fetch(`${API_BASE}/trails/?seconds=600`),
    ]);
    const { players } = await liveRes.json();
    const { trails } = await trailsRes.json();
    currentPlayers = players;
    renderTrails(trails);
    renderList();
  } catch (e) {
    console.error('Live or trails fetch failed:', e);
  }
}

function startPolling() {
  if (pollID !== null) return;
  pollLive();
  pollID = setInterval(pollLive, POLL_MS);
}

img.onload = () => {
  imgW = img.naturalWidth;
  imgH = img.naturalHeight;
  // size the SVG trails container to match the map dimensions
  if (trailsContainer) {
    trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`);
    trailsContainer.setAttribute('width', `${imgW}`);
    trailsContainer.setAttribute('height', `${imgH}`);
  }
  fitToWindow();
  startPolling();
  initWebSocket();
};

/* ---------- rendering sorted list & dots ------------------------ */
/**
 * Filter and sort the currentPlayers, then render them.
 */
function renderList() {
  // Filter by name prefix
  const filtered = currentPlayers.filter(p =>
    p.character_name.toLowerCase().startsWith(currentFilter)
  );
  // Sort filtered list
  const sorted = filtered.slice().sort(currentSort.comparator);
  render(sorted);
}

function render(players) {
  dots.innerHTML = '';
  list.innerHTML = '';

  players.forEach(p => {
    const { x, y } = worldToPx(p.ew, p.ns);

    // dot
    const dot = document.createElement('div');
    dot.className = 'dot';
    dot.style.left      = `${x}px`;
    dot.style.top       = `${y}px`;
    dot.style.background = getColorFor(p.character_name);

    


    // custom tooltip
    dot.addEventListener('mouseenter', e => showTooltip(e, p));
    dot.addEventListener('mousemove',  e => showTooltip(e, p));
    dot.addEventListener('mouseleave', hideTooltip);

    // click to select/zoom
    dot.addEventListener('click', () => selectPlayer(p, x, y));

    if (p.character_name === selected) dot.classList.add('highlight');
    dots.appendChild(dot);
   //sidebar
    const li = document.createElement('li');
    const color = getColorFor(p.character_name);
    li.style.borderLeftColor = color;
    li.className = 'player-item';
    li.innerHTML = `
    <span class="player-name">${p.character_name}</span>
    <span class="player-loc">${loc(p.ns, p.ew)}</span>
    <span class="stat kills">${p.kills}</span>
    <span class="stat kph">${p.kills_per_hour}</span>
    <span class="stat rares">${p.session_rares}/${p.total_rares}</span>
    <span class="stat meta">${p.vt_state}</span>
    <span class="stat onlinetime">${p.onlinetime}</span>
    <span class="stat deaths">${p.deaths}</span>
  `;

    // Color the metastate pill according to its value
    const metaSpan = li.querySelector('.stat.meta');
    if (metaSpan) {
      const goodStates = ['default', 'default2', 'hunt', 'combat'];
      const state = (p.vt_state || '').toString().toLowerCase();
      if (goodStates.includes(state)) {
        metaSpan.classList.add('green');
      } else {
        metaSpan.classList.add('red');
      }
    }
  
    li.addEventListener('click', () => selectPlayer(p, x, y));
    if (p.character_name === selected) li.classList.add('selected');
    // Chat button
    const chatBtn = document.createElement('button');
    chatBtn.className = 'chat-btn';
    chatBtn.textContent = 'Chat';
    chatBtn.addEventListener('click', e => {
      e.stopPropagation();
      showChatWindow(p.character_name);
    });
    li.appendChild(chatBtn);
    // Stats button
    const statsBtn = document.createElement('button');
    statsBtn.className = 'stats-btn';
    statsBtn.textContent = 'Stats';
    statsBtn.addEventListener('click', e => {
      e.stopPropagation();
      showStatsWindow(p.character_name);
    });
    li.appendChild(statsBtn);
    list.appendChild(li);
  });
}

/* ---------- rendering trails ------------------------------- */
function renderTrails(trailData) {
  trailsContainer.innerHTML = '';
  const byChar = trailData.reduce((acc, pt) => {
    (acc[pt.character_name] = acc[pt.character_name] || []).push(pt);
    return acc;
  }, {});
  for (const [name, pts] of Object.entries(byChar)) {
    if (pts.length < 2) continue;
    const points = pts.map(pt => {
      const { x, y } = worldToPx(pt.ew, pt.ns);
      return `${x},${y}`;
    }).join(' ');
    const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
    poly.setAttribute('points', points);
    // Use the same color as the player dot for consistency
    poly.setAttribute('stroke', getColorFor(name));
    poly.setAttribute('fill', 'none');
    poly.setAttribute('class', 'trail-path');
    trailsContainer.appendChild(poly);
  }
}
/* ---------- selection centering, focus zoom & blink ------------ */
function selectPlayer(p, x, y) {
  selected = p.character_name;
  // set focus zoom
  scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM));
  // center on the player
  const r = wrap.getBoundingClientRect();
  offX = r.width  / 2 - x * scale;
  offY = r.height / 2 - y * scale;
  updateView();
  renderList();  // keep sorted + highlight
}

/*
 * ---------- Chat & Command WebSocket Handlers ------------------
 * Maintains a persistent WebSocket connection to the /ws/live endpoint
 * for receiving chat messages and sending user commands to plugin clients.
 * Reconnects automatically on close and logs errors.
 */
// Initialize WebSocket for chat and command streams
function initWebSocket() {
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
  const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
  socket = new WebSocket(wsUrl);
  socket.addEventListener('message', evt => {
    let msg;
    try { msg = JSON.parse(evt.data); } catch { return; }
    if (msg.type === 'chat') {
      appendChatMessage(msg);
    }
  });
  socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
  socket.addEventListener('error', e => console.error('WebSocket error:', e));
}

// Display or create a chat window for a character
function showChatWindow(name) {
  if (chatWindows[name]) {
    // Restore flex layout when reopening & bring to front
    const existing = chatWindows[name];
    existing.style.display = 'flex';
    if (!window.__chatZ) window.__chatZ = 10000;
    window.__chatZ += 1;
    existing.style.zIndex = window.__chatZ;
    return;
  }
  const win = document.createElement('div');
  win.className = 'chat-window';
  win.dataset.character = name;
  // Header
  const header = document.createElement('div');
  header.className = 'chat-header';
  const title = document.createElement('span');
  title.textContent = `Chat: ${name}`;
  const closeBtn = document.createElement('button');
  closeBtn.className = 'chat-close-btn';
  closeBtn.textContent = '×';
  closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
  header.appendChild(title);
  header.appendChild(closeBtn);
  win.appendChild(header);
  // Messages container
  const msgs = document.createElement('div');
  msgs.className = 'chat-messages';
  win.appendChild(msgs);
  // Input form
  const form = document.createElement('form');
  form.className = 'chat-form';
  const input = document.createElement('input');
  input.type = 'text';
  input.className = 'chat-input';
  input.placeholder = 'Enter chat...';
  form.appendChild(input);
  form.addEventListener('submit', e => {
    e.preventDefault();
    const text = input.value.trim();
    if (!text) return;
    // Send command envelope: player_name and command only
    socket.send(JSON.stringify({ player_name: name, command: text }));
    input.value = '';
  });
  win.appendChild(form);
  document.body.appendChild(win);
  chatWindows[name] = win;

  /* --------------------------------------------------------- */
  /* enable dragging of the chat window via its header element */
  /* --------------------------------------------------------- */

  // keep a static counter so newer windows can be brought to front
  if (!window.__chatZ) window.__chatZ = 10000;

  let drag = false;
  let startX = 0, startY = 0;
  let startLeft = 0, startTop = 0;

  header.style.cursor = 'move';

  // bring to front when interacting
  const bringToFront = () => {
    window.__chatZ += 1;
    win.style.zIndex = window.__chatZ;
  };

  header.addEventListener('mousedown', e => {
    // don't initiate drag when pressing the close button (or other clickable controls)
    if (e.target.closest('button')) return;
    e.preventDefault();
    drag = true;
    bringToFront();
    startX = e.clientX;
    startY = e.clientY;
    // current absolute position
    startLeft = win.offsetLeft;
    startTop  = win.offsetTop;
    document.body.classList.add('noselect');
  });

  window.addEventListener('mousemove', e => {
    if (!drag) return;
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    win.style.left = `${startLeft + dx}px`;
    win.style.top  = `${startTop  + dy}px`;
  });

  window.addEventListener('mouseup', () => {
    drag = false;
    document.body.classList.remove('noselect');
  });

  /* touch support */
  header.addEventListener('touchstart', e => {
    if (e.touches.length !== 1 || e.target.closest('button')) return;
    drag = true;
    bringToFront();
    const t = e.touches[0];
    startX = t.clientX;
    startY = t.clientY;
    startLeft = win.offsetLeft;
    startTop  = win.offsetTop;
  });

  window.addEventListener('touchmove', e => {
    if (!drag || e.touches.length !== 1) return;
    const t = e.touches[0];
    const dx = t.clientX - startX;
    const dy = t.clientY - startY;
    win.style.left = `${startLeft + dx}px`;
    win.style.top  = `${startTop  + dy}px`;
  });

  window.addEventListener('touchend', () => {
    drag = false;
  });
}

// Append a chat message to the correct window
/**
 * Append a chat message to the correct window, optionally coloring the text.
 * msg: { type: 'chat', character_name, text, color? }
 */
function appendChatMessage(msg) {
  const { character_name: name, text, color } = msg;
  const win = chatWindows[name];
  if (!win) return;
  const msgs = win.querySelector('.chat-messages');
  const p = document.createElement('div');
  if (color !== undefined) {
    let c = color;
    if (typeof c === 'number') {
      // map numeric chat code to configured color, or fallback to raw hex
      if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
        c = CHAT_COLOR_MAP[c];
      } else {
        c = '#' + c.toString(16).padStart(6, '0');
      }
    }
    p.style.color = c;
  }
  p.textContent = text;
  msgs.appendChild(p);
  // Enforce max number of lines in scrollback
  while (msgs.children.length > MAX_CHAT_LINES) {
    msgs.removeChild(msgs.firstChild);
  }
  // Scroll to bottom
  msgs.scrollTop = msgs.scrollHeight;
}

/* ---------- pan & zoom handlers -------------------------------- */
wrap.addEventListener('wheel', e => {
  e.preventDefault();
  if (!imgW) return;

  const r  = wrap.getBoundingClientRect();
  const mx = (e.clientX - r.left - offX) / scale;
  const my = (e.clientY - r.top  - offY) / scale;
  const factor = e.deltaY > 0 ? 0.9 : 1.1;
  let ns = scale * factor;
  ns = Math.max(minScale, Math.min(MAX_Z, ns));

  offX -= mx * (ns - scale);
  offY -= my * (ns - scale);
  scale = ns;
  updateView();
}, { passive: false });

wrap.addEventListener('mousedown', e => {
  dragging = true; sx = e.clientX; sy = e.clientY;
  wrap.classList.add('dragging');
});
window.addEventListener('mousemove', e => {
  if (!dragging) return;
  offX += e.clientX - sx; offY += e.clientY - sy;
  sx = e.clientX; sy = e.clientY;
  updateView();
});
window.addEventListener('mouseup', () => {
  dragging = false; wrap.classList.remove('dragging');
});

wrap.addEventListener('touchstart', e => {
  if (e.touches.length !== 1) return;
  dragging = true;
  sx = e.touches[0].clientX; sy = e.touches[0].clientY;
});
wrap.addEventListener('touchmove', e => {
  if (!dragging || e.touches.length !== 1) return;
  const t = e.touches[0];
  offX += t.clientX - sx; offY += t.clientY - sy;
  sx = t.clientX; sy = t.clientY;
  updateView();
});
wrap.addEventListener('touchend', () => {
  dragging = false;
});
