Back to Squad Table
SOURCE / PINNED RELEASE

Made of little things.

Squad Table

Release
d36201f8ed4f…
Author-recorded commit
7b9bb681d6ba…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d

Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/board.ts
// SVG map board: static terrain art, live counter stacks, overlays and
// pan/zoom/drag input. Emits hex-level events; it never edits the table itself.

import { COUNTER, counterSvg, NATION_STYLE } from './counters.js';
import { center, corners, HEX_SIZE, hexside, parseHex, pixelToHex, hexId, SQRT3, thread, type Hex } from './hex.js';
import { sideNation, type Counter, type Owner, type TableState } from './model.js';
import type { GameMap, MapCell } from './scenarios.js';

const NS = 'http://www.w3.org/2000/svg';

export type Overlay = {
  selected: string | null;
  targeting: boolean;
  reach: Map<string, number> | null; // legal destinations → MF cost
  targets: Map<string, string> | null; // valid fire targets → IFT column / DRM label
  fireLine: { from: string; to: string } | null;
  los: { from: string; to: string | null } | null;
  presence: { side: Owner; hex: string | null }[];
  showCoords: boolean;
};

export type BoardEvents = {
  tap(hex: string | null): void;
  dragMove(from: string, to: string): void;
  hover(hex: string | null): void;
};

// Small deterministic noise so terrain art is stable across renders/clients.
function hash(text: string): () => number {
  let h = 2166136261;
  for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
  return () => {
    h ^= h << 13;
    h ^= h >>> 17;
    h ^= h << 5;
    return ((h >>> 0) % 10000) / 10000;
  };
}

const LEVEL_FILL = ['#e4dcc0', '#d5c296', '#c3a874', '#b08f5c'];

function points(list: [number, number][]): string {
  return list.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
}

