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.
// 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';
}
}
