+/* Lines of Action - SVG rendering and interaction handling
+ *
+ * Supports click-to-select/click-to-move, mouse drag, and touch drag
+ * via unified pointer events.
+ */
+
+class LoaView {
+ constructor(container, callbacks) {
+ this.callbacks = callbacks; /* { onMove(x1,y1,x2,y2) } */
+ this.pieceElements = new Map(); /* "x,y" -> <g> element */
+ this.interactive = false;
+ this.currentPlayer = null; /* which color can be moved */
+
+ /* Selection state */
+ this.hasSelected = false;
+ this.selectedX = -1;
+ this.selectedY = -1;
+
+ /* Drag state: 'idle' | 'pending' | 'dragging' */
+ this.dragState = 'idle';
+ this.dragSourceX = -1;
+ this.dragSourceY = -1;
+ this.dragStartClientX = 0;
+ this.dragStartClientY = 0;
+ this.ghostPiece = null;
+
+ const NS = "http://www.w3.org/2000/svg";
+ this.svg = document.createElementNS(NS, "svg");
+ this.svg.setAttribute("viewBox", "-30 -30 860 860");
+ container.appendChild(this.svg);
+
+ /* Defs: gradients, filters */
+ const defs = this._el("defs");
+
+ const gBlack = this._el("radialGradient", { id: "grad-black", cx: "40%", cy: "35%", r: "55%" });
+ gBlack.appendChild(this._el("stop", { offset: "0%", "stop-color": "#555" }));
+ gBlack.appendChild(this._el("stop", { offset: "100%", "stop-color": "#111" }));
+ defs.appendChild(gBlack);
+
+ const gWhite = this._el("radialGradient", { id: "grad-white", cx: "40%", cy: "35%", r: "55%" });
+ gWhite.appendChild(this._el("stop", { offset: "0%", "stop-color": "#fff" }));
+ gWhite.appendChild(this._el("stop", { offset: "100%", "stop-color": "#ccc" }));
+ defs.appendChild(gWhite);
+
+ const shadow = this._el("filter", { id: "piece-shadow", x: "-20%", y: "-20%", width: "140%", height: "140%" });
+ shadow.appendChild(this._el("feDropShadow", { dx: "1.5", dy: "2", stdDeviation: "2.5", "flood-color": "#000", "flood-opacity": "0.35" }));
+ defs.appendChild(shadow);
+
+ const glow = this._el("filter", { id: "sel-glow", x: "-50%", y: "-50%", width: "200%", height: "200%" });
+ const blur = this._el("feGaussianBlur", { in: "SourceGraphic", stdDeviation: "4", result: "blur" });
+ glow.appendChild(blur);
+ const merge = this._el("feMerge");
+ merge.appendChild(this._el("feMergeNode", { in: "blur" }));
+ merge.appendChild(this._el("feMergeNode", { in: "SourceGraphic" }));
+ glow.appendChild(merge);
+ defs.appendChild(glow);
+
+ /* Lift shadow for dragged pieces */
+ const liftShadow = this._el("filter", { id: "drag-shadow", x: "-30%", y: "-30%", width: "160%", height: "160%" });
+ liftShadow.appendChild(this._el("feDropShadow", { dx: "3", dy: "5", stdDeviation: "5", "flood-color": "#000", "flood-opacity": "0.45" }));
+ defs.appendChild(liftShadow);
+
+ this.svg.appendChild(defs);
+
+ /* Layer groups */
+ this.squaresLayer = this._el("g");
+ this.piecesLayer = this._el("g");
+ this.selectionLayer = this._el("g");
+ this.dragLayer = this._el("g");
+ this.svg.appendChild(this.squaresLayer);
+ this.svg.appendChild(this.piecesLayer);
+ this.svg.appendChild(this.selectionLayer);
+ this.svg.appendChild(this.dragLayer);
+
+ this._drawBoard();
+ this._setupPointerEvents();
+ }
+
+ _el(tag, attrs) {
+ const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
+ if (attrs) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
+ return el;
+ }
+
+ _drawBoard() {
+ const cols = "ABCDEFGH";
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ const rect = this._el("rect", {
+ x: x * 100, y: y * 100,
+ width: 100, height: 100,
+ fill: (x + y) % 2 === 0 ? "#e3b366" : "#420503"
+ });
+ this.squaresLayer.appendChild(rect);
+ }
+ }
+ for (let i = 0; i < BOARD_SIZE; i++) {
+ const colLabel = this._el("text", {
+ x: i * 100 + 50, y: 820,
+ "text-anchor": "middle", fill: "#d4b896",
+ "font-size": "18", "font-family": "sans-serif"
+ });
+ colLabel.textContent = cols[i];
+ this.squaresLayer.appendChild(colLabel);
+
+ const rowLabel = this._el("text", {
+ x: -12, y: i * 100 + 56,
+ "text-anchor": "middle", fill: "#d4b896",
+ "font-size": "18", "font-family": "sans-serif"
+ });
+ rowLabel.textContent = String(BOARD_SIZE - i);
+ this.squaresLayer.appendChild(rowLabel);
+ }
+ }
+
+ _svgCoords(e) {
+ const pt = this.svg.createSVGPoint();
+ pt.x = e.clientX;
+ pt.y = e.clientY;
+ return pt.matrixTransform(this.svg.getScreenCTM().inverse());
+ }
+
+ _boardCoords(svgPt) {
+ const x = Math.floor(svgPt.x / 100);
+ const y = Math.floor(svgPt.y / 100);
+ if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE)
+ return null;
+ return { x, y };
+ }
+
+ _isOwnPiece(x, y) {
+ if (this.currentPlayer === null) return false;
+ /* currentPlayer is the board cell value (CELL_BLACK or CELL_WHITE)
+ * that the local player controls. */
+ const key = `${x},${y}`;
+ /* We check the actual board state via the piece elements and
+ * the data attribute we set on them. */
+ const el = this.pieceElements.get(key);
+ return el && el.dataset.cell === String(this.currentPlayer);
+ }
+
+ _setupPointerEvents() {
+ this.svg.style.touchAction = "none";
+
+ this.svg.addEventListener("pointerdown", (e) => {
+ if (!this.interactive) return;
+ e.preventDefault();
+
+ const svgPt = this._svgCoords(e);
+ const bc = this._boardCoords(svgPt);
+ if (!bc) return;
+ const { x, y } = bc;
+
+ if (this.hasSelected) {
+ /* Clicking the selected piece deselects. */
+ if (x === this.selectedX && y === this.selectedY) {
+ this._deselect();
+ return;
+ }
+ /* Clicking another own piece switches selection. */
+ if (this._isOwnPiece(x, y)) {
+ this._select(x, y);
+ /* Start pending drag on the newly selected piece. */
+ this.dragState = 'pending';
+ this.dragSourceX = x;
+ this.dragSourceY = y;
+ this.dragStartClientX = e.clientX;
+ this.dragStartClientY = e.clientY;
+ this.svg.setPointerCapture(e.pointerId);
+ return;
+ }
+ /* Clicking empty/opponent square: attempt move. */
+ this._attemptMove(this.selectedX, this.selectedY, x, y);
+ return;
+ }
+
+ /* Nothing selected yet. Start interaction on own piece. */
+ if (this._isOwnPiece(x, y)) {
+ this.dragState = 'pending';
+ this.dragSourceX = x;
+ this.dragSourceY = y;
+ this.dragStartClientX = e.clientX;
+ this.dragStartClientY = e.clientY;
+ this.svg.setPointerCapture(e.pointerId);
+ }
+ });
+
+ this.svg.addEventListener("pointermove", (e) => {
+ if (this.dragState === 'idle') return;
+ e.preventDefault();
+
+ if (this.dragState === 'pending') {
+ const dx = e.clientX - this.dragStartClientX;
+ const dy = e.clientY - this.dragStartClientY;
+ if (dx * dx + dy * dy < 25) return; /* 5px threshold */
+ this._startDrag(this.dragSourceX, this.dragSourceY);
+ }
+
+ if (this.dragState === 'dragging') {
+ const svgPt = this._svgCoords(e);
+ this.ghostPiece.style.transform =
+ `translate(${svgPt.x}px, ${svgPt.y}px)`;
+ }
+ });
+
+ this.svg.addEventListener("pointerup", (e) => {
+ if (this.dragState === 'idle') return;
+ e.preventDefault();
+
+ if (this.dragState === 'dragging') {
+ this._endDrag(e);
+ } else if (this.dragState === 'pending') {
+ /* No drag happened — treat as a click to select. */
+ this._select(this.dragSourceX, this.dragSourceY);
+ }
+
+ this.dragState = 'idle';
+ try { this.svg.releasePointerCapture(e.pointerId); }
+ catch (_) { /* ignore if not captured */ }
+ });
+
+ this.svg.addEventListener("pointercancel", (e) => {
+ if (this.dragState === 'dragging') {
+ this._cleanupDrag();
+ }
+ this.dragState = 'idle';
+ });
+ }
+
+ _select(x, y) {
+ this.hasSelected = true;
+ this.selectedX = x;
+ this.selectedY = y;
+ this.showSelection(x, y);
+ }
+
+ _deselect() {
+ this.hasSelected = false;
+ this.clearSelection();
+ }
+
+ _attemptMove(x1, y1, x2, y2) {
+ this._deselect();
+ this.callbacks.onMove(x1, y1, x2, y2);
+ }
+
+ _startDrag(x, y) {
+ this.dragState = 'dragging';
+ this._select(x, y);
+
+ /* Dim the original piece. */
+ const key = `${x},${y}`;
+ const piece = this.pieceElements.get(key);
+ if (piece) piece.style.opacity = "0.35";
+
+ /* Create ghost piece in the drag layer. */
+ const cell = parseInt(piece.dataset.cell);
+ const isBlack = cell === CELL_BLACK;
+ const g = this._el("g", { class: "piece ghost" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+
+ const circle = this._el("circle", {
+ r: 40,
+ fill: isBlack ? "url(#grad-black)" : "url(#grad-white)",
+ stroke: isBlack ? "#6b4a2a" : "#8b7355",
+ "stroke-width": "1.5",
+ filter: "url(#drag-shadow)"
+ });
+ g.appendChild(circle);
+ this.dragLayer.appendChild(g);
+ this.ghostPiece = g;
+ }
+
+ _endDrag(e) {
+ const svgPt = this._svgCoords(e);
+ const bc = this._boardCoords(svgPt);
+
+ this._cleanupDrag();
+
+ if (bc && (bc.x !== this.dragSourceX || bc.y !== this.dragSourceY)) {
+ this._attemptMove(this.dragSourceX, this.dragSourceY, bc.x, bc.y);
+ }
+ }
+
+ _cleanupDrag() {
+ /* Remove ghost. */
+ if (this.ghostPiece) {
+ this.ghostPiece.remove();
+ this.ghostPiece = null;
+ }
+ /* Restore original piece opacity. */
+ const key = `${this.dragSourceX},${this.dragSourceY}`;
+ const piece = this.pieceElements.get(key);
+ if (piece) piece.style.opacity = "";
+ }
+
+ /* Set which player color the local user controls and whether
+ * interaction is enabled. */
+ setInteractive(canMove, playerCell) {
+ this.interactive = canMove;
+ this.currentPlayer = playerCell;
+ }
+
+ renderPieces(board) {
+ const newKeys = new Set();
+
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ const cell = board.cells[x][y];
+ if (cell === CELL_EMPTY) continue;
+ const key = `${x},${y}`;
+ newKeys.add(key);
+ if (!this.pieceElements.has(key)) {
+ const g = this._createPiece(x, y, cell);
+ this.piecesLayer.appendChild(g);
+ this.pieceElements.set(key, g);
+ }
+ }
+ }
+
+ for (const [key, el] of this.pieceElements) {
+ if (!newKeys.has(key)) {
+ el.remove();
+ this.pieceElements.delete(key);
+ }
+ }
+ }
+
+ _createPiece(x, y, cell) {
+ const g = this._el("g", { class: "piece" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+ g.dataset.cell = String(cell);
+
+ const isBlack = cell === CELL_BLACK;
+ const circle = this._el("circle", {
+ r: 40,
+ fill: isBlack ? "url(#grad-black)" : "url(#grad-white)",
+ stroke: isBlack ? "#6b4a2a" : "#8b7355",
+ "stroke-width": "1.5",
+ filter: "url(#piece-shadow)"
+ });
+ g.appendChild(circle);
+ return g;
+ }
+
+ animateMove(x1, y1, x2, y2, captured, callback) {
+ const fromKey = `${x1},${y1}`;
+ const toKey = `${x2},${y2}`;
+ const piece = this.pieceElements.get(fromKey);
+ if (!piece) { if (callback) callback(); return; }
+
+ if (captured) {
+ const target = this.pieceElements.get(toKey);
+ if (target) {
+ target.classList.add("captured");
+ setTimeout(() => target.remove(), 300);
+ this.pieceElements.delete(toKey);
+ }
+ }
+
+ this.pieceElements.delete(fromKey);
+ this.pieceElements.set(toKey, piece);
+
+ piece.style.transform = `translate(${x2 * 100 + 50}px, ${y2 * 100 + 50}px)`;
+
+ setTimeout(() => { if (callback) callback(); }, 310);
+ }
+
+ showSelection(x, y) {
+ this.clearSelection();
+
+ const g = this._el("g", { id: "selection-ring" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+
+ const ring = this._el("circle", {
+ r: 42, fill: "none",
+ stroke: "#4488ff", "stroke-width": "3",
+ filter: "url(#sel-glow)", opacity: "0.8"
+ });
+ g.appendChild(ring);
+ this.selectionLayer.appendChild(g);
+
+ const key = `${x},${y}`;
+ const piece = this.pieceElements.get(key);
+ if (piece) piece.classList.add("selected");
+ }
+
+ clearSelection() {
+ this.selectionLayer.innerHTML = "";
+ for (const el of this.pieceElements.values()) {
+ el.classList.remove("selected");
+ }
+ }
+
+ showWin(winner, board) {
+ const rings = [];
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ if (board.cells[x][y] === winner) {
+ const g = this._el("g", { class: "win-ring" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+ const ring = this._el("circle", {
+ r: 46, fill: "none",
+ stroke: "#4488ff", "stroke-width": "4",
+ filter: "url(#sel-glow)"
+ });
+ g.appendChild(ring);
+ this.selectionLayer.appendChild(g);
+ rings.push(g);
+ }
+ }
+ }
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ rings.forEach(g => g.classList.add("visible"));
+ });
+ });
+ }
+}