function terrainArt(cell: MapCell): string {
  const { x, y } = center(cell.hex);
  const rnd = hash(cell.id);
  const out: string[] = [];
  switch (cell.terrain) {
    case 'woods': {
      out.push(`<polygon points="${points(corners(cell.hex, HEX_SIZE * 0.86))}" fill="#5d7843" opacity="0.55"/>`);
      for (let i = 0; i < 9; i += 1) {
        const a = rnd() * Math.PI * 2;
        const d = rnd() * HEX_SIZE * 0.55;
        const r = 7 + rnd() * 5;
        out.push(
          `<circle cx="${(x + Math.cos(a) * d).toFixed(1)}" cy="${(y + Math.sin(a) * d).toFixed(1)}" r="${r.toFixed(1)}" fill="#46612f" stroke="#2f4320" stroke-width="0.8"/>`,
        );
      }
      break;
    }
    case 'brush':
      for (let i = 0; i < 26; i += 1) {
        const a = rnd() * Math.PI * 2;
        const d = Math.sqrt(rnd()) * HEX_SIZE * 0.75;
        out.push(`<circle cx="${(x + Math.cos(a) * d).toFixed(1)}" cy="${(y + Math.sin(a) * d).toFixed(1)}" r="${(1.6 + rnd() * 1.8).toFixed(1)}" fill="#7f9152"/>`);
      }
      break;
    case 'grain':
      out.push(`<polygon points="${points(corners(cell.hex, HEX_SIZE * 0.8))}" fill="#dcc376" opacity="0.85"/>`);
      for (let k = -4; k <= 4; k += 1) {
        out.push(`<line x1="${x - 26}" y1="${y + k * 6 - 6}" x2="${x + 26}" y2="${y + k * 6 + 6}" stroke="#b39a4b" stroke-width="0.8" opacity="0.7"/>`);
      }
      break;
    case 'orchard':
      for (let row = -1; row <= 1; row += 1) {
        for (let col = -1; col <= 1; col += 1) {
          const ox = x + col * 15 + (row & 1 ? 7 : 0);
          const oy = y + row * 15;
          out.push(`<circle cx="${ox}" cy="${oy}" r="4.6" fill="#6f8c45" stroke="#45602a" stroke-width="0.8"/>`);
        }
      }
      break;
    case 'marsh':
      for (let i = 0; i < 9; i += 1) {
        const ox = x + (rnd() - 0.5) * 48;
        const oy = y + (rnd() - 0.5) * 44;
        out.push(`<path d="M${ox - 5} ${oy} H${ox + 5} M${ox - 2.5} ${oy} L${ox - 4} ${oy - 4} M${ox} ${oy} V${oy - 5} M${ox + 2.5} ${oy} L${ox + 4} ${oy - 4}" stroke="#4f7a72" stroke-width="0.9" fill="none"/>`);
      }
      break;
    case 'water':
      out.push(`<polygon points="${points(corners(cell.hex, HEX_SIZE + 0.5))}" fill="#86a9b6"/>`);
      for (let k = -1; k <= 1; k += 1) {
        out.push(`<path d="M${x - 20} ${y + k * 12} q5 -4 10 0 t10 0 t10 0 t10 0" stroke="#e8f1ef" stroke-width="1" fill="none" opacity="0.7"/>`);
      }
      break;
    case 'wood-bldg':
    case 'stone-bldg': {
      const stone = cell.terrain === 'stone-bldg';
      const w = 40 + rnd() * 10;
      const h = 30 + rnd() * 8;
      const tilt = (rnd() - 0.5) * 16;
      const fill = stone ? '#8f8b83' : '#a5724a';
      const line = stone ? '#3a3834' : '#56351d';
      out.push(
        `<g transform="translate(${x} ${y}) rotate(${tilt.toFixed(1)})">` +
          `<rect x="${-w / 2 + 2}" y="${-h / 2 + 2}" width="${w}" height="${h}" fill="#000" opacity="0.22"/>` +
          `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" fill="${fill}" stroke="${line}" stroke-width="1.3"/>` +
          (stone
            ? `<path d="M${-w / 2} ${-h / 2} L${w / 2} ${h / 2} M${w / 2} ${-h / 2} L${-w / 2} ${h / 2}" stroke="${line}" stroke-width="0.7" opacity="0.6"/>`
            : `<path d="M${-w / 2} 0 H${w / 2}" stroke="${line}" stroke-width="1"/>`) +
          `</g>`,
      );
      break;
    }
    default:
      break;
  }
  return out.join('');
}

function staticBoard(map: GameMap): string {
  const parts: string[] = [];
  const width = HEX_SIZE * 1.5 * (map.cols - 1) + HEX_SIZE * 2;
  const height = SQRT3 * HEX_SIZE * (map.rows + 0.5);
  parts.push(`<rect x="-14" y="-14" width="${width + 28}" height="${height + 28}" rx="6" fill="#efe7cf" stroke="#2a2a20" stroke-width="2"/>`);
  parts.push(`<rect x="-14" y="-14" width="${width + 28}" height="${height + 28}" rx="6" fill="none" stroke="#c9b98d" stroke-width="6" opacity="0.6"/>`);
  for (const cell of map.cells.values()) {
    parts.push(`<polygon points="${points(corners(cell.hex, HEX_SIZE + 0.4))}" fill="${LEVEL_FILL[cell.level] ?? LEVEL_FILL[3]}"/>`);
  }
  // Level boundaries as contour strokes.
  for (const cell of map.cells.values()) {
    const cs = corners(cell.hex);
    const odd = cell.hex.c & 1;
    const dirs: [number, number][] = odd
      ? [[1, 1], [0, 1], [-1, 1], [-1, 0], [0, -1], [1, 0]]
      : [[1, 0], [0, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]];
    dirs.forEach(([dc, dr], i) => {
      const n = map.cells.get(hexId({ c: cell.hex.c + dc, r: cell.hex.r + dr }));
      if (n && n.level < cell.level) {
        const [x1, y1] = cs[i];
        const [x2, y2] = cs[(i + 1) % 6];
        parts.push(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#7c5f33" stroke-width="2.2" stroke-linecap="round"/>`);
      }
    });
  }
  for (const cell of map.cells.values()) parts.push(terrainArt(cell));
  for (const road of map.roads) {
    // Curve through hexside midpoints so straight rows don't zig-zag.
    const pts = road.map((id) => center(parseHex(id)!));
    let d = `M${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
    for (let i = 1; i < pts.length; i += 1) {
      const mx = (pts[i - 1].x + pts[i].x) / 2;
      const my = (pts[i - 1].y + pts[i].y) / 2;
      d += ` Q${pts[i - 1].x.toFixed(1)} ${pts[i - 1].y.toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
    }
    const last = pts[pts.length - 1];
    d += ` L${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
    parts.push(`<path d="${d}" stroke="#8f8466" stroke-width="12" fill="none" stroke-linejoin="round"/>`);
    parts.push(`<path d="${d}" stroke="#ddd3b4" stroke-width="9" fill="none" stroke-linejoin="round"/>`);
  }
  for (const side of map.hexsides) {
    const a = parseHex(side.a);
    const b = parseHex(side.b);
    if (!a || !b) continue;
    const [x1, y1, x2, y2] = hexside(a, b);
    parts.push(
      side.kind === 'hedge'
        ? `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#3a5527" stroke-width="5.5" stroke-linecap="round"/>`
        : `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#5e5a54" stroke-width="4.5" stroke-linecap="square" stroke-dasharray="3 1.4"/>`,
    );
  }
  for (const cell of map.cells.values()) {
    parts.push(`<polygon points="${points(corners(cell.hex))}" fill="none" stroke="#5a4628" stroke-opacity="0.42" stroke-width="0.9"/>`);
  }
  return parts.join('');
}

function coordLayer(map: GameMap): string {
  const parts: string[] = [];
  for (const cell of map.cells.values()) {
    const { x, y } = center(cell.hex);
    parts.push(`<text x="${x}" y="${y - HEX_SIZE * 0.66}" text-anchor="middle" font-size="7.5" class="coord">${cell.id}</text>`);
    if (cell.level > 0) parts.push(`<text x="${x}" y="${y + HEX_SIZE * 0.78}" text-anchor="middle" font-size="6.5" class="coord">L${cell.level}</text>`);
  }
  return parts.join('');
}

/** Marker counters implied by unit status in a stack, in ASL order (fire on top). */
function statusMarkers(state: TableState, ids: string[]): string[] {
  const units = ids.map((id) => state.counters.get(id)).filter((c): c is Counter => Boolean(c) && c!.side !== 'n');
  const out: string[] = [];
  if (units.some((c) => c.cx)) out.push('mk-cx');
  if (units.some((c) => c.pin)) out.push('mk-pin');
  if (units.some((c) => c.fired === 'final')) out.push('mk-final');
  if (units.some((c) => c.fired === 'prep')) out.push('mk-prep');
  return out;
}

export class Board {
  readonly svg: SVGSVGElement;
  private view: SVGGElement;
  private coords: SVGGElement;
  private counters: SVGGElement;
  private overlay: SVGGElement;
  private ghost: SVGGElement;
  /** Layer for transient shooting effects (see effects.ts). */
  readonly fx: SVGGElement;
  private map: GameMap | null = null;
  private state: TableState | null = null;
  private scale = 1;
  private tx = 0;
  private ty = 0;
  private fitted = false;
  private pointers = new Map<number, { x: number; y: number; type: string }>();
  private gesture:
    | { kind: 'idle' }
    | { kind: 'press'; x: number; y: number; hex: string | null; mouse: boolean; tx: number; ty: number }
    | { kind: 'pan'; x: number; y: number; tx: number; ty: number }
    | { kind: 'drag'; from: string }
    | { kind: 'pinch'; dist: number; scale: number; cx: number; cy: number; tx: number; ty: number } = { kind: 'idle' };
  private overlayState: Overlay = {
    selected: null,
    targeting: false,
    reach: null,
    targets: null,
    fireLine: null,
    los: null,
    presence: [],
    showCoords: true,
  };
  private hoverHex: string | null = null;

  constructor(host: HTMLElement, private events: BoardEvents) {
    this.svg = document.createElementNS(NS, 'svg');
    this.svg.setAttribute('class', 'board');
    this.svg.setAttribute('role', 'application');
    this.svg.setAttribute('aria-label', 'Hex map. Tap a hex to inspect its stack.');
    this.view = document.createElementNS(NS, 'g');
    this.coords = document.createElementNS(NS, 'g');
    this.counters = document.createElementNS(NS, 'g');
    this.overlay = document.createElementNS(NS, 'g');
    this.ghost = document.createElementNS(NS, 'g');
    this.fx = document.createElementNS(NS, 'g');
    this.fx.setAttribute('class', 'fx');
    this.ghost.setAttribute('class', 'ghost');
    this.svg.append(this.view);
    host.append(this.svg);
    this.bindInput();
    new ResizeObserver(() => {
      if (!this.fitted) this.fit();
      else this.applyTransform();
    }).observe(host);
  }

  setMap(map: GameMap): void {
    if (this.map?.id === map.id) return;
    this.map = map;
    const terrain = document.createElementNS(NS, 'g');
    terrain.innerHTML = staticBoard(map);
    this.coords.innerHTML = coordLayer(map);
    this.view.replaceChildren(terrain, this.coords, this.overlay, this.counters, this.fx, this.ghost);
    this.fitted = false;
    this.fit();
  }

  /** Centre of a hex in board coordinates. */
  hexCenter(id: string): { x: number; y: number } | null {
    const h = parseHex(id);
    return h && this.map?.cells.has(id) ? center(h) : null;
  }

  /** Brief camera shake for big hits. */
  shake(): void {
    this.svg.animate(
      [{ transform: 'translate(0,0)' }, { transform: 'translate(-4px,2px)' }, { transform: 'translate(3px,-3px)' }, { transform: 'translate(-2px,1px)' }, { transform: 'translate(0,0)' }],
      { duration: 320, easing: 'ease-out' },
    );
  }

  /** Fit the whole map in the frame. */
  fit(): void {
    if (!this.map) return;
    const rect = this.svg.getBoundingClientRect();
    if (rect.width < 10 || rect.height < 10) return;
    const width = HEX_SIZE * 1.5 * (this.map.cols - 1) + HEX_SIZE * 2 + 28;
    const height = SQRT3 * HEX_SIZE * (this.map.rows + 0.5) + 28;
    this.scale = Math.min(rect.width / width, rect.height / height);
    this.tx = (rect.width - width * this.scale) / 2 + 14 * this.scale;
    this.ty = (rect.height - height * this.scale) / 2 + 14 * this.scale;
    this.fitted = true;
    this.applyTransform();
  }

  /** Fit, but never below a zoom where hexes are comfortable to tap. */
  fitPlayable(minScale = 0.62): void {
    this.fit();
    if (this.scale >= minScale || !this.map) return;
    const rect = this.svg.getBoundingClientRect();
    const width = HEX_SIZE * 1.5 * (this.map.cols - 1) + HEX_SIZE * 2;
    const height = SQRT3 * HEX_SIZE * (this.map.rows + 0.5);
    this.scale = minScale;
    this.tx = rect.width / 2 - (width / 2) * minScale;
    this.ty = rect.height / 2 - (height / 2) * minScale;
    this.applyTransform();
  }

  zoomBy(factor: number, cx?: number, cy?: number): void {
    const rect = this.svg.getBoundingClientRect();
    const px = cx ?? rect.width / 2;
    const py = cy ?? rect.height / 2;
    const next = Math.max(0.2, Math.min(4, this.scale * factor));
    this.tx = px - ((px - this.tx) * next) / this.scale;
    this.ty = py - ((py - this.ty) * next) / this.scale;
    this.scale = next;
    this.applyTransform();
  }

  /** Pan so that a hex is visible (used after keyboard or log navigation). */
  reveal(id: string): void {
    const h = parseHex(id);
    if (!h) return;
    const rect = this.svg.getBoundingClientRect();
    const { x, y } = center(h);
    const sx = x * this.scale + this.tx;
    const sy = y * this.scale + this.ty;
    const pad = 50;
    if (sx < pad || sx > rect.width - pad || sy < pad || sy > rect.height - pad) {
      this.tx = rect.width / 2 - x * this.scale;
      this.ty = rect.height / 2 - y * this.scale;
      this.applyTransform();
    }
  }

  private applyTransform(): void {
    this.view.setAttribute('transform', `translate(${this.tx.toFixed(2)} ${this.ty.toFixed(2)}) scale(${this.scale.toFixed(4)})`);
  }

  render(state: TableState, overlay: Overlay): void {
    this.state = state;
    this.overlayState = overlay;
    this.coords.style.display = overlay.showCoords ? '' : 'none';
    const parts: string[] = [];
    for (const [at, ids] of state.stacks) {
      const h = parseHex(at);
      if (!h || !this.map?.cells.has(at)) continue;
      const { x, y } = center(h);
      parts.push(`<g data-hex="${at}" data-count="${ids.length}">`);
      const visible = ids.slice(-5);
      // Status markers sit on top of the stack like cardboard markers.
      const markers = statusMarkers(state, ids);
      const step = 3.2;
      const total = visible.length + markers.length;
      const base = ((total - 1) * step) / 2;
      visible.forEach((id, n) => {
        const c = state.counters.get(id);
        if (!c) return;
        const ox = x - base + n * step;
        const oy = y + base - n * step;
        const status = {
          cx: false,
          moved: state.move.done.has(id) || (state.move.spent.get(id) ?? 0) > 0,
          fired: undefined,
          pin: false,
        };
        parts.push(`<g transform="translate(${ox.toFixed(1)} ${oy.toFixed(1)})">${counterSvg(c, '', status)}</g>`);
      });
      markers.forEach((k, j) => {
        const n = visible.length + j;
        const ox = x - base + n * step;
        const oy = y + base - n * step;
        const marker: Counter = { id: `${at}-${k}`, k, side: 'n', face: 0, rot: 0, at };
        parts.push(`<g class="status-marker" data-marker="${k}" transform="translate(${ox.toFixed(1)} ${oy.toFixed(1)})">${counterSvg(marker)}</g>`);
      });
      if (ids.length > 1) {
        const bx = x + COUNTER / 2 + base - 1;
        const by = y - COUNTER / 2 - base + 1;
        parts.push(
          `<g class="badge"><circle cx="${bx}" cy="${by}" r="6.5" fill="#1f241d" stroke="#f0e6c8" stroke-width="1"/><text x="${bx}" y="${by + 2.6}" text-anchor="middle" font-size="7.5" font-weight="800" fill="#f0e6c8">${ids.length}</text></g>`,
        );
      }
      parts.push('</g>');
    }
    this.counters.innerHTML = parts.join('');
    this.renderOverlay();
  }

  private renderOverlay(): void {
    const state = this.state;
    const o = this.overlayState;
    if (!state) return;
    const parts: string[] = [];
    const move = state.lastMove;
    if (move && this.map?.cells.has(move.to)) {
      const owner = state.counters.get(move.ids[0])?.side ?? 'n';
      const color = NATION_STYLE[sideNation(state.scenario, owner)].edge;
      const hexes = [move.from, ...move.path].filter((id) => this.map!.cells.has(id));
      if (hexes.length > 1) {
        const pts = hexes.map((id) => center(parseHex(id)!));
        parts.push(
          `<polyline points="${pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="3" stroke-dasharray="6 4" stroke-linejoin="round" opacity="0.85" marker-end="url(#arrow)"/>`,
          `<circle cx="${pts[0].x}" cy="${pts[0].y}" r="5" fill="none" stroke="${color}" stroke-width="2" opacity="0.85"/>`,
        );
      }
    }
    if (o.reach) {
      for (const [id, cost] of o.reach) {
        const h = parseHex(id);
        if (!h) continue;
        const { x, y } = center(h);
        parts.push(
          `<polygon points="${points(corners(h, HEX_SIZE - 2.5))}" fill="#f7d774" fill-opacity="0.2" stroke="#c99a22" stroke-width="1.6"/>`,
          `<g class="reach-cost"><rect x="${x - 11}" y="${y + HEX_SIZE * 0.52}" width="22" height="11" rx="5.5" fill="#2a2412" opacity="0.85"/><text x="${x}" y="${y + HEX_SIZE * 0.52 + 8.4}" text-anchor="middle" font-size="8" font-weight="800" fill="#ffe08a">${Number.isInteger(cost) ? cost : cost.toFixed(1)}</text></g>`,
        );
      }
    }
    if (o.targets) {
      for (const [id, label] of o.targets) {
        const h = parseHex(id);
        if (!h) continue;
        const { x, y } = center(h);
        const active = o.fireLine?.to === id;
        parts.push(
          `<polygon points="${points(corners(h, HEX_SIZE - 2.5))}" fill="#d0452c" fill-opacity="${active ? 0.28 : 0.14}" stroke="#b3361f" stroke-width="${active ? 3 : 1.8}"/>`,
          `<g class="target-label"><rect x="${x - 13}" y="${y + HEX_SIZE * 0.5}" width="26" height="12" rx="6" fill="#4a120a" opacity="0.9"/><text x="${x}" y="${y + HEX_SIZE * 0.5 + 9}" text-anchor="middle" font-size="8" font-weight="800" fill="#ffd9cf">${label}</text></g>`,
        );
      }
    }
    if (o.fireLine && this.map?.cells.has(o.fireLine.from) && this.map.cells.has(o.fireLine.to)) {
      const a = center(parseHex(o.fireLine.from)!);
      const b = center(parseHex(o.fireLine.to)!);
      parts.push(
        `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#1b1b14" stroke-width="4" opacity="0.5"/>`,
        `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#ff7a5c" stroke-width="2" stroke-dasharray="7 4"/>`,
      );
    }
    if (o.los) {
      const from = parseHex(o.los.from);
      const target = o.los.to ?? this.hoverHex;
      const to = target ? parseHex(target) : null;
      if (from) {
        const a = center(from);
        if (to && this.map?.cells.has(target!)) {
          for (const step of thread(from, to)) {
            const h = parseHex(step.id);
            if (!h || !this.map.cells.has(step.id)) continue;
            parts.push(
              `<polygon points="${points(corners(h, HEX_SIZE - 3))}" fill="${step.edge ? 'none' : '#f6d36b'}" fill-opacity="0.18" stroke="#e1b43c" stroke-width="2" ${step.edge ? 'stroke-dasharray="4 3"' : ''}/>`,
            );
          }
          const b = center(to);
          parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#1b1b14" stroke-width="3.5" opacity="0.6"/>`);
          parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#ffe08a" stroke-width="1.6"/>`);
          parts.push(`<circle cx="${b.x}" cy="${b.y}" r="4" fill="#ffe08a" stroke="#1b1b14"/>`);
        }
        parts.push(`<circle cx="${a.x}" cy="${a.y}" r="5" fill="#ffe08a" stroke="#1b1b14" stroke-width="1.2"/>`);
      }
    }
    for (const p of o.presence) {
      const h = p.hex ? parseHex(p.hex) : null;
      if (!h) continue;
      const color = NATION_STYLE[sideNation(state.scenario, p.side)].face;
      parts.push(`<polygon class="presence" points="${points(corners(h, HEX_SIZE - 2))}" fill="none" stroke="${color}" stroke-width="3" stroke-dasharray="2 3"/>`);
    }
    if (o.selected && this.map?.cells.has(o.selected)) {
      const h = parseHex(o.selected)!;
      parts.push(`<polygon points="${points(corners(h, HEX_SIZE - 1.5))}" fill="#fff6d6" fill-opacity="0.12" stroke="#f7c948" stroke-width="3.2"/>`);
      parts.push(`<polygon points="${points(corners(h, HEX_SIZE - 1.5))}" fill="none" stroke="#1d1a12" stroke-width="0.9"/>`);
    }
    if (o.targeting && this.hoverHex && this.map?.cells.has(this.hoverHex)) {
      const h = parseHex(this.hoverHex)!;
      parts.push(`<polygon points="${points(corners(h, HEX_SIZE - 2))}" fill="#f7c948" fill-opacity="0.25" stroke="#f7c948" stroke-width="2" stroke-dasharray="5 3"/>`);
    }
    this.overlay.innerHTML =
      `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#3a3a2e"/></marker></defs>` +
      parts.join('');
  }

  // ----- input -----

  private local(clientX: number, clientY: number): { x: number; y: number } {
    const rect = this.svg.getBoundingClientRect();
    return { x: clientX - rect.left, y: clientY - rect.top };
  }

  private hexAt(clientX: number, clientY: number): string | null {
    const p = this.local(clientX, clientY);
    const h: Hex = pixelToHex((p.x - this.tx) / this.scale, (p.y - this.ty) / this.scale);
    const id = hexId(h);
    return this.map?.cells.has(id) ? id : null;
  }

  private setHover(hex: string | null): void {
    if (hex === this.hoverHex) return;
    this.hoverHex = hex;
    this.events.hover(hex);
    if (this.overlayState.targeting || (this.overlayState.los && !this.overlayState.los.to) || this.gesture.kind === 'drag') this.renderOverlay();
  }

  private bindInput(): void {
    const svg = this.svg;
    svg.addEventListener('pointerdown', (e) => {
      svg.setPointerCapture(e.pointerId);
      const p = this.local(e.clientX, e.clientY);
      this.pointers.set(e.pointerId, { ...p, type: e.pointerType });
      if (this.pointers.size === 2) {
        const [a, b] = [...this.pointers.values()];
        this.gesture = {
          kind: 'pinch',
          dist: Math.hypot(a.x - b.x, a.y - b.y),
          scale: this.scale,
          cx: (a.x + b.x) / 2,
          cy: (a.y + b.y) / 2,
          tx: this.tx,
          ty: this.ty,
        };
        this.clearGhost();
        return;
      }
      if (this.pointers.size > 2) return;
      const hex = this.hexAt(e.clientX, e.clientY);
      this.gesture = { kind: 'press', x: p.x, y: p.y, hex, mouse: e.pointerType === 'mouse', tx: this.tx, ty: this.ty };
    });
    svg.addEventListener('pointermove', (e) => {
      const p = this.local(e.clientX, e.clientY);
      if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, { ...p, type: e.pointerType });
      const g = this.gesture;
      if (g.kind === 'pinch' && this.pointers.size === 2) {
        const [a, b] = [...this.pointers.values()];
        const dist = Math.hypot(a.x - b.x, a.y - b.y);
        const next = Math.max(0.2, Math.min(4, (g.scale * dist) / Math.max(1, g.dist)));
        const cx = (a.x + b.x) / 2;
        const cy = (a.y + b.y) / 2;
        this.tx = cx - ((g.cx - g.tx) * next) / g.scale;
        this.ty = cy - ((g.cy - g.ty) * next) / g.scale;
        this.scale = next;
        this.applyTransform();
        return;
      }
      if (g.kind === 'press') {
        if (Math.hypot(p.x - g.x, p.y - g.y) < 6) return;
        // Mouse drags on a stack move counters; touch drags always pan so the
        // map can be scrolled without accidental moves.
        const stack = g.hex ? this.state?.stacks.get(g.hex) : undefined;
        if (g.mouse && g.hex && stack?.length) {
          this.gesture = { kind: 'drag', from: g.hex };
          this.showGhost(g.hex);
        } else {
          this.gesture = { kind: 'pan', x: g.x, y: g.y, tx: g.tx, ty: g.ty };
        }
      }
      const g2 = this.gesture;
      if (g2.kind === 'pan') {
        this.tx = g2.tx + (p.x - g2.x);
        this.ty = g2.ty + (p.y - g2.y);
        this.applyTransform();
      } else if (g2.kind === 'drag') {
        this.moveGhost(p.x, p.y);
        this.setHover(this.hexAt(e.clientX, e.clientY));
      } else if (e.pointerType === 'mouse' && g2.kind === 'idle') {
        this.setHover(this.hexAt(e.clientX, e.clientY));
      }
    });
    const end = (e: PointerEvent, cancelled: boolean) => {
      this.pointers.delete(e.pointerId);
      const g = this.gesture;
      if (g.kind === 'pinch') {
        if (this.pointers.size === 0) this.gesture = { kind: 'idle' };
        else {
          // Continue as a pan with the remaining finger.
          const [rest] = [...this.pointers.values()];
          this.gesture = { kind: 'pan', x: rest.x, y: rest.y, tx: this.tx, ty: this.ty };
        }
        return;
      }
      if (this.pointers.size) return;
      this.gesture = { kind: 'idle' };
      if (cancelled) {
        this.clearGhost();
        return;
      }
      if (g.kind === 'press') {
        if (e.pointerType !== 'mouse') this.setHover(g.hex);
        this.events.tap(g.hex);
      } else if (g.kind === 'drag') {
        this.clearGhost();
        const to = this.hexAt(e.clientX, e.clientY);
        if (to && to !== g.from) this.events.dragMove(g.from, to);
        this.renderOverlay();
      }
    };
    svg.addEventListener('pointerup', (e) => end(e, false));
    svg.addEventListener('pointercancel', (e) => end(e, true));
    svg.addEventListener('pointerleave', (e) => {
      if (e.pointerType === 'mouse' && this.gesture.kind === 'idle') this.setHover(null);
    });
    svg.addEventListener(
      'wheel',
      (e) => {
        e.preventDefault();
        const p = this.local(e.clientX, e.clientY);
        this.zoomBy(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0018)), p.x, p.y);
      },
      { passive: false },
    );
  }

  private dragIds: string[] = [];
  /** Counters a drag from `hex` would carry: set by the app to the current selection. */
  setDragSelection(ids: string[]): void {
    this.dragIds = ids;
  }

  private showGhost(hex: string): void {
    const state = this.state;
    if (!state) return;
    const ids = this.overlayState.selected === hex && this.dragIds.length ? this.dragIds : (state.stacks.get(hex) ?? []);
    const top = state.counters.get(ids[ids.length - 1]);
    if (!top) return;
    this.ghost.innerHTML = `<g opacity="0.85">${counterSvg(top)}</g>${
      ids.length > 1
        ? `<text x="${COUNTER / 2 + 4}" y="${-COUNTER / 2}" font-size="9" font-weight="800" fill="#1d1a12">×${ids.length}</text>`
        : ''
    }`;
    this.ghost.style.display = '';
  }

  private moveGhost(x: number, y: number): void {
    const gx = (x - this.tx) / this.scale;
    const gy = (y - this.ty) / this.scale;
    this.ghost.setAttribute('transform', `translate(${gx.toFixed(1)} ${gy.toFixed(1)})`);
  }

  private clearGhost(): void {
    this.ghost.innerHTML = '';
    this.ghost.style.display = 'none';
  }
}