From 54d9b6dd2d1b63f56508a80e76e79d3ec377308a Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 3 Sep 2024 11:38:56 -0600 Subject: [PATCH 1/9] Reduce number of dom objects and operations in SVG renderer --- examples/main.tsx | 4 +- src/Goban/InteractiveBase.ts | 6 +- src/Goban/SVGRenderer.ts | 5029 +++++++++++++++++++++++----------- 3 files changed, 3445 insertions(+), 1594 deletions(-) diff --git a/examples/main.tsx b/examples/main.tsx index a6fceff0..176e0e54 100644 --- a/examples/main.tsx +++ b/examples/main.tsx @@ -376,8 +376,8 @@ function ReactGoban( let i = 0; const start = Date.now(); - //const NUM_MOVES = 300; - const NUM_MOVES = 20; + const NUM_MOVES = 300; + // const NUM_MOVES = 20; const interval = setInterval(() => { i++; if (i >= NUM_MOVES) { diff --git a/src/Goban/InteractiveBase.ts b/src/Goban/InteractiveBase.ts index ef64ff47..fbdd3f29 100644 --- a/src/Goban/InteractiveBase.ts +++ b/src/Goban/InteractiveBase.ts @@ -239,8 +239,8 @@ export abstract class GobanInteractive extends GobanBase { protected display_width?: number; protected done_loading_review: boolean = false; protected dont_draw_last_move: boolean; - protected last_move_radius: number; - protected circle_radius: number; + public last_move_radius: number; + public circle_radius: number; protected edit_color?: "black" | "white"; protected errorHandler: (e: Error) => void; protected heatmap?: NumberMatrix; @@ -274,7 +274,7 @@ export abstract class GobanInteractive extends GobanBase { protected shift_key_is_down: boolean; //protected show_move_numbers: boolean; protected show_variation_move_numbers: boolean; - protected square_size: number = 10; + public square_size: number = 10; protected stone_placement_enabled: boolean; protected sendLatencyTimer?: ReturnType; diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index 918be74c..ad91d4a2 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -37,6 +37,7 @@ import { } from "../engine/util"; import { callbacks } from "./callbacks"; import { Goban, GobanMetrics, GobanSelectedThemes } from "./Goban"; +import { ColoredCircle } from "./InteractiveBase"; //import { GobanCanvasConfig, GobanCanvasInterface } from "./GobanCanvas"; @@ -115,7 +116,8 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { private lines_layer?: SVGGraphicsElement; private coordinate_labels_layer?: SVGGraphicsElement; private grid: Array> = []; - private grid_layer?: SVGGraphicsElement; + public grid_layer?: SVGGraphicsElement; + private cells: Array> = []; private shadow_grid: Array> = []; private shadow_layer?: SVGGraphicsElement; private pen_layer?: SVGGraphicsElement; @@ -131,7 +133,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { private labeling_mode?: "put" | "clear"; private last_label_position: { i: number; j: number } = { i: NaN, j: NaN }; private last_pen_position?: [number, number]; - protected metrics: GobanMetrics = { width: NaN, height: NaN, mid: NaN, offset: NaN }; + public metrics: GobanMetrics = { width: NaN, height: NaN, mid: NaN, offset: NaN }; private drawing_enabled: boolean = true; protected title_div?: HTMLElement; @@ -143,20 +145,20 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { "removal-graphic": "x", "removal-scale": 1.0, }; - private theme_black!: GobanTheme; + public theme_black!: GobanTheme; private theme_black_stones: Array = []; - private theme_black_text_color: string = HOT_PINK; + public theme_black_text_color: string = HOT_PINK; private theme_blank_text_color: string = HOT_PINK; private theme_board!: GobanTheme; - private theme_faded_line_color: string = HOT_PINK; - private theme_faded_star_color: string = HOT_PINK; + public theme_faded_line_color: string = HOT_PINK; + public theme_faded_star_color: string = HOT_PINK; //private theme_faded_text_color:string; private theme_line_color: string = ""; private theme_star_color: string = ""; private theme_stone_radius: number = 10; - private theme_white!: GobanTheme; + public theme_white!: GobanTheme; private theme_white_stones: Array = []; - private theme_white_text_color: string = HOT_PINK; + public theme_white_text_color: string = HOT_PINK; constructor(config: SVGRendererGobanConfig, preloaded_data?: AdHocFormat | JGOF) { /* TODO: Need to reconcile the clock fields before we can get rid of this `any` cast */ @@ -206,7 +208,6 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.setTheme(this.getSelectedThemes(), true); const watcher = this.watchSelectedThemes((themes: GobanSelectedThemes) => { if (!this.engine) { - console.error("Engine not initialized yet, ignoring theme change"); return; } delete __theme_cache.black?.["Custom"]; @@ -233,6 +234,20 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.detachPenLayer(); } + public cell(i: number, j: number): GCell { + if (!this.cells[j]) { + this.cells[j] = []; + } + if (!this.cells[j][i]) { + this.cells[j][i] = new GCell(this, i, j); + } + return this.cells[j][i]; + } + + public clearCells(): void { + this.cells = []; + } + public override destroy(): void { super.destroy(); @@ -1188,30 +1203,30 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { if (i < 0 || j < 0 || !this.drawing_enabled || this.no_display) { return; } - if (this.__draw_state[j][i] !== this.drawingHash(i, j)) { - this.__drawSquare(i, j); + + if (!this.grid_layer) { + return; + } + + const CELLS = false; + + if (CELLS) { + this.cellDraw(i, j); + } else { + if (this.__draw_state[j][i] !== this.drawingHash(i, j)) { + this.__drawSquare(i, j); + } } } - private __drawSquare(i: number, j: number): void { - if (!this.drawing_enabled || this.no_display || !this.grid || !this.grid[j]) { + private cellDraw(i: number, j: number): void { + if (!this.drawing_enabled || this.no_display) { return; } if (i < 0 || j < 0) { return; } - let cell = this.grid[j][i]; - const shadow_cell = this.shadow_grid[j][i]; - - if (cell) { - cell.remove(); - } - if (shadow_cell) { - shadow_cell.remove(); - } - - cell = this.grid[j][i] = document.createElementNS("http://www.w3.org/2000/svg", "g"); - this.grid_layer!.appendChild(cell); + const cell = this.cell(i, j); const removed_stone_scale = this.themes["removal-scale"]; const ss = this.square_size; @@ -1225,14 +1240,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { } const l = i * ss + ox; - //const r = (i + 1) * ss + ox; const t = j * ss + oy; - //const b = (j + 1) * ss + oy; - - //const cx = l + this.metrics.mid; - //const cy = t + this.metrics.mid; - const cx = this.metrics.mid; - const cy = this.metrics.mid; let transform = `translate(${l},${t})`; @@ -1302,48 +1310,6 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { /* Fade our lines if we have text to draw */ if (have_text_to_draw) { - /* draw lighter colored lines */ - let sx = 0; - let ex = ss; - const mx = ss / 2 - this.metrics.offset; - let sy = 0; - let ey = ss; - const my = ss / 2 - this.metrics.offset; - - if (i === 0) { - sx += this.metrics.mid; - } - if (i === this.width - 1) { - ex -= this.metrics.mid; - } - if (j === 0) { - sy += this.metrics.mid; - } - if (j === this.height - 1) { - ey -= this.metrics.mid; - } - - if (i === this.width - 1 && j === this.height - 1) { - if (mx === ex && my === ey) { - ex += 1; - ey += 1; - } - } - - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("stroke", this.theme_faded_line_color); - path.setAttribute("stroke-width", this.square_size < 5 ? "0.2" : "1"); - path.setAttribute("fill", "none"); - path.setAttribute( - "d", - ` - M ${Math.floor(sx)} ${my} L ${Math.floor(ex)} ${my} - M ${mx} ${Math.floor(sy)} L ${mx} ${Math.floor(ey)} - `, - ); - cell.appendChild(path); - - /* Draw star points */ let star_radius; if (this.square_size < 5) { star_radius = 0.5; @@ -1381,76 +1347,36 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { draw_star_point = true; } - if (draw_star_point) { - const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circ.setAttribute("cx", cx.toString()); - circ.setAttribute("cy", cy.toString()); - circ.setAttribute("r", star_radius.toString()); - circ.setAttribute("fill", this.theme_faded_star_color); - cell.appendChild(circ); - } + cell.drawFadedIntersectionLines(draw_star_point, star_radius); + } else { + cell.clearFadedLines(); } /* Heatmap */ - if (this.heatmap) { - if (this.heatmap[j][i] > 0.001) { - const color = "#00FF00"; - const r = Math.floor(this.square_size * 0.5) - 0.5; - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", (cx - r).toFixed(1)); - rect.setAttribute("y", (cy - r).toFixed(1)); - rect.setAttribute("width", (r * 2).toFixed(1)); - rect.setAttribute("height", (r * 2).toFixed(1)); - rect.setAttribute("fill-opacity", Math.min(this.heatmap[j][i], 0.5).toFixed(2)); - rect.setAttribute("fill", color); - cell.appendChild(rect); - } + if (this.heatmap && this.heatmap[j][i] > 0.001) { + cell.heatmap(this.heatmap[j][i]); + } else { + cell.clearHeatmap(); } /* Draw square highlights if any */ - { - if ( - pos.hint || - (this.highlight_movetree_moves && movetree_contains_this_square) || - pos.color - ) { - const color = pos.color ? pos.color : pos.hint ? "#8EFF0A" : "#FF8E0A"; - const r = Math.floor(this.square_size * 0.5) - 0.5; - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", (cx - r).toFixed(1)); - rect.setAttribute("y", (cy - r).toFixed(1)); - rect.setAttribute("width", (r * 2).toFixed(1)); - rect.setAttribute("height", (r * 2).toFixed(1)); - rect.setAttribute("fill-opacity", "0.6"); - rect.setAttribute("fill", color); - cell.appendChild(rect); - } + if ( + pos.hint || + (this.highlight_movetree_moves && movetree_contains_this_square) || + pos.color + ) { + const color = pos.color ? pos.color : pos.hint ? "#8EFF0A" : "#FF8E0A"; + cell.highlight(color); + } else { + cell.clearHighlight(); } /* Colored stones */ const circle = this.colored_circles?.[j][i]; if (circle) { - const radius = Math.floor(this.square_size * 0.5) - 0.5; - let lineWidth = radius * (circle.border_width || 0.1); - if (lineWidth < 0.3) { - lineWidth = 0; - } - - const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circ.setAttribute("class", "colored-circle"); - circ.setAttribute("fill", circle.color); - if (circle.border_color) { - circ.setAttribute("stroke", circle.border_color); - } - if (lineWidth > 0) { - circ.setAttribute("stroke-width", lineWidth.toFixed(1)); - } else { - circ.setAttribute("stroke-width", "1px"); - } - circ.setAttribute("cx", cx.toString()); - circ.setAttribute("cy", cy.toString()); - circ.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); - cell.appendChild(circ); + cell.circle(circle); + } else { + cell.clearCircle(); } /* Draw stones & hovers */ @@ -1578,71 +1504,25 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { const stone_transparent = translucent || !stone_color; - if (color === 1) { - const stone = this.theme_black.getStone( - i, - j, - this.theme_black_stones, - this, - ); - const [elt, shadow] = this.theme_black.placeBlackStoneSVG( - cell, - stone_transparent ? undefined : this.shadow_layer, - stone, - cx, - cy, - this.theme_stone_radius, - ); - this.shadow_grid[j][i] = shadow; - if (stone_transparent) { - elt.setAttribute("opacity", stoneAlphaValue.toString()); - } - } else { - const stone = this.theme_white.getStone( - i, - j, - this.theme_white_stones, - this, - ); - const [elt, shadow] = this.theme_white.placeWhiteStoneSVG( - cell, - stone_transparent ? undefined : this.shadow_layer, - stone, - cx, - cy, - this.theme_stone_radius, - ); - this.shadow_grid[j][i] = shadow; - if (stone_transparent) { - elt.setAttribute("opacity", stoneAlphaValue.toString()); - } - } + cell.stone( + color, + stone_transparent, + color === JGOFNumericPlayerColor.BLACK + ? this.theme_black.getStone(i, j, this.theme_black_stones, this) + : this.theme_white.getStone(i, j, this.theme_white_stones, this), + this.theme_stone_radius, + stoneAlphaValue, + ); + } else { + cell.clearStone(); } /** Draw the circle around the blue move */ const circle = this.colored_circles?.[j][i]; if (pos.blue_move && circle) { - const radius = Math.floor(this.square_size * 0.5) - 0.5; - let lineWidth = radius * (circle.border_width || 0.1); - if (lineWidth < 0.3) { - lineWidth = 0; - } - - const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circ.setAttribute("class", "colored-circle"); - if (circle.border_color) { - circ.setAttribute("stroke", circle.border_color || "#000000"); - } - if (lineWidth > 0) { - circ.setAttribute("stroke-width", `${lineWidth.toFixed(1)}px`); - } else { - circ.setAttribute("stroke-width", "1px"); - } - circ.setAttribute("fill", "none"); - circ.setAttribute("cx", cx.toString()); - circ.setAttribute("cy", cy.toString()); - circ.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); - cell.appendChild(circ); + cell.blueMove(circle.border_color || "#000000", circle.border_width || 0.1); + } else { + cell.clearBlueMove(); } /* Red X if the stone is marked for removal */ @@ -1664,6 +1544,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { t + this.metrics.mid * (1.0 - removed_stone_scale) }) scale(${removed_stone_scale})`; } + } else { + cell.clearStone(); + cell.clearBlueMove(); } } @@ -1682,197 +1565,126 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.last_hover_square.x === i && this.last_hover_square.y === j) ) { - let r = Math.max(1, this.metrics.mid * 0.5); - const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); - cross.setAttribute("class", "removal-cross"); const color = this.engine.board[j][i] === JGOFNumericPlayerColor.BLACK ? "black" : "white"; + let fill = ""; + const mid = this.metrics.mid; + let r = Math.max(1, mid * 0.5); + if (pos.score === "black" && color === "white") { - cross.setAttribute("fill", this.theme_white_text_color); + fill = this.theme_white_text_color; } else if (pos.score === "white" && color === "black") { - cross.setAttribute("fill", this.theme_black_text_color); + fill = this.theme_black_text_color; } else if ( (pos.score === "white" && color === "white") || (pos.score === "black" && color === "black") ) { // score point for the same color where the stone is removed // should call special attention to it - cross.setAttribute("fill", "#ff0000"); + fill = "#ff0000"; red_x = true; - r = Math.max(1, this.metrics.mid * 0.65); + r = Math.max(1, mid * 0.65); } else { // otherwise, no score but removed stones can happen when // territory isn't properly sealed, so we are going to mark // it grey to avoid calling too much attention, but still // denote that removing these stones doesn't result in // the territory being territory yet. - cross.setAttribute("fill", "#777777"); + fill = "#777777"; } + const opacity = this.engine.board[j][i] ? 1.0 : 0.2; + cell.removalCross(fill, r, opacity); + } else { + cell.clearRemovalCross(); + } - /* four dagger tip points with a square in the center. Start at top left going clockwise*/ - const dx = r * 0.25; // tip width - const ir = r * 0.3; // inner radius for our box - const ir_dx = ir * 0.4; // offset to where our daggers meet the box + /* Draw Scores */ + if ( + (pos.score && + (!draw_removal_x || red_x) && + (this.engine.phase !== "finished" || + this.mode === "play" || + this.mode === "analyze")) || + (this.scoring_mode && + this.score_estimator && + (this.score_estimator.territory[j][i] || + (this.score_estimator.removal[j][i] && + this.score_estimator.board[j][i] === 0))) || + ((this.engine.phase === "stone removal" || + (this.engine.phase === "finished" && this.mode === "play")) && + this.engine.board[j][i] === 0 && + (this.engine.removal[j][i] || pos.needs_sealing)) || + (this.mode === "analyze" && + this.analyze_tool === "score" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j) + ) { + let color = pos.score; - // prettier-ignore - const points = [ - /* top half */ - -r , -r , - -r + dx , -r , - -ir + ir_dx , -ir - ir_dx , - ir - ir_dx , -ir - ir_dx , - r - dx , -r , - r , -r , + if ( + this.scoring_mode && + this.score_estimator && + (this.score_estimator.territory[j][i] || + (this.score_estimator.removal[j][i] && this.score_estimator.board[j][i] === 0)) + ) { + color = this.score_estimator.territory[j][i] === 1 ? "black" : "white"; + if (this.score_estimator.board[j][i] === 0 && this.score_estimator.removal[j][i]) { + color = "dame"; + } + } - /* right half */ - r , -r + dx , - ir + ir_dx , -ir + ir_dx , - ir + ir_dx , ir - ir_dx , - r , r - dx , - r , r , + if ( + (this.engine.phase === "stone removal" || + (this.engine.phase === "finished" && this.mode === "play")) && + this.engine.board[j][i] === 0 && + this.engine.removal[j][i] + ) { + color = "dame"; + } - /* bottom half */ - r - dx , r , - ir - ir_dx , ir + ir_dx , - -ir + ir_dx , ir + ir_dx , - -r + dx , r , - -r , r , - - /* left half */ - -r , r - dx , - -ir - ir_dx , ir - ir_dx , - -ir - ir_dx , -ir + ir_dx , - -r , -r + dx , - //-r , -r , - ]; - - const path = - points - .map((v, i) => { - return (i % 2 === 0 ? (i === 0 ? "M" : "L") : " ") + v; - }) - .join(" ") + " Z"; - - cross.setAttribute("stroke", "#888888"); - cross.setAttribute("stroke-width", `${this.square_size * 0.0275}px`); - //cross.setAttribute("fill", "none"); - cross.setAttribute( - "d", - path, - /* - ` - M ${cx - r} ${cy - r} - L ${cx + r} ${cy + r} - M ${cx + r} ${cy - r} - L ${cx - r} ${cy + r} - `, - */ - ); - const opacity = this.engine.board[j][i] ? 1.0 : 0.2; - cross.setAttribute("stroke-opacity", opacity?.toString()); - cross.setAttribute("transform", `translate(${cx}, ${cy})`); - - cell.appendChild(cross); - } + if (pos.needs_sealing) { + color = "seal"; + } - /* Draw Scores */ - { if ( - (pos.score && - (!draw_removal_x || red_x) && - (this.engine.phase !== "finished" || - this.mode === "play" || - this.mode === "analyze")) || - (this.scoring_mode && - this.score_estimator && - (this.score_estimator.territory[j][i] || - (this.score_estimator.removal[j][i] && - this.score_estimator.board[j][i] === 0))) || - ((this.engine.phase === "stone removal" || - (this.engine.phase === "finished" && this.mode === "play")) && - this.engine.board[j][i] === 0 && - (this.engine.removal[j][i] || pos.needs_sealing)) || - (this.mode === "analyze" && - this.analyze_tool === "score" && - this.last_hover_square && - this.last_hover_square.x === i && - this.last_hover_square.y === j) + this.mode === "analyze" && + this.analyze_tool === "score" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j ) { - let color = pos.score; - - if ( - this.scoring_mode && - this.score_estimator && - (this.score_estimator.territory[j][i] || - (this.score_estimator.removal[j][i] && - this.score_estimator.board[j][i] === 0)) - ) { - color = this.score_estimator.territory[j][i] === 1 ? "black" : "white"; - if ( - this.score_estimator.board[j][i] === 0 && - this.score_estimator.removal[j][i] - ) { - color = "dame"; - } - } - - if ( - (this.engine.phase === "stone removal" || - (this.engine.phase === "finished" && this.mode === "play")) && - this.engine.board[j][i] === 0 && - this.engine.removal[j][i] - ) { - color = "dame"; - } - - if (pos.needs_sealing) { - color = "seal"; - } - - if ( - this.mode === "analyze" && - this.analyze_tool === "score" && - this.last_hover_square && - this.last_hover_square.x === i && - this.last_hover_square.y === j - ) { - color = this.analyze_subtool; - } + color = this.analyze_subtool; + } - const r = this.square_size * 0.15; - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", (cx - r).toFixed(1)); - rect.setAttribute("y", (cy - r).toFixed(1)); - rect.setAttribute("width", (r * 2).toFixed(1)); - rect.setAttribute("height", (r * 2).toFixed(1)); - if (color === "white") { - rect.setAttribute("fill", this.theme_black_text_color); - rect.setAttribute("stroke", "#777777"); - } - if (color === "black") { - rect.setAttribute("fill", this.theme_white_text_color); - rect.setAttribute("stroke", "#888888"); - } - if (color === "dame") { - rect.setAttribute("fill-opacity", "0.2"); - rect.setAttribute("stroke", "#365FE6"); - } - if (color === "seal") { - rect.setAttribute("fill-opacity", "0.8"); - rect.setAttribute("fill", "#ff4444"); - rect.setAttribute("stroke", "#E079CE"); - } - if (color?.[0] === "#") { - rect.setAttribute("fill", color); - rect.setAttribute("stroke", color_blend("#888888", color)); - } - rect.setAttribute( - "stroke-width", - (Math.ceil(this.square_size * 0.065) - 0.5).toFixed(1), - ); - cell.appendChild(rect); + let opacity = 1.0; + let fill = ""; + let stroke = ""; + if (color === "white") { + fill = this.theme_black_text_color; + stroke = "#777777"; + } + if (color === "black") { + fill = this.theme_white_text_color; + stroke = "#888888"; + } + if (color === "dame") { + opacity = 0.2; + stroke = "#365FE6"; } + if (color === "seal") { + opacity = 0.8; + fill = "#ff4444"; + stroke = "#E079CE"; + } + if (color?.[0] === "#") { + fill = color; + stroke = color_blend("#888888", color); + } + cell.score(fill, stroke, opacity); + } else { + cell.clearScore(); } /* Draw letters and numbers */ @@ -1941,54 +1753,31 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { if (letter) { letter_was_drawn = true; - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("class", "letter"); - text.setAttribute("fill", text_color); - text.setAttribute( - "font-size", - subscript ? `${this.square_size * 0.4}px` : `${this.square_size * 0.5}px`, - ); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("x", cx.toString()); - let yy = cy; - yy += this.square_size / 6; - if (subscript) { - yy -= this.square_size * 0.15; - } - text.setAttribute("y", yy.toString()); - text.textContent = letter; - if (transparent) { - text.setAttribute("fill-opacity", "0.6"); - } - cell.appendChild(text); draw_last_move = false; + cell.letter( + letter, + text_color, + subscript ? this.square_size * 0.4 : this.square_size * 0.5, + transparent ? 0.6 : 1.0, + !!subscript, + ); + } else { + cell.clearLetter(); } if (subscript) { letter_was_drawn = true; - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("class", "subscript"); - text.setAttribute("fill", text_color); - text.setAttribute("font-size", `${this.square_size * 0.4}px`); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("x", cx.toString()); - let yy = cy; - yy -= this.square_size / 6; - if (letter) { - yy += this.square_size * 0.6; - } else { - yy += this.square_size * 0.31; - } - if (pos.sub_triangle) { - yy -= this.square_size * 0.08; - } - text.setAttribute("y", yy.toString()); - text.textContent = subscript; - if (transparent) { - text.setAttribute("fill-opacity", "0.6"); - } - cell.appendChild(text); draw_last_move = false; + cell.subscript( + subscript, + text_color, + this.square_size * 0.4, + transparent ? 0.6 : 1.0, + !!letter, + !!pos.sub_triangle, + ); + } else { + cell.clearSubscript(); } } @@ -2021,23 +1810,12 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { } if (pos.circle || hover_mark === "circle") { - const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circ.setAttribute("class", "circle"); - circ.setAttribute("fill", "none"); - circ.setAttribute("stroke", symbol_color); - circ.setAttribute("stroke-width", `${this.square_size * 0.075}px`); - circ.setAttribute("cx", cx.toString()); - circ.setAttribute("cy", cy.toString()); - circ.setAttribute( - "r", - Math.max(0.1, this.square_size * this.circle_radius).toFixed(2), - ); - if (transparent) { - circ.setAttribute("stroke-opacity", "0.6"); - } - cell.appendChild(circ); draw_last_move = false; + cell.circleSymbol(symbol_color, transparent ? 0.6 : 1.0); + } else { + cell.clearCircleSymbol(); } + if ( pos.triangle || pos.chat_triangle || @@ -2045,82 +1823,24 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { alt_marking === "triangle" || hover_mark === "triangle" ) { - let scale = 1.0; - let oy = 0.0; - let line_width = this.square_size * 0.075; - if (pos.sub_triangle) { - scale = 0.5; - oy = this.square_size * 0.3; - transparent = false; - line_width *= 0.5; - } - - const triangle = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); - triangle.setAttribute("class", "triangle"); - triangle.setAttribute("fill", "none"); - triangle.setAttribute("stroke", symbol_color); - triangle.setAttribute("stroke-width", `${line_width}px`); - const r = this.square_size * 0.3 * scale; - let theta = -(Math.PI * 2) / 4; - const points = []; - points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); - theta += (Math.PI * 2) / 3; - points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); - theta += (Math.PI * 2) / 3; - points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); - theta += (Math.PI * 2) / 3; - points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); - triangle.setAttribute( - "points", - points.map((p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" "), - ); - if (transparent) { - triangle.setAttribute("stroke-opacity", "0.6"); - } - cell.appendChild(triangle); draw_last_move = false; + cell.triangleSymbol(symbol_color, transparent ? 0.6 : 1.0, !!pos.sub_triangle); + } else { + cell.clearTriangleSymbol(); } + if (pos.cross || hover_mark === "cross") { - const r = Math.max(1, this.metrics.mid * 0.35); - const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); - cross.setAttribute("class", "cross"); - cross.setAttribute("stroke", symbol_color); - cross.setAttribute("stroke-width", `${this.square_size * 0.075}px`); - cross.setAttribute("fill", "none"); - cross.setAttribute( - "d", - ` - M ${cx - r} ${cy - r} - L ${cx + r} ${cy + r} - M ${cx + r} ${cy - r} - L ${cx - r} ${cy + r} - `, - ); - if (transparent) { - cross.setAttribute("stroke-opacity", "0.6"); - } - - cell.appendChild(cross); - draw_last_move = false; + cell.crossSymbol(symbol_color, transparent ? 0.6 : 1.0); + } else { + cell.clearCrossSymbol(); } if (pos.square || hover_mark === "square") { draw_last_move = false; - const square = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - square.setAttribute("class", "square"); - square.setAttribute("fill", "none"); - square.setAttribute("stroke", symbol_color); - square.setAttribute("stroke-width", `${this.square_size * 0.075}px`); - const r = Math.max(1, this.metrics.mid * 0.4); - square.setAttribute("x", (cx - r).toFixed(2)); - square.setAttribute("y", (cy - r).toFixed(2)); - square.setAttribute("width", (r * 2).toFixed(2)); - square.setAttribute("height", (r * 2).toFixed(2)); - if (transparent) { - square.setAttribute("stroke-opacity", "0.6"); - } - cell.appendChild(square); + cell.squareSymbol(symbol_color, transparent ? 0.6 : 1.0); + } else { + cell.clearSquareSymbol(); } } @@ -2128,7 +1848,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { if (this.last_move && this.engine && !this.last_move.is(this.engine.cur_move)) { const m = this.last_move; delete this.last_move; - this.drawSquare(m.x, m.y); + this.cell(m.x, m.y).clearLastMove(); } /* Draw last move */ @@ -2149,70 +1869,17 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { if (this.submit_move) { draw_last_move = false; - - const r = Math.max(1, this.metrics.mid * 0.35) * 0.8; - const cross = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - cross.setAttribute("class", "last-move"); - cross.setAttribute("stroke", color); - cross.setAttribute("stroke-width", `${this.square_size * 0.075}px`); - cross.setAttribute("fill", "none"); - cross.setAttribute("opacity", this.last_move_opacity.toString()); - cross.setAttribute( - "d", - ` - M ${cx - r} ${cy} - L ${cx + r} ${cy} - M ${cx} ${cy - r} - L ${cx} ${cy + r} - `, - ); - cell.appendChild(cross); + cell.lastMove("+", color, this.last_move_opacity); } else { if ( this.engine.undo_requested && this.getShowUndoRequestIndicator() && this.engine.undo_requested === this.engine.cur_move.move_number ) { - const letter = "?"; draw_last_move = false; - - const text = document.createElementNS( - "http://www.w3.org/2000/svg", - "text", - ); - text.setAttribute("class", "letter"); - text.setAttribute("fill", color); - text.setAttribute("font-size", `${this.square_size * 0.5}px`); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("x", cx.toString()); - let yy = cy; - yy += this.square_size / 6; - text.setAttribute("y", yy.toString()); - text.textContent = letter; - cell.appendChild(text); + cell.lastMove("?", color, 1.0); } else { - const circ = document.createElementNS( - "http://www.w3.org/2000/svg", - "circle", - ); - let r = this.square_size * this.last_move_radius; - if (this.submit_move) { - r = this.square_size * 0.3; - } - - r = Math.max(0.1, r); - circ.setAttribute("class", "last-move"); - circ.setAttribute("fill", "none"); - circ.setAttribute("stroke", color); - circ.setAttribute("stroke-width", `${this.square_size * 0.075}px`); - circ.setAttribute("cx", cx.toString()); - circ.setAttribute("cy", cy.toString()); - circ.setAttribute("opacity", this.last_move_opacity.toString()); - circ.setAttribute("r", r.toString()); - cell.appendChild(circ); + cell.lastMove("o", color, this.last_move_opacity); } } } @@ -2235,66 +1902,66 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { const color_num = color === "black" ? 1 : 2; if (color_num !== stone_color) { - const r = this.square_size * 0.2 * Math.abs(est); - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", (cx - r).toFixed(1)); - rect.setAttribute("y", (cy - r).toFixed(1)); - rect.setAttribute("width", (r * 2).toFixed(1)); - rect.setAttribute("height", (r * 2).toFixed(1)); - if (color === "white") { - rect.setAttribute("fill", this.theme_black_text_color); - rect.setAttribute("stroke", "#777777"); - } - if (color === "black") { - rect.setAttribute("fill", this.theme_white_text_color); - rect.setAttribute("stroke", "#888888"); - } - rect.setAttribute( - "stroke-width", - (Math.ceil(this.square_size * 0.035) - 0.5).toFixed(1), - ); - cell.appendChild(rect); + cell.scoreEstimate(color, est); + } else { + cell.clearScoreEstimate(); } + } else { + cell.clearScoreEstimate(); } - this.__draw_state[j][i] = this.drawingHash(i, j); - - cell.setAttribute("transform", transform); - - if (this.shadow_grid[j][i]) { - this.shadow_grid[j][i].setAttribute("transform", transform); - } + cell.transform = transform; + //this.__draw_state[j][i] = this.drawingHash(i, j); } - - private drawingHash(i: number, j: number): string { - if (this.no_display) { - return ""; + private __drawSquare(i: number, j: number): void { + if (!this.drawing_enabled || this.no_display || !this.grid || !this.grid[j]) { + return; } if (i < 0 || j < 0) { - return ".."; + return; } - let ret = this.square_size + ","; + let cell = this.grid[j][i]; + const shadow_cell = this.shadow_grid[j][i]; - const draw_last_move = !this.dont_draw_last_move; - let stone_color = 0; - if (this.engine) { - stone_color = this.engine.board[j][i]; + if (cell) { + cell.remove(); + } + if (shadow_cell) { + shadow_cell.remove(); } - ret += stone_color + ","; + cell = this.grid[j][i] = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.grid_layer!.appendChild(cell); - /* Draw heatmap */ - if (this.heatmap) { - if (this.heatmap[j][i] > 0.001) { - ret += "heat " + this.heatmap[j][i] + ","; - } + const removed_stone_scale = this.themes["removal-scale"]; + const ss = this.square_size; + let ox = this.draw_left_labels ? ss : 0; + let oy = this.draw_top_labels ? ss : 0; + if (this.bounds.left > 0) { + ox = -ss * this.bounds.left; + } + if (this.bounds.top > 0) { + oy = -ss * this.bounds.top; } - /* Colored stones */ - const circle = this.colored_circles?.[j][i]; - if (circle) { - ret += "circle " + circle.color; + const l = i * ss + ox; + //const r = (i + 1) * ss + ox; + const t = j * ss + oy; + //const b = (j + 1) * ss + oy; + + //const cx = l + this.metrics.mid; + //const cy = t + this.metrics.mid; + const cx = this.metrics.mid; + const cy = this.metrics.mid; + + let transform = `translate(${l},${t})`; + + let draw_last_move = !this.dont_draw_last_move; + + let stone_color = 0; + if (this.engine) { + stone_color = this.engine.board[j][i]; } /* Figure out marks for this spot */ @@ -2308,7 +1975,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.engine && this.engine.cur_move && (this.mode !== "play" || - (typeof this.isInPushedAnalysis() !== "undefined" && this.isInPushedAnalysis())) + (typeof this.isInPushedAnalysis() !== "undefined" && + this.isInPushedAnalysis() && + this.show_variation_move_numbers)) ) { let cur: MoveTree | null = this.engine.cur_move; for (; cur && !cur.trunk; cur = cur.parent) { @@ -2328,24 +1997,201 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { movetree_contains_this_square = true; } - /* Draw stones & hovers */ - let draw_removal_x = false; - { + let have_text_to_draw = false; + let text_color = this.theme_blank_text_color; + for (const key in pos) { + if (key.length <= 3) { + have_text_to_draw = true; + } + } + if ( + pos.circle || + pos.triangle || + pos.chat_triangle || + pos.sub_triangle || + pos.cross || + pos.square + ) { + have_text_to_draw = true; + } + if (pos.letter && pos.letter.length > 0) { + have_text_to_draw = true; + } + if (pos.subscript && pos.subscript.length > 0) { + have_text_to_draw = true; + } + + /* Fade our lines if we have text to draw */ + if (have_text_to_draw) { + /* draw lighter colored lines */ + let sx = 0; + let ex = ss; + const mx = ss / 2 - this.metrics.offset; + let sy = 0; + let ey = ss; + const my = ss / 2 - this.metrics.offset; + + if (i === 0) { + sx += this.metrics.mid; + } + if (i === this.width - 1) { + ex -= this.metrics.mid; + } + if (j === 0) { + sy += this.metrics.mid; + } + if (j === this.height - 1) { + ey -= this.metrics.mid; + } + + if (i === this.width - 1 && j === this.height - 1) { + if (mx === ex && my === ey) { + ex += 1; + ey += 1; + } + } + + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("stroke", this.theme_faded_line_color); + path.setAttribute("stroke-width", this.square_size < 5 ? "0.2" : "1"); + path.setAttribute("fill", "none"); + path.setAttribute( + "d", + ` + M ${Math.floor(sx)} ${my} L ${Math.floor(ex)} ${my} + M ${mx} ${Math.floor(sy)} L ${mx} ${Math.floor(ey)} + `, + ); + cell.appendChild(path); + + /* Draw star points */ + let star_radius; + if (this.square_size < 5) { + star_radius = 0.5; + } else { + star_radius = Math.max(2, (this.metrics.mid - 1.5) * 0.16); + } + let draw_star_point = false; if ( - stone_color /* if there is really a stone here */ || - (this.stone_placement_enabled && - this.last_hover_square && - this.last_hover_square.x === i && - this.last_hover_square.y === j && - (this.mode !== "analyze" || this.analyze_tool === "stone") && - this.engine && - !this.scoring_mode && + this.width === 19 && + this.height === 19 && + ((i === 3 && (j === 3 || j === 9 || j === 15)) || + (i === 9 && (j === 3 || j === 9 || j === 15)) || + (i === 15 && (j === 3 || j === 9 || j === 15))) + ) { + draw_star_point = true; + } + + if ( + this.width === 13 && + this.height === 13 && + ((i === 3 && (j === 3 || j === 9)) || + (i === 6 && j === 6) || + (i === 9 && (j === 3 || j === 9))) + ) { + draw_star_point = true; + } + + if ( + this.width === 9 && + this.height === 9 && + ((i === 2 && (j === 2 || j === 6)) || + (i === 4 && j === 4) || + (i === 6 && (j === 2 || j === 6))) + ) { + draw_star_point = true; + } + + if (draw_star_point) { + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", star_radius.toString()); + circ.setAttribute("fill", this.theme_faded_star_color); + cell.appendChild(circ); + } + } + + /* Heatmap */ + if (this.heatmap) { + if (this.heatmap[j][i] > 0.001) { + const color = "#00FF00"; + const r = Math.floor(this.square_size * 0.5) - 0.5; + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + rect.setAttribute("fill-opacity", Math.min(this.heatmap[j][i], 0.5).toFixed(2)); + rect.setAttribute("fill", color); + cell.appendChild(rect); + } + } + + /* Draw square highlights if any */ + { + if ( + pos.hint || + (this.highlight_movetree_moves && movetree_contains_this_square) || + pos.color + ) { + const color = pos.color ? pos.color : pos.hint ? "#8EFF0A" : "#FF8E0A"; + const r = Math.floor(this.square_size * 0.5) - 0.5; + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + rect.setAttribute("fill-opacity", "0.6"); + rect.setAttribute("fill", color); + cell.appendChild(rect); + } + } + + /* Colored stones */ + const circle = this.colored_circles?.[j][i]; + if (circle) { + const radius = Math.floor(this.square_size * 0.5) - 0.5; + let lineWidth = radius * (circle.border_width || 0.1); + if (lineWidth < 0.3) { + lineWidth = 0; + } + + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circ.setAttribute("class", "colored-circle"); + circ.setAttribute("fill", circle.color); + if (circle.border_color) { + circ.setAttribute("stroke", circle.border_color); + } + if (lineWidth > 0) { + circ.setAttribute("stroke-width", lineWidth.toFixed(1)); + } else { + circ.setAttribute("stroke-width", "1px"); + } + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); + cell.appendChild(circ); + } + + /* Draw stones & hovers */ + let draw_removal_x = false; + { + if ( + stone_color /* if there is really a stone here */ || + (this.stone_placement_enabled && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j && + (this.mode !== "analyze" || this.analyze_tool === "stone") && + this.engine && + !this.scoring_mode && (this.engine.phase === "play" || (this.engine.phase === "finished" && this.mode === "analyze")) && (this.engine.puzzle_player_move_mode !== "fixed" || movetree_contains_this_square || (this.getPuzzlePlacementSetting && - this.getPuzzlePlacementSetting().mode !== "play"))) || + this.getPuzzlePlacementSetting().mode === "play"))) || (this.scoring_mode && this.score_estimator && this.score_estimator.board[j][i] && @@ -2357,7 +2203,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { pos.black || pos.white ) { + //let color = stone_color ? stone_color : (this.move_selected ? this.engine.otherPlayer() : this.engine.player); let translucent = false; + let stoneAlphaValue = 0.6; let color; if ( this.scoring_mode && @@ -2369,7 +2217,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { translucent = true; } else if ( this.engine && - this.engine.phase === "stone removal" && + ((this.engine.phase === "stone removal" && + this.engine.last_official_move === this.engine.cur_move) || + (this.engine.phase === "finished" && this.mode !== "analyze")) && this.engine.board && this.engine.removal && this.engine.board[j][i] && @@ -2385,40 +2235,138 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.analyze_subtool !== "alternate" ) { color = this.edit_color === "black" ? 1 : 2; + if (this.shift_key_is_down) { + color = this.edit_color === "black" ? 2 : 1; + } } else if (this.move_selected) { if (this.engine.handicapMovesLeft() <= 0) { color = this.engine.otherPlayer(); } else { color = this.engine.player; } + } else if (this.mode === "puzzle") { + if (this.getPuzzlePlacementSetting) { + const s = this.getPuzzlePlacementSetting(); + if (s.mode === "setup") { + color = s.color; + if (this.shift_key_is_down) { + color = color === 1 ? 2 : 1; + } + } else { + color = this.engine.player; + } + } else { + color = this.engine.player; + } } else if (pos.black || pos.white) { color = pos.black ? 1 : 2; translucent = true; + stoneAlphaValue = this.variation_stone_opacity; } else { color = this.engine.player; } - //if (this.mode === "analyze" && pos.stone_removed) { if (pos.stone_removed) { translucent = true; } - if (color === 1) { - ret += this.theme_black.getStoneHash(i, j, this.theme_black_stones, this); - } - if (color === 2) { - ret += this.theme_white.getStoneHash(i, j, this.theme_white_stones, this); + if (!(this.autoplaying_puzzle_move && !stone_color)) { + text_color = + color === 1 ? this.theme_black_text_color : this.theme_white_text_color; + + if (!this.theme_black_stones) { + const err = new Error( + `Goban.theme_black_stones not set. Current themes is ${JSON.stringify( + this.themes, + )}`, + ); + setTimeout(() => { + throw err; + }, 1); + return; + } + if (!this.theme_white_stones) { + const err = new Error( + `Goban.theme_white_stones not set. Current themes is ${JSON.stringify( + this.themes, + )}`, + ); + setTimeout(() => { + throw err; + }, 1); + return; + } + + const stone_transparent = translucent || !stone_color; + + if (color === 1) { + const stone = this.theme_black.getStone( + i, + j, + this.theme_black_stones, + this, + ); + const [elt, shadow] = this.theme_black.placeBlackStoneSVG( + cell, + stone_transparent ? undefined : this.shadow_layer, + stone, + cx, + cy, + this.theme_stone_radius, + ); + this.shadow_grid[j][i] = shadow; + if (stone_transparent) { + elt.setAttribute("opacity", stoneAlphaValue.toString()); + } + } else { + const stone = this.theme_white.getStone( + i, + j, + this.theme_white_stones, + this, + ); + const [elt, shadow] = this.theme_white.placeWhiteStoneSVG( + cell, + stone_transparent ? undefined : this.shadow_layer, + stone, + cx, + cy, + this.theme_stone_radius, + ); + this.shadow_grid[j][i] = shadow; + if (stone_transparent) { + elt.setAttribute("opacity", stoneAlphaValue.toString()); + } + } } - if ( - pos.blue_move && - this.colored_circles && - this.colored_circles[j] && - this.colored_circles[j][i] - ) { - ret += "blue"; + /** Draw the circle around the blue move */ + const circle = this.colored_circles?.[j][i]; + if (pos.blue_move && circle) { + const radius = Math.floor(this.square_size * 0.5) - 0.5; + let lineWidth = radius * (circle.border_width || 0.1); + if (lineWidth < 0.3) { + lineWidth = 0; + } + + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circ.setAttribute("class", "colored-circle"); + if (circle.border_color) { + circ.setAttribute("stroke", circle.border_color || "#000000"); + } + if (lineWidth > 0) { + circ.setAttribute("stroke-width", `${lineWidth.toFixed(1)}px`); + } else { + circ.setAttribute("stroke-width", "1px"); + } + circ.setAttribute("fill", "none"); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); + cell.appendChild(circ); } + /* Red X if the stone is marked for removal */ if ( (this.engine && this.engine.phase === "stone removal" && @@ -2432,10 +2380,11 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { //(this.mode === "analyze" && pos.stone_removed) pos.stone_removed ) { - draw_removal_x = true; + draw_removal_x = this.themes["removal-graphic"] === "x"; + transform = `translate(${l + this.metrics.mid * (1.0 - removed_stone_scale)}, ${ + t + this.metrics.mid * (1.0 - removed_stone_scale) + }) scale(${removed_stone_scale})`; } - - ret += (translucent ? "T" : "") + color + ","; } } @@ -2454,62 +2403,98 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.last_hover_square.x === i && this.last_hover_square.y === j) ) { + let r = Math.max(1, this.metrics.mid * 0.5); + const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cross.setAttribute("class", "removal-cross"); const color = this.engine.board[j][i] === JGOFNumericPlayerColor.BLACK ? "black" : "white"; + if (pos.score === "black" && color === "white") { - ret += "whiteX"; + cross.setAttribute("fill", this.theme_white_text_color); } else if (pos.score === "white" && color === "black") { - ret += "blackX"; - } else { - ret += "redX"; + cross.setAttribute("fill", this.theme_black_text_color); + } else if ( + (pos.score === "white" && color === "white") || + (pos.score === "black" && color === "black") + ) { + // score point for the same color where the stone is removed + // should call special attention to it + cross.setAttribute("fill", "#ff0000"); red_x = true; + r = Math.max(1, this.metrics.mid * 0.65); + } else { + // otherwise, no score but removed stones can happen when + // territory isn't properly sealed, so we are going to mark + // it grey to avoid calling too much attention, but still + // denote that removing these stones doesn't result in + // the territory being territory yet. + cross.setAttribute("fill", "#777777"); } - } - /* Draw square highlights if any */ - { - if (pos.hint || (this.highlight_movetree_moves && movetree_contains_this_square)) { - if (pos.hint) { - ret += "hint,"; - } else { - ret += "highlight,"; - } - } - } + /* four dagger tip points with a square in the center. Start at top left going clockwise*/ + const dx = r * 0.25; // tip width + const ir = r * 0.3; // inner radius for our box + const ir_dx = ir * 0.4; // offset to where our daggers meet the box - /* Draw delete X's */ - { - let draw_x = false; - let transparent = false; - if ( - this.engine && - (this.scoring_mode || this.engine.phase === "stone removal") && - this.stone_placement_enabled && - this.last_hover_square && - this.last_hover_square.x === i && - this.last_hover_square.y === j && - (this.mode !== "analyze" || this.analyze_tool === "stone") - ) { - draw_x = true; - transparent = true; - } + // prettier-ignore + const points = [ + /* top half */ + -r , -r , + -r + dx , -r , + -ir + ir_dx , -ir - ir_dx , + ir - ir_dx , -ir - ir_dx , + r - dx , -r , + r , -r , - if (pos.mark_x) { - draw_x = true; - transparent = false; - } + /* right half */ + r , -r + dx , + ir + ir_dx , -ir + ir_dx , + ir + ir_dx , ir - ir_dx , + r , r - dx , + r , r , - if (this.scoring_mode && this.score_estimator && this.score_estimator.removal[j][i]) { - draw_x = true; - transparent = false; - } + /* bottom half */ + r - dx , r , + ir - ir_dx , ir + ir_dx , + -ir + ir_dx , ir + ir_dx , + -r + dx , r , + -r , r , - if (pos.remove && this.mode !== "analyze") { - draw_x = true; - transparent = false; - } + /* left half */ + -r , r - dx , + -ir - ir_dx , ir - ir_dx , + -ir - ir_dx , -ir + ir_dx , + -r , -r + dx , + //-r , -r , + ]; - ret += draw_x + "," + transparent; + const path = + points + .map((v, i) => { + return (i % 2 === 0 ? (i === 0 ? "M" : "L") : " ") + v; + }) + .join(" ") + " Z"; + + cross.setAttribute("stroke", "#888888"); + cross.setAttribute("stroke-width", `${this.square_size * 0.0275}px`); + //cross.setAttribute("fill", "none"); + cross.setAttribute( + "d", + path, + /* + ` + M ${cx - r} ${cy - r} + L ${cx + r} ${cy + r} + M ${cx + r} ${cy - r} + L ${cx - r} ${cy + r} + `, + */ + ); + const opacity = this.engine.board[j][i] ? 1.0 : 0.2; + cross.setAttribute("stroke-opacity", opacity?.toString()); + cross.setAttribute("transform", `translate(${cx}, ${cy})`); + + cell.appendChild(cross); } /* Draw Scores */ @@ -2536,6 +2521,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.last_hover_square.y === j) ) { let color = pos.score; + if ( this.scoring_mode && this.score_estimator && @@ -2574,24 +2560,55 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { ) { color = this.analyze_subtool; } - if ( - this.scoring_mode && - this.score_estimator && - this.score_estimator.territory[j][i] - ) { - color = this.score_estimator.territory[j][i] === 1 ? "black" : "white"; + + const r = this.square_size * 0.15; + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + if (color === "white") { + rect.setAttribute("fill", this.theme_black_text_color); + rect.setAttribute("stroke", "#777777"); } - ret += "score " + color + ","; + if (color === "black") { + rect.setAttribute("fill", this.theme_white_text_color); + rect.setAttribute("stroke", "#888888"); + } + if (color === "dame") { + rect.setAttribute("fill-opacity", "0.2"); + rect.setAttribute("stroke", "#365FE6"); + } + if (color === "seal") { + rect.setAttribute("fill-opacity", "0.8"); + rect.setAttribute("fill", "#ff4444"); + rect.setAttribute("stroke", "#E079CE"); + } + if (color?.[0] === "#") { + rect.setAttribute("fill", color); + rect.setAttribute("stroke", color_blend("#888888", color)); + } + rect.setAttribute( + "stroke-width", + (Math.ceil(this.square_size * 0.065) - 0.5).toFixed(1), + ); + cell.appendChild(rect); } } /* Draw letters and numbers */ + let letter_was_drawn = false; { let letter: string | undefined; + let subscript: string | undefined; let transparent = false; if (pos.letter) { letter = pos.letter; } + if (pos.subscript) { + subscript = pos.subscript; + } + if ( this.mode === "play" && this.byoyomi_label && @@ -2599,12 +2616,12 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.last_hover_square.x === i && this.last_hover_square.y === j ) { - //transparent = true; letter = this.byoyomi_label; } if ( this.mode === "analyze" && this.analyze_tool === "label" && + (this.analyze_subtool === "letters" || this.analyze_subtool === "numbers") && this.last_hover_square && this.last_hover_square.x === i && this.last_hover_square.y === j @@ -2619,31 +2636,94 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { if ( this.show_variation_move_numbers && !letter && - !(pos.circle || pos.triangle || pos.chat_triangle || pos.cross || pos.square) + !( + pos.circle || + pos.triangle || + pos.chat_triangle || + pos.sub_triangle || + pos.cross || + pos.square + ) ) { const m = this.engine.getMoveByLocation(i, j, false); if (m && !m.trunk) { - if (m.edited) { - //letter = "triangle"; - if (this.engine.board[j][i]) { - alt_marking = "triangle"; + const move_diff = m.getMoveNumberDifferenceFromTrunk(); + if (move_diff !== m.move_number) { + if (m.edited) { + if (this.engine.board[j][i]) { + alt_marking = "triangle"; + } + } else { + letter = move_diff.toString(); } - } else { - letter = m.getMoveNumberDifferenceFromTrunk().toString(); } } } if (letter) { - ret += letter + (transparent ? " fade" : "") + ","; + letter_was_drawn = true; + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "letter"); + text.setAttribute("fill", text_color); + text.setAttribute( + "font-size", + subscript ? `${this.square_size * 0.4}px` : `${this.square_size * 0.5}px`, + ); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("x", cx.toString()); + let yy = cy; + yy += this.square_size / 6; + if (subscript) { + yy -= this.square_size * 0.15; + } + text.setAttribute("y", yy.toString()); + text.textContent = letter; + if (transparent) { + text.setAttribute("fill-opacity", "0.6"); + } + cell.appendChild(text); + draw_last_move = false; } - if (pos.subscript) { - ret += " _ " + pos.subscript + (transparent ? " fade" : "") + ","; + + if (subscript) { + letter_was_drawn = true; + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "subscript"); + text.setAttribute("fill", text_color); + text.setAttribute("font-size", `${this.square_size * 0.4}px`); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("x", cx.toString()); + let yy = cy; + yy -= this.square_size / 6; + if (letter) { + yy += this.square_size * 0.6; + } else { + yy += this.square_size * 0.31; + } + if (pos.sub_triangle) { + yy -= this.square_size * 0.08; + } + text.setAttribute("y", yy.toString()); + text.textContent = subscript; + if (transparent) { + text.setAttribute("fill-opacity", "0.6"); + } + cell.appendChild(text); + draw_last_move = false; } } /* draw special symbols */ { + let transparent = letter_was_drawn; + let hover_mark: string | undefined; + const symbol_color = + stone_color === 1 + ? this.theme_black_text_color + : stone_color === 2 + ? this.theme_white_text_color + : text_color; + if ( this.analyze_tool === "label" && this.last_hover_square && @@ -2656,24 +2736,122 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.analyze_subtool === "cross" || this.analyze_subtool === "circle" ) { - ret += "hover " + this.analyze_subtool + ","; + transparent = true; + hover_mark = this.analyze_subtool; } } - if (pos.circle) { - ret += "circle,"; + if (pos.circle || hover_mark === "circle") { + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circ.setAttribute("class", "circle"); + circ.setAttribute("fill", "none"); + circ.setAttribute("stroke", symbol_color); + circ.setAttribute("stroke-width", `${this.square_size * 0.075}px`); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute( + "r", + Math.max(0.1, this.square_size * this.circle_radius).toFixed(2), + ); + if (transparent) { + circ.setAttribute("stroke-opacity", "0.6"); + } + cell.appendChild(circ); + draw_last_move = false; } - if (pos.triangle || pos.chat_triangle || alt_marking === "triangle") { - ret += "triangle,"; + if ( + pos.triangle || + pos.chat_triangle || + pos.sub_triangle || + alt_marking === "triangle" || + hover_mark === "triangle" + ) { + let scale = 1.0; + let oy = 0.0; + let line_width = this.square_size * 0.075; + if (pos.sub_triangle) { + scale = 0.5; + oy = this.square_size * 0.3; + transparent = false; + line_width *= 0.5; + } + + const triangle = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); + triangle.setAttribute("class", "triangle"); + triangle.setAttribute("fill", "none"); + triangle.setAttribute("stroke", symbol_color); + triangle.setAttribute("stroke-width", `${line_width}px`); + const r = this.square_size * 0.3 * scale; + let theta = -(Math.PI * 2) / 4; + const points = []; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + theta += (Math.PI * 2) / 3; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + theta += (Math.PI * 2) / 3; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + theta += (Math.PI * 2) / 3; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + triangle.setAttribute( + "points", + points.map((p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" "), + ); + if (transparent) { + triangle.setAttribute("stroke-opacity", "0.6"); + } + cell.appendChild(triangle); + draw_last_move = false; } - if (pos.cross) { - ret += "cross,"; + if (pos.cross || hover_mark === "cross") { + const r = Math.max(1, this.metrics.mid * 0.35); + const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cross.setAttribute("class", "cross"); + cross.setAttribute("stroke", symbol_color); + cross.setAttribute("stroke-width", `${this.square_size * 0.075}px`); + cross.setAttribute("fill", "none"); + cross.setAttribute( + "d", + ` + M ${cx - r} ${cy - r} + L ${cx + r} ${cy + r} + M ${cx + r} ${cy - r} + L ${cx - r} ${cy + r} + `, + ); + if (transparent) { + cross.setAttribute("stroke-opacity", "0.6"); + } + + cell.appendChild(cross); + + draw_last_move = false; } - if (pos.square) { - ret += "square,"; + + if (pos.square || hover_mark === "square") { + draw_last_move = false; + const square = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + square.setAttribute("class", "square"); + square.setAttribute("fill", "none"); + square.setAttribute("stroke", symbol_color); + square.setAttribute("stroke-width", `${this.square_size * 0.075}px`); + const r = Math.max(1, this.metrics.mid * 0.4); + square.setAttribute("x", (cx - r).toFixed(2)); + square.setAttribute("y", (cy - r).toFixed(2)); + square.setAttribute("width", (r * 2).toFixed(2)); + square.setAttribute("height", (r * 2).toFixed(2)); + if (transparent) { + square.setAttribute("stroke-opacity", "0.6"); + } + cell.appendChild(square); } } + /* Clear last move */ + if (this.last_move && this.engine && !this.last_move.is(this.engine.cur_move)) { + const m = this.last_move; + delete this.last_move; + this.drawSquare(m.x, m.y); + } + /* Draw last move */ if (draw_last_move && this.engine && this.engine.cur_move) { if ( @@ -2682,13 +2860,82 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.engine.board[j][i] && (this.engine.phase === "play" || this.engine.phase === "finished") ) { - ret += "last_move,"; - if ( - this.engine.undo_requested && - this.getShowUndoRequestIndicator() && - this.engine.undo_requested === this.engine.cur_move.move_number - ) { - ret += "?" + ","; + this.last_move = this.engine.cur_move; + + if (i >= 0 && j >= 0) { + const color = + stone_color === 1 + ? this.theme_black_text_color + : this.theme_white_text_color; + + if (this.submit_move) { + draw_last_move = false; + + const r = Math.max(1, this.metrics.mid * 0.35) * 0.8; + const cross = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + cross.setAttribute("class", "last-move"); + cross.setAttribute("stroke", color); + cross.setAttribute("stroke-width", `${this.square_size * 0.075}px`); + cross.setAttribute("fill", "none"); + cross.setAttribute("opacity", this.last_move_opacity.toString()); + cross.setAttribute( + "d", + ` + M ${cx - r} ${cy} + L ${cx + r} ${cy} + M ${cx} ${cy - r} + L ${cx} ${cy + r} + `, + ); + cell.appendChild(cross); + } else { + if ( + this.engine.undo_requested && + this.getShowUndoRequestIndicator() && + this.engine.undo_requested === this.engine.cur_move.move_number + ) { + const letter = "?"; + draw_last_move = false; + + const text = document.createElementNS( + "http://www.w3.org/2000/svg", + "text", + ); + text.setAttribute("class", "letter"); + text.setAttribute("fill", color); + text.setAttribute("font-size", `${this.square_size * 0.5}px`); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("x", cx.toString()); + let yy = cy; + yy += this.square_size / 6; + text.setAttribute("y", yy.toString()); + text.textContent = letter; + cell.appendChild(text); + } else { + const circ = document.createElementNS( + "http://www.w3.org/2000/svg", + "circle", + ); + let r = this.square_size * this.last_move_radius; + if (this.submit_move) { + r = this.square_size * 0.3; + } + + r = Math.max(0.1, r); + circ.setAttribute("class", "last-move"); + circ.setAttribute("fill", "none"); + circ.setAttribute("stroke", color); + circ.setAttribute("stroke-width", `${this.square_size * 0.075}px`); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("opacity", this.last_move_opacity.toString()); + circ.setAttribute("r", r.toString()); + cell.appendChild(circ); + } + } } } } @@ -2705,1093 +2952,2697 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { ? this.stalling_score_estimate : this.score_estimator; const est = se!.ownership[j][i]; + const color = est < 0 ? "white" : "black"; + const color_num = color === "black" ? 1 : 2; - ret += est.toFixed(5) + ","; + if (color_num !== stone_color) { + const r = this.square_size * 0.2 * Math.abs(est); + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + if (color === "white") { + rect.setAttribute("fill", this.theme_black_text_color); + rect.setAttribute("stroke", "#777777"); + } + if (color === "black") { + rect.setAttribute("fill", this.theme_white_text_color); + rect.setAttribute("stroke", "#888888"); + } + rect.setAttribute( + "stroke-width", + (Math.ceil(this.square_size * 0.035) - 0.5).toFixed(1), + ); + cell.appendChild(rect); + } } - return ret; + this.__draw_state[j][i] = this.drawingHash(i, j); + + cell.setAttribute("transform", transform); + + if (this.shadow_grid[j][i]) { + this.shadow_grid[j][i].setAttribute("transform", transform); + } } - private drawLines(force_clear?: boolean): void { - if (force_clear) { - if (this.lines_layer) { - this.lines_layer.remove(); - delete this.lines_layer; - } + private drawingHash(i: number, j: number): string { + if (this.no_display) { + return ""; + } + if (i < 0 || j < 0) { + return ".."; } - if (!this.lines_layer) { - const ss = this.square_size; - let ox = this.draw_left_labels ? ss : 0; - let oy = this.draw_top_labels ? ss : 0; + let ret = this.square_size + ","; - if (this.bounds.left > 0) { - ox = -ss * this.bounds.left; - } - if (this.bounds.top > 0) { - oy = -ss * this.bounds.top; - } + const draw_last_move = !this.dont_draw_last_move; + let stone_color = 0; + if (this.engine) { + stone_color = this.engine.board[j][i]; + } - // lines go through center of our stone grid - ox += Math.round(ss / 2); + ret += stone_color + ","; + + /* Draw heatmap */ + if (this.heatmap) { + if (this.heatmap[j][i] > 0.001) { + ret += "heat " + this.heatmap[j][i] + ","; + } + } + + /* Colored stones */ + const circle = this.colored_circles?.[j][i]; + if (circle) { + ret += "circle " + circle.color; + } + + /* Figure out marks for this spot */ + let pos = this.getMarks(i, j); + if (!pos) { + console.error("No position for ", j, i); + pos = {}; + } + let alt_marking: string | undefined; + if ( + this.engine && + this.engine.cur_move && + (this.mode !== "play" || + (typeof this.isInPushedAnalysis() !== "undefined" && this.isInPushedAnalysis())) + ) { + let cur: MoveTree | null = this.engine.cur_move; + for (; cur && !cur.trunk; cur = cur.parent) { + if (cur.x === i && cur.y === j) { + const move_diff = cur.getMoveNumberDifferenceFromTrunk(); + if (move_diff !== cur.move_number) { + if (!cur.edited && this.show_variation_move_numbers) { + alt_marking = cur.getMoveNumberDifferenceFromTrunk().toString(); + } + } + } + } + } + + let movetree_contains_this_square = false; + if (this.engine && this.engine.cur_move.lookupMove(i, j, this.engine.player, false)) { + movetree_contains_this_square = true; + } + + /* Draw stones & hovers */ + let draw_removal_x = false; + { + if ( + stone_color /* if there is really a stone here */ || + (this.stone_placement_enabled && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j && + (this.mode !== "analyze" || this.analyze_tool === "stone") && + this.engine && + !this.scoring_mode && + (this.engine.phase === "play" || + (this.engine.phase === "finished" && this.mode === "analyze")) && + (this.engine.puzzle_player_move_mode !== "fixed" || + movetree_contains_this_square || + (this.getPuzzlePlacementSetting && + this.getPuzzlePlacementSetting().mode !== "play"))) || + (this.scoring_mode && + this.score_estimator && + this.score_estimator.board[j][i] && + this.score_estimator.removal[j][i]) || + (this.engine && + this.engine.phase === "stone removal" && + this.engine.board[j][i] && + this.engine.removal[j][i]) || + pos.black || + pos.white + ) { + let translucent = false; + let color; + if ( + this.scoring_mode && + this.score_estimator && + this.score_estimator.board[j][i] && + this.score_estimator.removal[j][i] + ) { + color = this.score_estimator.board[j][i]; + translucent = true; + } else if ( + this.engine && + this.engine.phase === "stone removal" && + this.engine.board && + this.engine.removal && + this.engine.board[j][i] && + this.engine.removal[j][i] + ) { + color = this.engine.board[j][i]; + translucent = true; + } else if (stone_color) { + color = stone_color; + } else if ( + this.mode === "analyze" && + this.analyze_tool === "stone" && + this.analyze_subtool !== "alternate" + ) { + color = this.edit_color === "black" ? 1 : 2; + } else if (this.move_selected) { + if (this.engine.handicapMovesLeft() <= 0) { + color = this.engine.otherPlayer(); + } else { + color = this.engine.player; + } + } else if (pos.black || pos.white) { + color = pos.black ? 1 : 2; + translucent = true; + } else { + color = this.engine.player; + } + + //if (this.mode === "analyze" && pos.stone_removed) { + if (pos.stone_removed) { + translucent = true; + } + + if (color === 1) { + ret += this.theme_black.getStoneHash(i, j, this.theme_black_stones, this); + } + if (color === 2) { + ret += this.theme_white.getStoneHash(i, j, this.theme_white_stones, this); + } + + if ( + pos.blue_move && + this.colored_circles && + this.colored_circles[j] && + this.colored_circles[j][i] + ) { + ret += "blue"; + } + + if ( + (this.engine && + this.engine.phase === "stone removal" && + this.engine.last_official_move === this.engine.cur_move && + this.engine.board[j][i] && + this.engine.removal[j][i]) || + (this.scoring_mode && + this.score_estimator && + this.score_estimator.board[j][i] && + this.score_estimator.removal[j][i]) || + //(this.mode === "analyze" && pos.stone_removed) + pos.stone_removed + ) { + draw_removal_x = true; + } + + ret += (translucent ? "T" : "") + color + ","; + } + } + + let red_x = false; + if ( + draw_removal_x || + (this.mode === "analyze" && + this.analyze_tool === "removal" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j) || + (this.engine.phase === "stone removal" && + this.engine.isActivePlayer(this.player_id) && + this.engine.cur_move === this.engine.last_official_move && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j) + ) { + const color = + this.engine.board[j][i] === JGOFNumericPlayerColor.BLACK ? "black" : "white"; + if (pos.score === "black" && color === "white") { + ret += "whiteX"; + } else if (pos.score === "white" && color === "black") { + ret += "blackX"; + } else { + ret += "redX"; + red_x = true; + } + } + + /* Draw square highlights if any */ + { + if (pos.hint || (this.highlight_movetree_moves && movetree_contains_this_square)) { + if (pos.hint) { + ret += "hint,"; + } else { + ret += "highlight,"; + } + } + } + + /* Draw delete X's */ + { + let draw_x = false; + let transparent = false; + if ( + this.engine && + (this.scoring_mode || this.engine.phase === "stone removal") && + this.stone_placement_enabled && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j && + (this.mode !== "analyze" || this.analyze_tool === "stone") + ) { + draw_x = true; + transparent = true; + } + + if (pos.mark_x) { + draw_x = true; + transparent = false; + } + + if (this.scoring_mode && this.score_estimator && this.score_estimator.removal[j][i]) { + draw_x = true; + transparent = false; + } + + if (pos.remove && this.mode !== "analyze") { + draw_x = true; + transparent = false; + } + + ret += draw_x + "," + transparent; + } + + /* Draw Scores */ + { + if ( + (pos.score && + (!draw_removal_x || red_x) && + (this.engine.phase !== "finished" || + this.mode === "play" || + this.mode === "analyze")) || + (this.scoring_mode && + this.score_estimator && + (this.score_estimator.territory[j][i] || + (this.score_estimator.removal[j][i] && + this.score_estimator.board[j][i] === 0))) || + ((this.engine.phase === "stone removal" || + (this.engine.phase === "finished" && this.mode === "play")) && + this.engine.board[j][i] === 0 && + (this.engine.removal[j][i] || pos.needs_sealing)) || + (this.mode === "analyze" && + this.analyze_tool === "score" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j) + ) { + let color = pos.score; + if ( + this.scoring_mode && + this.score_estimator && + (this.score_estimator.territory[j][i] || + (this.score_estimator.removal[j][i] && + this.score_estimator.board[j][i] === 0)) + ) { + color = this.score_estimator.territory[j][i] === 1 ? "black" : "white"; + if ( + this.score_estimator.board[j][i] === 0 && + this.score_estimator.removal[j][i] + ) { + color = "dame"; + } + } + + if ( + (this.engine.phase === "stone removal" || + (this.engine.phase === "finished" && this.mode === "play")) && + this.engine.board[j][i] === 0 && + this.engine.removal[j][i] + ) { + color = "dame"; + } + + if (pos.needs_sealing) { + color = "seal"; + } + + if ( + this.mode === "analyze" && + this.analyze_tool === "score" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j + ) { + color = this.analyze_subtool; + } + if ( + this.scoring_mode && + this.score_estimator && + this.score_estimator.territory[j][i] + ) { + color = this.score_estimator.territory[j][i] === 1 ? "black" : "white"; + } + ret += "score " + color + ","; + } + } + + /* Draw letters and numbers */ + { + let letter: string | undefined; + let transparent = false; + if (pos.letter) { + letter = pos.letter; + } + if ( + this.mode === "play" && + this.byoyomi_label && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j + ) { + //transparent = true; + letter = this.byoyomi_label; + } + if ( + this.mode === "analyze" && + this.analyze_tool === "label" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j + ) { + transparent = true; + letter = this.label_character; + } + if (!letter && alt_marking !== "triangle") { + letter = alt_marking; + } + + if ( + this.show_variation_move_numbers && + !letter && + !(pos.circle || pos.triangle || pos.chat_triangle || pos.cross || pos.square) + ) { + const m = this.engine.getMoveByLocation(i, j, false); + if (m && !m.trunk) { + if (m.edited) { + //letter = "triangle"; + if (this.engine.board[j][i]) { + alt_marking = "triangle"; + } + } else { + letter = m.getMoveNumberDifferenceFromTrunk().toString(); + } + } + } + + if (letter) { + ret += letter + (transparent ? " fade" : "") + ","; + } + if (pos.subscript) { + ret += " _ " + pos.subscript + (transparent ? " fade" : "") + ","; + } + } + + /* draw special symbols */ + { + if ( + this.analyze_tool === "label" && + this.last_hover_square && + this.last_hover_square.x === i && + this.last_hover_square.y === j + ) { + if ( + this.analyze_subtool === "triangle" || + this.analyze_subtool === "square" || + this.analyze_subtool === "cross" || + this.analyze_subtool === "circle" + ) { + ret += "hover " + this.analyze_subtool + ","; + } + } + + if (pos.circle) { + ret += "circle,"; + } + if (pos.triangle || pos.chat_triangle || alt_marking === "triangle") { + ret += "triangle,"; + } + if (pos.cross) { + ret += "cross,"; + } + if (pos.square) { + ret += "square,"; + } + } + + /* Draw last move */ + if (draw_last_move && this.engine && this.engine.cur_move) { + if ( + this.engine.cur_move.x === i && + this.engine.cur_move.y === j && + this.engine.board[j][i] && + (this.engine.phase === "play" || this.engine.phase === "finished") + ) { + ret += "last_move,"; + if ( + this.engine.undo_requested && + this.getShowUndoRequestIndicator() && + this.engine.undo_requested === this.engine.cur_move.move_number + ) { + ret += "?" + ","; + } + } + } + + /* Score Estimation */ + if ( + (this.scoring_mode === true && this.score_estimator) || + (this.scoring_mode === "stalling-scoring-mode" && + this.stalling_score_estimate && + this.mode !== "analyze") + ) { + const se = + this.scoring_mode === "stalling-scoring-mode" + ? this.stalling_score_estimate + : this.score_estimator; + const est = se!.ownership[j][i]; + + ret += est.toFixed(5) + ","; + } + + return ret; + } + + private drawLines(force_clear?: boolean): void { + if (force_clear) { + if (this.lines_layer) { + this.lines_layer.remove(); + delete this.lines_layer; + } + } + + if (!this.lines_layer) { + const ss = this.square_size; + let ox = this.draw_left_labels ? ss : 0; + let oy = this.draw_top_labels ? ss : 0; + + if (this.bounds.left > 0) { + ox = -ss * this.bounds.left; + } + if (this.bounds.top > 0) { + oy = -ss * this.bounds.top; + } + + // lines go through center of our stone grid + ox += Math.round(ss / 2); oy += Math.round(ss / 2); - // Tiny square sizes, as in the ones used to display puzzle icons - const TINY_SQUARE_SIZE = 10; + // Tiny square sizes, as in the ones used to display puzzle icons + const TINY_SQUARE_SIZE = 10; + + // Compute a line width that is rounded to the nearest 0.5 so we + // get crisp lines + const line_width = + ss > TINY_SQUARE_SIZE + ? Math.round(2 * Math.round(Math.max(1, ss * 0.02))) * 0.5 + : // for very small boards, like puzzle icons, have faint lines + ss * 0.08; + ox -= line_width * 0.5; + oy -= line_width * 0.5; + + // Round to half pixel offsets odd widths for crisp lines + ox = Math.round(ox * 2.0) * 0.5; + oy = Math.round(oy * 2.0) * 0.5; + + this.lines_layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); + const lines_path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + let path_str = ""; + for (let x = 0; x < this.width; ++x) { + path_str += `M ${ox + x * ss} ${oy} L ${ox + x * ss} ${ + oy + (this.height - 1) * ss + } `; + } + for (let y = 0; y < this.height; ++y) { + path_str += `M ${ox} ${oy + y * ss} L ${ox + (this.width - 1) * ss} ${ + oy + y * ss + } `; + } + lines_path.setAttribute("d", path_str); + lines_path.setAttribute("stroke", this.theme_line_color); + if (ss > TINY_SQUARE_SIZE) { + lines_path.setAttribute("stroke-width", `${line_width.toFixed(0)}px`); + } else { + lines_path.setAttribute("stroke-width", `${line_width.toFixed(1)}px`); + } + lines_path.setAttribute("stroke-linecap", "square"); + this.lines_layer.appendChild(lines_path); + + // Hoshi / star points + let hoshi = null; + + if (this.width === 19 && this.height === 19) { + hoshi = [ + [3, 3], + [3, 9], + [3, 15], + [9, 3], + [9, 9], + [9, 15], + [15, 3], + [15, 9], + [15, 15], + ]; + } + + if (this.width === 13 && this.height === 13) { + hoshi = [ + [3, 3], + [3, 9], + [6, 6], + [9, 3], + [9, 9], + ]; + } + + if (this.width === 9 && this.height === 9) { + hoshi = [ + [2, 2], + [2, 6], + [4, 4], + [6, 2], + [6, 6], + ]; + } + + if (hoshi) { + const r = this.square_size * 0.075; + for (let i = 0; i < hoshi.length; ++i) { + const [hx, hy] = hoshi[i]; + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", (ox + hx * ss).toString()); + circle.setAttribute("cy", (oy + hy * ss).toString()); + circle.setAttribute("r", `${r.toFixed(1)}px`); + circle.setAttribute("fill", this.theme_star_color); + this.lines_layer.appendChild(circle); + } + } + this.svg.appendChild(this.lines_layer); + } + } + + private drawCoordinateLabels(force_clear?: boolean): void { + if (force_clear) { + if (this.coordinate_labels_layer) { + this.coordinate_labels_layer.remove(); + delete this.coordinate_labels_layer; + } + } + + if (!this.coordinate_labels_layer) { + this.coordinate_labels_layer = document.createElementNS( + "http://www.w3.org/2000/svg", + "g", + ); + this.coordinate_labels_layer.setAttribute("class", "coordinate-labels"); + + let text_size = Math.round(this.square_size * 0.5); + let bold_or_not = "bold"; + if (this.getCoordinateDisplaySystem() === "1-1") { + text_size *= 0.7; + bold_or_not = ""; + + if (this.height > 20) { + text_size *= 0.7; + } + } + + const place = (ch: string, x: number, y: number): void => { + /* places centered (horizontally & vertically) text at x,y */ + const ox = 0; + const oy = this.square_size / 6; + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", (x + ox).toFixed(0)); + text.setAttribute("y", (y + oy).toFixed(0)); + text.setAttribute("font-size", `${Math.round(text_size)}px`); + text.setAttribute("font-size", `${Math.round(text_size)}px`); + text.setAttribute("font-weight", bold_or_not); + text.setAttribute("fill", this.theme_board.getLabelTextColor()); + text.textContent = ch; + this.coordinate_labels_layer!.appendChild(text); + }; + const v_place = (ch: string, x: number, y: number): void => { + /* places centered (horizontally & vertically) text at x,y, with text going down vertically. */ + for (let i = 0; i < ch.length; ++i) { + //const xx = x - text_size / 2; + const H = text_size; + const xx = x; + let yy = y + text_size / 2.5; + + if (ch.length === 2) { + yy = yy - H * 0.5 + i * H; + } + if (ch.length === 3) { + yy = yy - H * 1 + i * H + 0.5; + } + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", xx.toFixed(0)); + text.setAttribute("y", yy.toFixed(1)); + text.setAttribute("font-size", `${Math.round(text_size)}px`); + text.setAttribute("font-weight", bold_or_not); + text.setAttribute("fill", this.theme_board.getLabelTextColor()); + text.textContent = ch[i]; + this.coordinate_labels_layer!.appendChild(text); + } + }; + + const drawHorizontal = (i: number, j: number): void => { + switch (this.getCoordinateDisplaySystem()) { + case "A1": + for (let c = 0; c < this.width; ++i, ++c) { + const x = + (i - + this.bounds.left - + (this.bounds.left > 0 ? +this.draw_left_labels : 0)) * + this.square_size + + this.square_size / 2; + const y = j * this.square_size + this.square_size / 2; + place(encodePrettyXCoordinate(c), x, y); + } + break; + case "1-1": + for (let c = 0; c < this.width; ++i, ++c) { + const x = + (i - + this.bounds.left - + (this.bounds.left > 0 ? +this.draw_left_labels : 0)) * + this.square_size + + this.square_size / 2; + const y = j * this.square_size + this.square_size / 2; + place("" + (c + 1), x, y); + } + break; + } + }; + + const drawVertical = (i: number, j: number): void => { + switch (this.getCoordinateDisplaySystem()) { + case "A1": + for (let c = 0; c < this.height; ++j, ++c) { + const x = i * this.square_size + this.square_size / 2; + const y = + (j - + this.bounds.top - + (this.bounds.top > 0 ? +this.draw_top_labels : 0)) * + this.square_size + + this.square_size / 2; + place("" + (this.height - c), x, y); + } + break; + case "1-1": + const chinese_japanese_numbers = [ + "一", + "二", + "三", + "四", + "五", + "六", + "七", + "八", + "九", + "十", + "十一", + "十二", + "十三", + "十四", + "十五", + "十六", + "十七", + "十八", + "十九", + "二十", + "二十一", + "二十二", + "二十三", + "二十四", + "二十五", + ]; + for (let c = 0; c < this.height; ++j, ++c) { + const x = i * this.square_size + this.square_size / 2; + const y = + (j - + this.bounds.top - + (this.bounds.top > 0 ? +this.draw_top_labels : 0)) * + this.square_size + + this.square_size / 2; + v_place(chinese_japanese_numbers[c], x, y); + } + break; + } + }; + + if (this.draw_top_labels && this.bounds.top === 0) { + drawHorizontal(this.draw_left_labels ? 1 : 0, 0); + } + if (this.draw_bottom_labels && this.bounds.bottom === this.height - 1) { + drawHorizontal( + this.draw_left_labels ? 1 : 0, + +this.draw_top_labels + this.bounded_height, + ); + } + if (this.draw_left_labels && this.bounds.left === 0) { + drawVertical(0, this.draw_top_labels ? 1 : 0); + } + if (this.draw_right_labels && this.bounds.right === this.width - 1) { + drawVertical(+this.draw_left_labels + this.bounded_width, +this.draw_top_labels); + } + + this.svg.appendChild(this.coordinate_labels_layer); + } + } + + protected computeThemeStoneRadius(): number { + const r = this.square_size * 0.5; + return Math.max(1, r); + } + + public redraw(force_clear?: boolean): void { + if (!this.ready_to_draw) { + return; + } + if (this.destroyed) { + console.debug("Attempting to redraw destroyed goban"); + return; + } + if (!this.drawing_enabled) { + return; + } + if (this.no_display) { + return; + } + + const metrics = (this.metrics = this.computeMetrics()); + if ( + force_clear || + !( + this.__set_board_width === metrics.width && + this.__set_board_height === metrics.height && + this.theme_stone_radius === this.computeThemeStoneRadius() + ) + ) { + force_clear = true; + try { + this.parent.style.width = metrics.width + "px"; + this.parent.style.height = metrics.height + "px"; + this.svg.setAttribute("width", metrics.width.toString()); + this.svg.setAttribute("height", metrics.height.toString()); + + this.__set_board_width = metrics.width; + this.__set_board_height = metrics.height; + + this.setTheme(this.getSelectedThemes(), true); + } catch (e) { + setTimeout(() => { + throw e; + }, 1); + return; + } + } + + this.drawLines(force_clear); + this.drawCoordinateLabels(force_clear); + + if (force_clear || !this.grid_layer || !this.shadow_layer) { + this.shadow_layer?.remove(); + this.shadow_layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.shadow_layer.setAttribute("class", "shadow-layer"); + this.svg.appendChild(this.shadow_layer); + + this.grid_layer?.remove(); + this.grid_layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.grid_layer.setAttribute("class", "grid"); + this.svg.appendChild(this.grid_layer); + + this.clearCells(); + + for (let j = 0; j < this.height; ++j) { + this.grid[j] = []; + this.shadow_grid[j] = []; + /* + for (let i = 0; i < this.width; ++i) { + const cell = document.createElementNS("http://www.w3.org/2000/svg", "g"); + cell.setAttribute("x", (i * this.square_size).toString()); + cell.setAttribute("y", (j * this.square_size).toString()); + cell.setAttribute("width", this.square_size.toString()); + cell.setAttribute("height", this.square_size.toString()); + this.grid_layer.appendChild(cell); + + this.grid[j][i] = cell; + } + */ + } + } + + /* Draw squares */ + if ( + !this.__draw_state || + force_clear || + this.__draw_state.length !== this.height || + this.__draw_state[0].length !== this.width + ) { + this.__draw_state = makeMatrix(this.width, this.height, ""); + } + + for (let j = this.bounds.top; j <= this.bounds.bottom; ++j) { + for (let i = this.bounds.left; i <= this.bounds.right; ++i) { + this.drawSquare(i, j); + } + } + + if (this.pen_marks) { + if (force_clear) { + this.detachPenLayer(); + } + this.drawPenMarks(this.pen_marks); + } + this.move_tree_redraw(); + } + + public showMessage( + message_id_or_error: MessageID, + parameters?: { [key: string]: any }, + timeout: number = 5000, + ): void { + this.clearMessage(); + + const message_id = parameters?.error?.message_id || message_id_or_error; + + const msg = formatMessage(message_id, parameters); + this.emit("show-message", { + formatted: msg, + message_id: message_id, + parameters: parameters, + }); + + if (!this.config.dont_show_messages) { + this.message_div = document.createElement("div"); + this.message_div.className = "GobanMessage"; + this.message_td = document.createElement("td"); + const table = document.createElement("table"); + const tr = document.createElement("tr"); + tr.appendChild(this.message_td); + table.appendChild(tr); + this.message_div.appendChild(table); + this.message_text = document.createElement("div"); + this.message_text.innerHTML = msg; + this.message_td.appendChild(this.message_text); + this.parent.appendChild(this.message_div); + + const message_time = Date.now(); + this.message_div.addEventListener("click", () => { + try { + if (Date.now() - message_time < 100) { + return; + } + + if (timeout > 0) { + this.clearMessage(); + } + } catch (e) { + console.error(e); + } + }); + } + + if (!timeout) { + timeout = 5000; + } + + if (timeout > 0) { + this.message_timeout = window.setTimeout(() => { + this.clearMessage(); + }, timeout); + } + } + public clearMessage(): void { + if (this.message_div) { + this.message_div.remove(); + delete this.message_div; + } + if (this.message_timeout) { + clearTimeout(this.message_timeout); + delete this.message_timeout; + } + + this.emit("clear-message"); + } + protected setTheme(themes: GobanSelectedThemes, dont_redraw: boolean): void { + if (this.no_display) { + console.log("No display"); + return; + } + + this.themes = themes; + const BoardTheme = THEMES["board"]?.[themes.board] || THEMES["board"]["Plain"]; + const WhiteTheme = THEMES["white"]?.[themes.white] || THEMES["white"]["Plain"]; + const BlackTheme = THEMES["black"]?.[themes.black] || THEMES["black"]["Plain"]; + this.theme_board = new BoardTheme(); + this.theme_white = new WhiteTheme(this.theme_board); + this.theme_black = new BlackTheme(this.theme_board); + + if (!this.metrics) { + this.metrics = this.computeMetrics(); + } + this.theme_stone_radius = this.computeThemeStoneRadius(); + if (isNaN(this.theme_stone_radius)) { + console.error( + "setThemes was not able to find the board size, metrics were: ", + JSON.stringify(this.metrics), + ); + throw new Error("invalid stone radius computed"); + } + + /* + const deferredRenderCallback = () => { + this.redraw(true); + this.move_tree_redraw(); + }; + */ + + try { + this.svg_defs?.remove(); + this.svg_defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + this.svg.appendChild(this.svg_defs); + + if (!(themes.white in __theme_cache.white)) { + __theme_cache.white[themes.white] = {}; + } + if (!(themes.black in __theme_cache.black)) { + __theme_cache.black[themes.black] = {}; + } + + this.theme_white.preRenderShadowSVG(this.svg_defs, "black"); + this.theme_white.preRenderShadowSVG(this.svg_defs, "white"); + + for (const radius of [this.theme_stone_radius, MoveTree.stone_radius]) { + __theme_cache.white[themes.white][radius] = this.theme_white.preRenderWhiteSVG( + this.svg_defs, + radius, + 23434, + () => {}, + ); + __theme_cache.black[themes.black][radius] = this.theme_black.preRenderBlackSVG( + this.svg_defs, + radius, + 2081, + () => {}, + ); + } + } catch (e) { + console.error(`Error pre-rendering stones.`, { + themes, + move_tree_stone_radius: MoveTree.stone_radius, + }); + throw e; + } + + this.theme_white_stones = __theme_cache.white[themes.white][this.theme_stone_radius]; + this.theme_black_stones = __theme_cache.black[themes.black][this.theme_stone_radius]; + if (!this.theme_white_stones || !this.theme_black_stones) { + throw new Error( + "Failed to load stone images for given radius" + this.theme_stone_radius, + ); + } + this.theme_line_color = this.theme_board.getLineColor(); + this.theme_faded_line_color = this.theme_board.getFadedLineColor(); + this.theme_star_color = this.theme_board.getStarColor(); + this.theme_faded_star_color = this.theme_board.getFadedStarColor(); + this.theme_blank_text_color = this.theme_board.getBlankTextColor(); + this.theme_black_text_color = this.theme_black.getBlackTextColor(); + this.theme_white_text_color = this.theme_white.getWhiteTextColor(); + const bg_css = this.theme_board.getBackgroundCSS(); + if (this.parent) { + for (const key in bg_css) { + (this.parent.style as any)[key] = (bg_css as any)[key]; + } + } + + if (!dont_redraw) { + this.redraw(true); + this.move_tree_redraw(); + } + } + private onLabelingStart(ev: MouseEvent | TouchEvent) { + const pos = getRelativeEventPosition(ev, this.parent); + this.last_label_position = this.xy2ij(pos.x, pos.y, false); + + { + const x = this.last_label_position.i; + const y = this.last_label_position.j; + if (!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { + return; + } + } + + this.labeling_mode = this.putOrClearLabel( + this.last_label_position.i, + this.last_label_position.j, + ) + ? "put" + : "clear"; + + /* clear hover */ + if (this.__last_pt.valid) { + const last_hover = this.last_hover_square; + delete this.last_hover_square; + if (last_hover) { + this.drawSquare(last_hover.x, last_hover.y); + } + } + this.__last_pt = this.xy2ij(-1, -1); + this.drawSquare(this.last_label_position.i, this.last_label_position.j); + } + private onLabelingMove(ev: MouseEvent | TouchEvent) { + const pos = getRelativeEventPosition(ev, this.parent); + const cur = this.xy2ij(pos.x, pos.y); - // Compute a line width that is rounded to the nearest 0.5 so we - // get crisp lines - const line_width = - ss > TINY_SQUARE_SIZE - ? Math.round(2 * Math.round(Math.max(1, ss * 0.02))) * 0.5 - : // for very small boards, like puzzle icons, have faint lines - ss * 0.08; - ox -= line_width * 0.5; - oy -= line_width * 0.5; + { + const x = cur.i; + const y = cur.j; + if (!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { + return; + } + } - // Round to half pixel offsets odd widths for crisp lines - ox = Math.round(ox * 2.0) * 0.5; - oy = Math.round(oy * 2.0) * 0.5; + if (cur.i !== this.last_label_position.i || cur.j !== this.last_label_position.j) { + this.last_label_position = cur; + this.putOrClearLabel(cur.i, cur.j, this.labeling_mode); + this.setLabelCharacterFromMarks(); + } + } + protected setTitle(title: string): void { + this.title = title; + if (this.title_div) { + this.title_div.innerHTML = title; + } + } + protected watchSelectedThemes(cb: (themes: GobanSelectedThemes) => void): { + remove: () => any; + } { + if (callbacks.watchSelectedThemes) { + return callbacks.watchSelectedThemes(cb); + } + return { remove: () => {} }; + } - this.lines_layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); - const lines_path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - let path_str = ""; - for (let x = 0; x < this.width; ++x) { - path_str += `M ${ox + x * ss} ${oy} L ${ox + x * ss} ${ - oy + (this.height - 1) * ss - } `; + // + // Move tree + // + public setMoveTreeContainer(container: HTMLElement): void { + this.move_tree_container = container; + this.move_tree_redraw(); + } + + public move_tree_redraw(no_warp?: boolean): void { + if (!this.move_tree_container) { + return; + } + + let do_init = false; + if (!this.move_tree_inner_container) { + do_init = true; + this.move_tree_inner_container = document.createElement("div"); + //this.move_tree_canvas = allocateCanvasOrError(); + this.move_tree_svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + this.move_tree_inner_container.appendChild(this.move_tree_svg); + this.move_tree_container.appendChild(this.move_tree_inner_container); + this.move_tree_bindEvents(this.move_tree_svg); + this.move_tree_container.style.position = "relative"; + this.move_tree_svg.style.position = "absolute"; + + try { + const observer = new ResizeObserver(() => { + this.move_tree_redraw(true); + }); + observer.observe(this.move_tree_container); + this.on("destroy", () => { + observer.disconnect(); + }); + } catch (e) { + // ResizeObserver is still fairly new and might not exist } - for (let y = 0; y < this.height; ++y) { - path_str += `M ${ox} ${oy + y * ss} L ${ox + (this.width - 1) * ss} ${ - oy + y * ss - } `; + } + + if (!this.move_tree_svg) { + console.warn(`move_tree_redraw called without move_tree_svg set`); + return; + } + + if (do_init || this.move_tree_inner_container.parentNode !== this.move_tree_container) { + const move_tree_on_scroll = (event: Event) => { + try { + this.move_tree_redraw(true); + } catch (e) { + console.error(e); + } + }; + + this.move_tree_container.appendChild(this.move_tree_inner_container); + this.move_tree_container.style.position = "relative"; + this.move_tree_container.removeEventListener("scroll", move_tree_on_scroll); + this.move_tree_container.addEventListener("scroll", move_tree_on_scroll); + const mt = this.move_tree_container; + this.on("destroy", () => { + mt.removeEventListener("scroll", move_tree_on_scroll); + }); + } + + if ( + this.move_tree_svg.clientWidth !== this.move_tree_container.clientWidth || + this.move_tree_svg.clientHeight !== this.move_tree_container.clientHeight + ) { + this.move_tree_svg.setAttribute( + "width", + this.move_tree_container.clientWidth.toString(), + ); + this.move_tree_svg.setAttribute( + "height", + this.move_tree_container.clientHeight.toString(), + ); + } + + this.engine.move_tree.recomputeIsobranches(); + const active_path_end = this.engine.cur_move; + + this.engine.move_tree_layout_dirty = false; + + active_path_end.setActivePath(++MoveTree.active_path_number); + + //const canvas = this.move_tree_canvas; + const svg = this.move_tree_svg; + const engine = this.engine; + + this.engine.move_tree_layout_vector = []; + const layout_hash = {}; + this.engine.move_tree.layout(0, 0, layout_hash, 0); + this.engine.move_tree_layout_hash = layout_hash; + let max_height = 0; + for (let i = 0; i < this.engine.move_tree_layout_vector.length; ++i) { + max_height = Math.max(this.engine.move_tree_layout_vector[i] + 1, max_height); + } + + const div_clientWidth = this.move_tree_container.clientWidth; + const div_clientHeight = this.move_tree_container.clientHeight; + const width = Math.max( + div_clientWidth, + this.engine.move_tree_layout_vector.length * MoveTree.stone_square_size, + ); + const height = Math.max(div_clientHeight, max_height * MoveTree.stone_square_size); + + let div_scroll_top = this.move_tree_container.scrollTop; + let div_scroll_left = this.move_tree_container.scrollLeft; + + this.move_tree_inner_container.style.width = width + "px"; + this.move_tree_inner_container.style.height = height + "px"; + + if (!no_warp) { + /* make sure our active stone is visible, but don't scroll around unnecessarily */ + if ( + div_scroll_left > active_path_end.layout_cx || + div_scroll_left + div_clientWidth - 20 < active_path_end.layout_cx || + div_scroll_top > active_path_end.layout_cy || + div_scroll_top + div_clientHeight - 20 < active_path_end.layout_cy + ) { + this.move_tree_container.scrollLeft = + active_path_end.layout_cx - div_clientWidth / 2; + this.move_tree_container.scrollTop = + active_path_end.layout_cy - div_clientHeight / 2; + div_scroll_top = this.move_tree_container.scrollTop; + div_scroll_left = this.move_tree_container.scrollLeft; } - lines_path.setAttribute("d", path_str); - lines_path.setAttribute("stroke", this.theme_line_color); - if (ss > TINY_SQUARE_SIZE) { - lines_path.setAttribute("stroke-width", `${line_width.toFixed(0)}px`); - } else { - lines_path.setAttribute("stroke-width", `${line_width.toFixed(1)}px`); + } + + svg.style.top = div_scroll_top + "px"; + svg.style.left = div_scroll_left + "px"; + + const viewport = { + offset_x: div_scroll_left, + offset_y: div_scroll_top, + minx: div_scroll_left - MoveTree.stone_square_size, + miny: div_scroll_top - MoveTree.stone_square_size, + maxx: div_scroll_left + div_clientWidth + MoveTree.stone_square_size, + maxy: div_scroll_top + div_clientHeight + MoveTree.stone_square_size, + }; + + svg.innerHTML = ""; + + this.move_tree_hilightNode(svg, active_path_end, "#6BAADA", viewport); + + if (engine.cur_review_move && engine.cur_review_move.id !== active_path_end.id) { + this.move_tree_hilightNode(svg, engine.cur_review_move, "#6BDA6B", viewport); + } + + this.move_tree_recursiveDrawPath(svg, this.engine.move_tree, viewport); + + this.move_tree_drawRecursive( + svg, + this.engine.move_tree, + MoveTree.active_path_number, + viewport, + ); + } + public move_tree_bindEvents(svg: SVGElement): void { + const handler = (event: TouchEvent | MouseEvent) => { + try { + if (!this.move_tree_container) { + throw new Error(`move_tree_container was not set`); + } + + const ox = this.move_tree_container.scrollLeft; + const oy = this.move_tree_container.scrollTop; + const pos = getRelativeEventPosition(event, this.move_tree_container); + pos.x += ox; + pos.y += oy; + const i = Math.floor(pos.x / MoveTree.stone_square_size); + const j = Math.floor(pos.y / MoveTree.stone_square_size); + const node = this.engine.move_tree.getNodeAtLayoutPosition(i, j); + + if (node) { + if (this.engine.cur_move.id !== node.id) { + this.engine.jumpTo(node); + this.setLabelCharacterFromMarks(); + this.updateTitleAndStonePlacement(); + this.emit("update"); + this.syncReviewMove(); + this.redraw(); + } + if (this.engine.cur_move.played_by) { + // note that getRelativeEventPosition handles various + // nasty looking things to do with Touch etc, so using it here + // gets around that kind of thing, even though in theory it + // might be nicer to sent the client absolute coords, maybe. + const rpos = getRelativeEventPosition(event, this.move_tree_container); + this.emit("played-by-click", { + player_id: this.engine.cur_move.played_by, + x: rpos.x, + y: rpos.y, + }); + } + } + } catch (e) { + console.error(e); } - lines_path.setAttribute("stroke-linecap", "square"); - this.lines_layer.appendChild(lines_path); + }; - // Hoshi / star points - let hoshi = null; + svg.addEventListener("touchstart", handler); + svg.addEventListener("mousedown", handler); - if (this.width === 19 && this.height === 19) { - hoshi = [ - [3, 3], - [3, 9], - [3, 15], - [9, 3], - [9, 9], - [9, 15], - [15, 3], - [15, 9], - [15, 15], - ]; - } + this.on("destroy", () => { + svg.removeEventListener("touchstart", handler); + svg.removeEventListener("mousedown", handler); + }); + } - if (this.width === 13 && this.height === 13) { - hoshi = [ - [3, 3], - [3, 9], - [6, 6], - [9, 3], - [9, 9], - ]; - } + move_tree_drawStone( + svg: SVGElement, + node: MoveTree, + active_path_number: number, + viewport: MoveTreeViewPortInterface, + ): void { + const stone_idx = node.move_number * 31; + const cx = node.layout_cx - viewport.offset_x; + const cy = node.layout_cy - viewport.offset_y; + const color = node.player; + const on_path = node.active_path_number === active_path_number; + const r = MoveTree.stone_radius; - if (this.width === 9 && this.height === 9) { - hoshi = [ - [2, 2], - [2, 6], - [4, 4], - [6, 2], - [6, 6], - ]; - } + const cell = document.createElementNS("http://www.w3.org/2000/svg", "g"); + svg.appendChild(cell); + if (!on_path) { + cell.setAttribute("class", "move-tree-stone"); + cell.setAttribute("fill-opacity", "0.6"); + cell.setAttribute("stroke-opacity", "0.6"); + cell.setAttribute("opacity", "0.6"); + } - if (hoshi) { - const r = this.square_size * 0.075; - for (let i = 0; i < hoshi.length; ++i) { - const [hx, hy] = hoshi[i]; - const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circle.setAttribute("cx", (ox + hx * ss).toString()); - circle.setAttribute("cy", (oy + hy * ss).toString()); - circle.setAttribute("r", `${r.toFixed(1)}px`); - circle.setAttribute("fill", this.theme_star_color); - this.lines_layer.appendChild(circle); - } - } - this.svg.appendChild(this.lines_layer); + const theme_white_stones = __theme_cache.white[this.themes.white][r]; + const theme_black_stones = __theme_cache.black[this.themes.black][r]; + + if (!theme_white_stones || !theme_black_stones) { + throw new Error( + "Failed to load stone images for given radius" + this.theme_stone_radius, + ); } - } - private drawCoordinateLabels(force_clear?: boolean): void { - if (force_clear) { - if (this.coordinate_labels_layer) { - this.coordinate_labels_layer.remove(); - delete this.coordinate_labels_layer; - } + if (color === 1) { + const stone = theme_black_stones[stone_idx % theme_black_stones.length]; + this.theme_black.placeBlackStoneSVG(cell, undefined, stone, cx, cy, r); + } else if (color === 2) { + const stone = theme_white_stones[stone_idx % theme_white_stones.length]; + this.theme_white.placeWhiteStoneSVG(cell, undefined, stone, cx, cy, r); + } else { + return; } - if (!this.coordinate_labels_layer) { - this.coordinate_labels_layer = document.createElementNS( - "http://www.w3.org/2000/svg", - "g", - ); - this.coordinate_labels_layer.setAttribute("class", "coordinate-labels"); + const text_color = color === 1 ? this.theme_black_text_color : this.theme_white_text_color; - let text_size = Math.round(this.square_size * 0.5); - let bold_or_not = "bold"; - if (this.getCoordinateDisplaySystem() === "1-1") { - text_size *= 0.7; - bold_or_not = ""; + let label = ""; + switch (callbacks.getMoveTreeNumbering ? callbacks.getMoveTreeNumbering() : "move-number") { + case "move-coordinates": + label = node.pretty_coordinates; + break; - if (this.height > 20) { - text_size *= 0.7; + case "none": + label = ""; + break; + + case "move-number": + default: + if (node.pretty_coordinates === "pass") { + label = String("."); + } else { + label = String(node.move_number); } - } + break; + } - const place = (ch: string, x: number, y: number): void => { - /* places centered (horizontally & vertically) text at x,y */ - const ox = 0; - const oy = this.square_size / 6; + if (node.label !== label) { + node.label = label; + delete node.label_metrics; + } - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", (x + ox).toFixed(0)); - text.setAttribute("y", (y + oy).toFixed(0)); - text.setAttribute("font-size", `${Math.round(text_size)}px`); - text.setAttribute("font-size", `${Math.round(text_size)}px`); - text.setAttribute("font-weight", bold_or_not); - text.setAttribute("fill", this.theme_board.getLabelTextColor()); - text.textContent = ch; - this.coordinate_labels_layer!.appendChild(text); - }; - const v_place = (ch: string, x: number, y: number): void => { - /* places centered (horizontally & vertically) text at x,y, with text going down vertically. */ - for (let i = 0; i < ch.length; ++i) { - //const xx = x - text_size / 2; - const H = text_size; - const xx = x; - let yy = y + text_size / 2.5; + const font_size = 10; + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", cx.toFixed(0)); + text.setAttribute("y", (cy + font_size / 8).toFixed(1)); + text.setAttribute("width", MoveTree.stone_square_size.toFixed(1)); + text.setAttribute("height", MoveTree.stone_square_size.toFixed(1)); + text.setAttribute("font-size", `${font_size}px`); + text.setAttribute("font-weight", "bold"); + text.setAttribute("alignment-baseline", "middle"); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("dominant-baseline", "middle"); + text.setAttribute("fill", text_color); + text.textContent = node.label; - if (ch.length === 2) { - yy = yy - H * 0.5 + i * H; - } - if (ch.length === 3) { - yy = yy - H * 1 + i * H + 0.5; - } + cell.appendChild(text); - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", xx.toFixed(0)); - text.setAttribute("y", yy.toFixed(1)); - text.setAttribute("font-size", `${Math.round(text_size)}px`); - text.setAttribute("font-weight", bold_or_not); - text.setAttribute("fill", this.theme_board.getLabelTextColor()); - text.textContent = ch[i]; - this.coordinate_labels_layer!.appendChild(text); - } - }; + const ring_color = node.text + ? "#3333ff" + : node.correct_answer + ? "#33ff33" + : node.wrong_answer + ? "#ff3333" + : null; - const drawHorizontal = (i: number, j: number): void => { - switch (this.getCoordinateDisplaySystem()) { - case "A1": - for (let c = 0; c < this.width; ++i, ++c) { - const x = - (i - - this.bounds.left - - (this.bounds.left > 0 ? +this.draw_left_labels : 0)) * - this.square_size + - this.square_size / 2; - const y = j * this.square_size + this.square_size / 2; - place(encodePrettyXCoordinate(c), x, y); - } - break; - case "1-1": - for (let c = 0; c < this.width; ++i, ++c) { - const x = - (i - - this.bounds.left - - (this.bounds.left > 0 ? +this.draw_left_labels : 0)) * - this.square_size + - this.square_size / 2; - const y = j * this.square_size + this.square_size / 2; - place("" + (c + 1), x, y); - } - break; - } - }; + if (ring_color) { + const ring = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + ring.setAttribute("cx", cx.toFixed(0)); + ring.setAttribute("cy", cy.toFixed(1)); + ring.setAttribute("r", r.toFixed(0)); + ring.setAttribute("fill", "none"); + ring.setAttribute("stroke", ring_color); + ring.setAttribute("stroke-width", "2"); + cell.appendChild(ring); + } + } + move_tree_drawRecursive( + svg: SVGElement, + node: MoveTree, + active_path_number: number, + viewport: MoveTreeViewPortInterface, + ): void { + if (node.trunk_next) { + this.move_tree_drawRecursive(svg, node.trunk_next, active_path_number, viewport); + } + for (let i = 0; i < node.branches.length; ++i) { + this.move_tree_drawRecursive(svg, node.branches[i], active_path_number, viewport); + } - const drawVertical = (i: number, j: number): void => { - switch (this.getCoordinateDisplaySystem()) { - case "A1": - for (let c = 0; c < this.height; ++j, ++c) { - const x = i * this.square_size + this.square_size / 2; - const y = - (j - - this.bounds.top - - (this.bounds.top > 0 ? +this.draw_top_labels : 0)) * - this.square_size + - this.square_size / 2; - place("" + (this.height - c), x, y); - } - break; - case "1-1": - const chinese_japanese_numbers = [ - "一", - "二", - "三", - "四", - "五", - "六", - "七", - "八", - "九", - "十", - "十一", - "十二", - "十三", - "十四", - "十五", - "十六", - "十七", - "十八", - "十九", - "二十", - "二十一", - "二十二", - "二十三", - "二十四", - "二十五", - ]; - for (let c = 0; c < this.height; ++j, ++c) { - const x = i * this.square_size + this.square_size / 2; - const y = - (j - - this.bounds.top - - (this.bounds.top > 0 ? +this.draw_top_labels : 0)) * - this.square_size + - this.square_size / 2; - v_place(chinese_japanese_numbers[c], x, y); - } - break; - } - }; + if ( + !viewport || + (node.layout_cx >= viewport.minx && + node.layout_cx <= viewport.maxx && + node.layout_cy >= viewport.miny && + node.layout_cy <= viewport.maxy) + ) { + this.move_tree_drawStone(svg, node, active_path_number, viewport); + } + } + move_tree_hilightNode( + svg: SVGElement, + node: MoveTree, + color: string, + viewport: MoveTreeViewPortInterface, + ): void { + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + const sx = + Math.round(node.layout_cx - MoveTree.stone_square_size * 0.5) - viewport.offset_x; + const sy = + Math.round(node.layout_cy - MoveTree.stone_square_size * 0.5) - viewport.offset_y; + rect.setAttribute("x", sx.toFixed(0)); + rect.setAttribute("y", sy.toFixed(0)); + rect.setAttribute("width", MoveTree.stone_square_size.toFixed(0)); + rect.setAttribute("height", MoveTree.stone_square_size.toFixed(0)); + rect.setAttribute("fill", color); + svg.appendChild(rect); + } - if (this.draw_top_labels && this.bounds.top === 0) { - drawHorizontal(this.draw_left_labels ? 1 : 0, 0); + move_tree_drawPath(svg: SVGElement, node: MoveTree, viewport: MoveTreeViewPortInterface): void { + if (node.parent) { + if (node.parent.layout_cx < viewport.minx && node.layout_cx < viewport.minx) { + return; } - if (this.draw_bottom_labels && this.bounds.bottom === this.height - 1) { - drawHorizontal( - this.draw_left_labels ? 1 : 0, - +this.draw_top_labels + this.bounded_height, - ); + if (node.parent.layout_cy < viewport.miny && node.layout_cy < viewport.miny) { + return; } - if (this.draw_left_labels && this.bounds.left === 0) { - drawVertical(0, this.draw_top_labels ? 1 : 0); + if (node.parent.layout_cx > viewport.maxx && node.layout_cx > viewport.maxx) { + return; } - if (this.draw_right_labels && this.bounds.right === this.width - 1) { - drawVertical(+this.draw_left_labels + this.bounded_width, +this.draw_top_labels); + if (node.parent.layout_cy > viewport.maxy && node.layout_cy > viewport.maxy) { + return; } - this.svg.appendChild(this.coordinate_labels_layer); + const curve = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const ox = viewport.offset_x; + const oy = viewport.offset_y; + curve.setAttribute( + "d", + `M ${node.parent.layout_cx - ox} ${node.parent.layout_cy - oy} Q ${ + node.layout_cx - MoveTree.stone_square_size * 0.5 - ox + } ${node.layout_cy - oy} ${node.layout_cx - ox} ${node.layout_cy - oy}`, + ); + curve.setAttribute("fill", "none"); + curve.setAttribute( + "stroke", + node.trunk ? "#000000" : MoveTree.line_colors[node.line_color], + ); + curve.setAttribute("stroke-width", "1"); + svg.appendChild(curve); } } + move_tree_drawIsoBranchTo( + svg: SVGElement, + from_node: MoveTree, + to_node: MoveTree, + viewport: MoveTreeViewPortInterface, + ): void { + let A: MoveTree = from_node; + let B: MoveTree = to_node; - protected computeThemeStoneRadius(): number { - const r = this.square_size * 0.5; - return Math.max(1, r); - } - - public redraw(force_clear?: boolean): void { - if (!this.ready_to_draw) { + /* don't render if it's off screen */ + if (A.layout_cx < viewport.minx && B.layout_cx < viewport.minx) { return; } - if (this.destroyed) { - console.debug("Attempting to redraw destroyed goban"); + if (A.layout_cy < viewport.miny && B.layout_cy < viewport.miny) { return; } - if (!this.drawing_enabled) { + if (A.layout_cx > viewport.maxx && B.layout_cx > viewport.maxx) { return; } - if (this.no_display) { + if (A.layout_cy > viewport.maxy && B.layout_cy > viewport.maxy) { return; } - const metrics = (this.metrics = this.computeMetrics()); if ( - force_clear || - !( - this.__set_board_width === metrics.width && - this.__set_board_height === metrics.height && - this.theme_stone_radius === this.computeThemeStoneRadius() - ) + B.trunk_next === null && + B.branches.length === 0 && + (A.trunk_next !== null || A.branches.length !== 0) ) { - force_clear = true; - try { - this.parent.style.width = metrics.width + "px"; - this.parent.style.height = metrics.height + "px"; - this.svg.setAttribute("width", metrics.width.toString()); - this.svg.setAttribute("height", metrics.height.toString()); - - this.__set_board_width = metrics.width; - this.__set_board_height = metrics.height; - - this.setTheme(this.getSelectedThemes(), true); - } catch (e) { - setTimeout(() => { - throw e; - }, 1); - return; - } - } - - this.drawLines(force_clear); - this.drawCoordinateLabels(force_clear); - - if (force_clear || !this.grid_layer || !this.shadow_layer) { - this.shadow_layer?.remove(); - this.shadow_layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); - this.shadow_layer.setAttribute("class", "shadow-layer"); - this.svg.appendChild(this.shadow_layer); - - this.grid_layer?.remove(); - this.grid_layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); - this.grid_layer.setAttribute("class", "grid"); - this.svg.appendChild(this.grid_layer); - - for (let j = 0; j < this.height; ++j) { - this.grid[j] = []; - this.shadow_grid[j] = []; - /* - for (let i = 0; i < this.width; ++i) { - const cell = document.createElementNS("http://www.w3.org/2000/svg", "g"); - cell.setAttribute("x", (i * this.square_size).toString()); - cell.setAttribute("y", (j * this.square_size).toString()); - cell.setAttribute("width", this.square_size.toString()); - cell.setAttribute("height", this.square_size.toString()); - this.grid_layer.appendChild(cell); - - this.grid[j][i] = cell; - } - */ - } + const t = A; + A = B; + B = t; } - /* Draw squares */ - if ( - !this.__draw_state || - force_clear || - this.__draw_state.length !== this.height || - this.__draw_state[0].length !== this.width - ) { - this.__draw_state = makeMatrix(this.width, this.height, ""); - } + const strong = + A.trunk_next == null && + A.branches.length === 0 && + (B.trunk_next !== null || B.branches.length !== 0); - for (let j = this.bounds.top; j <= this.bounds.bottom; ++j) { - for (let i = this.bounds.left; i <= this.bounds.right; ++i) { - this.drawSquare(i, j); - } - } + const ox = viewport.offset_x; + const oy = viewport.offset_y; - if (this.pen_marks) { - if (force_clear) { - this.detachPenLayer(); - } - this.drawPenMarks(this.pen_marks); - } - this.move_tree_redraw(); + const curve = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const my = strong ? B.layout_cy : (A.layout_cy + B.layout_cy) / 2; + const mx = (A.layout_cx + B.layout_cx) / 2 + MoveTree.stone_square_size * 0.5; + curve.setAttribute( + "d", + `M ${B.layout_cx - ox} ${B.layout_cy - oy} Q ${mx - ox} ${my - oy} ${ + A.layout_cx - ox + } ${A.layout_cy - oy}`, + ); + curve.setAttribute("fill", "none"); + curve.setAttribute("stroke", MoveTree.isobranch_colors[strong ? "strong" : "weak"]); + curve.setAttribute("stroke-width", "2"); + svg.appendChild(curve); } - - public showMessage( - message_id_or_error: MessageID, - parameters?: { [key: string]: any }, - timeout: number = 5000, + move_tree_recursiveDrawPath( + svg: SVGElement, + node: MoveTree, + viewport: MoveTreeViewPortInterface, ): void { - this.clearMessage(); - - const message_id = parameters?.error?.message_id || message_id_or_error; + if (node.trunk_next) { + this.move_tree_recursiveDrawPath(svg, node.trunk_next, viewport); + } + for (let i = 0; i < node.branches.length; ++i) { + this.move_tree_recursiveDrawPath(svg, node.branches[i], viewport); + } - const msg = formatMessage(message_id, parameters); - this.emit("show-message", { - formatted: msg, - message_id: message_id, - parameters: parameters, - }); + if (node.isobranches) { + for (let i = 0; i < node.isobranches.length; ++i) { + this.move_tree_drawIsoBranchTo(svg, node, node.isobranches[i], viewport); + } + } - if (!this.config.dont_show_messages) { - this.message_div = document.createElement("div"); - this.message_div.className = "GobanMessage"; - this.message_td = document.createElement("td"); - const table = document.createElement("table"); - const tr = document.createElement("tr"); - tr.appendChild(this.message_td); - table.appendChild(tr); - this.message_div.appendChild(table); - this.message_text = document.createElement("div"); - this.message_text.innerHTML = msg; - this.message_td.appendChild(this.message_text); - this.parent.appendChild(this.message_div); + this.move_tree_drawPath(svg, node, viewport); + } +} - const message_time = Date.now(); - this.message_div.addEventListener("click", () => { - try { - if (Date.now() - message_time < 100) { - return; - } +class GCell { + renderer: SVGRenderer; + i: number; + j: number; + _g?: SVGGraphicsElement; + _transform: string = ""; - if (timeout > 0) { - this.clearMessage(); - } - } catch (e) { - console.error(e); - } - }); - } + private _faded_lines?: SVGPathElement; + private _faded_star_point?: SVGCircleElement; - if (!timeout) { - timeout = 5000; - } + constructor(renderer: SVGRenderer, i: number, j: number) { + this.renderer = renderer; + this.i = i; + this.j = j; + } - if (timeout > 0) { - this.message_timeout = window.setTimeout(() => { - this.clearMessage(); - }, timeout); + public get g(): SVGGraphicsElement { + if (!this._g) { + this._g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + this.renderer.grid_layer!.appendChild(this._g); } + return this._g; } - public clearMessage(): void { - if (this.message_div) { - this.message_div.remove(); - delete this.message_div; - } - if (this.message_timeout) { - clearTimeout(this.message_timeout); - delete this.message_timeout; - } - this.emit("clear-message"); - } - protected setTheme(themes: GobanSelectedThemes, dont_redraw: boolean): void { - if (this.no_display) { - console.log("No display"); + public set transform(transform: string) { + if (this._transform === transform) { return; } - this.themes = themes; - const BoardTheme = THEMES["board"]?.[themes.board] || THEMES["board"]["Plain"]; - const WhiteTheme = THEMES["white"]?.[themes.white] || THEMES["white"]["Plain"]; - const BlackTheme = THEMES["black"]?.[themes.black] || THEMES["black"]["Plain"]; - this.theme_board = new BoardTheme(); - this.theme_white = new WhiteTheme(this.theme_board); - this.theme_black = new BlackTheme(this.theme_board); + this._transform = transform; - if (!this.metrics) { - this.metrics = this.computeMetrics(); - } - this.theme_stone_radius = this.computeThemeStoneRadius(); - if (isNaN(this.theme_stone_radius)) { - console.error( - "setThemes was not able to find the board size, metrics were: ", - JSON.stringify(this.metrics), - ); - throw new Error("invalid stone radius computed"); - } + this.g.setAttribute("transform", transform); + /* TODO: Deal with shadows * /* - const deferredRenderCallback = () => { - this.redraw(true); - this.move_tree_redraw(); - }; + if (this.shadow_grid[j][i]) { + this.shadow_grid[j][i].setAttribute("transform", transform); + } */ + } - try { - this.svg_defs?.remove(); - this.svg_defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); - this.svg.appendChild(this.svg_defs); + /* + * Faded intersection lines + */ + private last_faded_intersection_line_parameters?: { + sx: number; + sy: number; + ex: number; + ey: number; + mx: number; + my: number; + draw_star_point: boolean; + star_radius: number; + cx: number; + cy: number; + }; - if (!(themes.white in __theme_cache.white)) { - __theme_cache.white[themes.white] = {}; - } - if (!(themes.black in __theme_cache.black)) { - __theme_cache.black[themes.black] = {}; - } + public drawFadedIntersectionLines(draw_star_point: boolean, star_radius: number): void { + const mid = this.renderer.metrics.mid; + const ss = this.renderer.square_size; + const offset = this.renderer.metrics.offset; + const width = this.renderer.width; + const height = this.renderer.height; - this.theme_white.preRenderShadowSVG(this.svg_defs, "black"); - this.theme_white.preRenderShadowSVG(this.svg_defs, "white"); + let sx = 0; + let ex = ss; + const mx = ss / 2 - offset; + let sy = 0; + let ey = ss; + const my = ss / 2 - offset; - for (const radius of [this.theme_stone_radius, MoveTree.stone_radius]) { - __theme_cache.white[themes.white][radius] = this.theme_white.preRenderWhiteSVG( - this.svg_defs, - radius, - 23434, - () => {}, - ); - __theme_cache.black[themes.black][radius] = this.theme_black.preRenderBlackSVG( - this.svg_defs, - radius, - 2081, - () => {}, - ); - } - } catch (e) { - console.error(`Error pre-rendering stones.`, { - themes, - move_tree_stone_radius: MoveTree.stone_radius, - }); - throw e; + if (this.i === 0) { + sx += mid; } - - this.theme_white_stones = __theme_cache.white[themes.white][this.theme_stone_radius]; - this.theme_black_stones = __theme_cache.black[themes.black][this.theme_stone_radius]; - if (!this.theme_white_stones || !this.theme_black_stones) { - throw new Error( - "Failed to load stone images for given radius" + this.theme_stone_radius, - ); + if (this.i === width - 1) { + ex -= mid; } - this.theme_line_color = this.theme_board.getLineColor(); - this.theme_faded_line_color = this.theme_board.getFadedLineColor(); - this.theme_star_color = this.theme_board.getStarColor(); - this.theme_faded_star_color = this.theme_board.getFadedStarColor(); - this.theme_blank_text_color = this.theme_board.getBlankTextColor(); - this.theme_black_text_color = this.theme_black.getBlackTextColor(); - this.theme_white_text_color = this.theme_white.getWhiteTextColor(); - const bg_css = this.theme_board.getBackgroundCSS(); - if (this.parent) { - for (const key in bg_css) { - (this.parent.style as any)[key] = (bg_css as any)[key]; - } + if (this.j === 0) { + sy += mid; + } + if (this.j === height - 1) { + ey -= mid; } - if (!dont_redraw) { - this.redraw(true); - this.move_tree_redraw(); + if (this.i === width - 1 && this.j === height - 1) { + if (mx === ex && my === ey) { + ex += 1; + ey += 1; + } } - } - private onLabelingStart(ev: MouseEvent | TouchEvent) { - const pos = getRelativeEventPosition(ev, this.parent); - this.last_label_position = this.xy2ij(pos.x, pos.y, false); - { - const x = this.last_label_position.i; - const y = this.last_label_position.j; - if (!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { + const cx = mid; + const cy = mid; + + if (this.last_faded_intersection_line_parameters) { + if ( + this.last_faded_intersection_line_parameters.sx === sx && + this.last_faded_intersection_line_parameters.sy === sy && + this.last_faded_intersection_line_parameters.ex === ex && + this.last_faded_intersection_line_parameters.ey === ey && + this.last_faded_intersection_line_parameters.mx === mx && + this.last_faded_intersection_line_parameters.my === my && + this.last_faded_intersection_line_parameters.draw_star_point === draw_star_point && + this.last_faded_intersection_line_parameters.star_radius === star_radius && + this.last_faded_intersection_line_parameters.cx === cx && + this.last_faded_intersection_line_parameters.cy === cy + ) { return; } } + this.last_faded_intersection_line_parameters = { + sx, + sy, + ex, + ey, + mx, + my, + draw_star_point, + star_radius, + cx, + cy, + }; + this.clearFadedLines(); + const g = this.g; - this.labeling_mode = this.putOrClearLabel( - this.last_label_position.i, - this.last_label_position.j, - ) - ? "put" - : "clear"; + if (draw_star_point) { + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + this._faded_star_point = circ; + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", star_radius.toString()); + circ.setAttribute("fill", this.renderer.theme_faded_star_color); + g.prepend(circ); + } - /* clear hover */ - if (this.__last_pt.valid) { - const last_hover = this.last_hover_square; - delete this.last_hover_square; - if (last_hover) { - this.drawSquare(last_hover.x, last_hover.y); - } + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + this._faded_lines = path; + path.setAttribute("stroke", this.renderer.theme_faded_line_color); + path.setAttribute("stroke-width", this.renderer.square_size < 5 ? "0.2" : "1"); + path.setAttribute("fill", "none"); + path.setAttribute( + "d", + ` + M ${Math.floor(sx)} ${my} L ${Math.floor(ex)} ${my} + M ${mx} ${Math.floor(sy)} L ${mx} ${Math.floor(ey)} + `, + ); + g.prepend(path); + } + + public clearFadedLines(): void { + if (this._faded_lines) { + this._faded_lines.remove(); + delete this._faded_lines; + } + if (this._faded_star_point) { + this._faded_star_point.remove(); + delete this._faded_star_point; } - this.__last_pt = this.xy2ij(-1, -1); - this.drawSquare(this.last_label_position.i, this.last_label_position.j); } - private onLabelingMove(ev: MouseEvent | TouchEvent) { - const pos = getRelativeEventPosition(ev, this.parent); - const cur = this.xy2ij(pos.x, pos.y); - { - const x = cur.i; - const y = cur.j; - if (!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { - return; - } + /* + * Heatmap + */ + private last_heatmap_value?: number; + private last_heatmap_rect?: SVGRectElement; + + public heatmap(value: number): void { + if (this.last_heatmap_value === value) { + return; } - if (cur.i !== this.last_label_position.i || cur.j !== this.last_label_position.j) { - this.last_label_position = cur; - this.putOrClearLabel(cur.i, cur.j, this.labeling_mode); - this.setLabelCharacterFromMarks(); + if (!this.last_heatmap_rect) { + const mid = this.renderer.metrics.mid; + const ss = this.renderer.square_size; + const cx = mid; + const cy = mid; + const color = "#00FF00"; + const r = Math.floor(ss * 0.5) - 0.5; + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + this.last_heatmap_rect = rect; + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + this.last_heatmap_rect.setAttribute("fill", color); + this.g.appendChild(this.last_heatmap_rect); + } + + this.last_heatmap_value = value; + this.last_heatmap_rect.setAttribute("fill-opacity", Math.min(value, 0.5).toFixed(2)); + } + public clearHeatmap(): void { + if (this.last_heatmap_rect) { + this.last_heatmap_rect.remove(); + delete this.last_heatmap_rect; + this.last_heatmap_value = undefined; } } - protected setTitle(title: string): void { - this.title = title; - if (this.title_div) { - this.title_div.innerHTML = title; + + /* + * Highlights + */ + + private last_highlight_color?: string; + private last_highlight_rect?: SVGRectElement; + public highlight(color: string): void { + if (this.last_highlight_color === color) { + return; } + + if (!this.last_highlight_rect) { + const mid = this.renderer.metrics.mid; + const ss = this.renderer.square_size; + const cx = mid; + const cy = mid; + const r = Math.floor(ss * 0.5) - 0.5; + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + this.last_highlight_rect = rect; + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + rect.setAttribute("fill-opacity", "0.6"); + this.g.appendChild(rect); + } + + this.last_highlight_rect.setAttribute("fill", color); + this.last_highlight_color = color; } - protected watchSelectedThemes(cb: (themes: GobanSelectedThemes) => void): { - remove: () => any; - } { - if (callbacks.watchSelectedThemes) { - return callbacks.watchSelectedThemes(cb); + public clearHighlight(): void { + if (this.last_highlight_rect) { + this.last_highlight_rect.remove(); + delete this.last_highlight_rect; + this.last_highlight_color = undefined; } - return { remove: () => {} }; } - // - // Move tree - // - public setMoveTreeContainer(container: HTMLElement): void { - this.move_tree_container = container; - this.move_tree_redraw(); - } + /* + * Colored circles + */ + private last_circle?: SVGCircleElement; + private last_circle_fill?: string; + private last_circle_radius?: number; + private last_circle_stroke?: string; + private last_circle_stroke_width?: number; + public circle(circle: ColoredCircle): void { + const ss = this.renderer.square_size; + const radius = Math.floor(ss * 0.5) - 0.5; + + if (!this.last_circle) { + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; - public move_tree_redraw(no_warp?: boolean): void { - if (!this.move_tree_container) { + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + this.last_circle = circ; + circ.setAttribute("class", "colored-circle"); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + this.g.appendChild(circ); + } + + if (this.last_circle_fill !== circle.color) { + this.last_circle.setAttribute("fill", circle.color); + this.last_circle_fill = circle.color; + } + if (this.last_circle_stroke !== circle.border_color) { + if (circle.border_color) { + this.last_circle.setAttribute("stroke", circle.border_color); + } else { + this.last_circle.removeAttribute("stroke"); + } + this.last_circle_stroke = circle.border_color; + } + + let lineWidth = radius * (circle.border_width || 0.1); + if (lineWidth < 0.3) { + lineWidth = 0; + } + + if (this.last_circle_stroke_width !== circle.border_width) { + if (lineWidth > 0) { + this.last_circle.setAttribute("stroke-width", lineWidth.toFixed(1)); + } else { + this.last_circle.setAttribute("stroke-width", "1px"); + } + this.last_circle_stroke_width = circle.border_width; + } + if (this.last_circle_radius !== radius) { + this.last_circle.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); + this.last_circle_radius = radius; + } + } + public clearCircle(): void { + if (this.last_circle) { + this.last_circle.remove(); + delete this.last_circle; + delete this.last_circle_fill; + delete this.last_circle_radius; + delete this.last_circle_stroke; + delete this.last_circle_stroke_width; + } + } + + /* + * Stone + */ + private last_stone?: SVGElement; + private last_stone_color?: JGOFNumericPlayerColor; + private last_stone_transparent?: boolean; + private last_stone_stone?: string; + private last_stone_radius?: number; + private last_stone_alpha_value?: number; + + public stone( + color: JGOFNumericPlayerColor, + transparent: boolean, + stone: string, + radius: number, + stone_alpha_value: number, + ): void { + if ( + this.last_stone && + this.last_stone_color === color && + this.last_stone_transparent === transparent && + this.last_stone_stone === stone && + this.last_stone_radius === radius && + this.last_stone_alpha_value === stone_alpha_value + ) { return; } - let do_init = false; - if (!this.move_tree_inner_container) { - do_init = true; - this.move_tree_inner_container = document.createElement("div"); - //this.move_tree_canvas = allocateCanvasOrError(); - this.move_tree_svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - this.move_tree_inner_container.appendChild(this.move_tree_svg); - this.move_tree_container.appendChild(this.move_tree_inner_container); - this.move_tree_bindEvents(this.move_tree_svg); - this.move_tree_container.style.position = "relative"; - this.move_tree_svg.style.position = "absolute"; - - try { - const observer = new ResizeObserver(() => { - this.move_tree_redraw(true); - }); - observer.observe(this.move_tree_container); - this.on("destroy", () => { - observer.disconnect(); - }); - } catch (e) { - // ResizeObserver is still fairly new and might not exist - } + this.clearStone(); + + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + // TODO: Need to handle shadows + const [elt, shadow] = + color === JGOFNumericPlayerColor.BLACK + ? this.renderer.theme_black.placeBlackStoneSVG( + this.g, + //transparent ? undefined : this.shadow_layer, + undefined, + stone, + cx, + cy, + radius, + ) + : this.renderer.theme_black.placeWhiteStoneSVG( + this.g, + //transparent ? undefined : this.shadow_layer, + undefined, + stone, + cx, + cy, + radius, + ); + if (shadow) { + console.warn("Shadows not implemented"); + } + //this.shadow_grid[j][i] = shadow; + if (transparent) { + elt.setAttribute("opacity", stone_alpha_value.toString()); + } + + this.last_stone = elt; + this.last_stone_color = color; + this.last_stone_transparent = transparent; + this.last_stone_stone = stone; + this.last_stone_radius = radius; + this.last_stone_alpha_value = stone_alpha_value; + } + public clearStone(): void { + if (this.last_stone) { + this.last_stone.remove(); + delete this.last_stone; } + } - if (!this.move_tree_svg) { - console.warn(`move_tree_redraw called without move_tree_svg set`); + /* + * Blue move + */ + private last_blue_move?: SVGCircleElement; + private last_blue_move_color?: string; + private last_blue_move_border_width?: number; + + public blueMove(color: string, border_width: number) { + if ( + this.last_blue_move && + this.last_blue_move_color === color && + this.last_blue_move_border_width === border_width + ) { return; } - if (do_init || this.move_tree_inner_container.parentNode !== this.move_tree_container) { - const move_tree_on_scroll = (event: Event) => { - try { - this.move_tree_redraw(true); - } catch (e) { - console.error(e); - } - }; + this.clearBlueMove(); - this.move_tree_container.appendChild(this.move_tree_inner_container); - this.move_tree_container.style.position = "relative"; - this.move_tree_container.removeEventListener("scroll", move_tree_on_scroll); - this.move_tree_container.addEventListener("scroll", move_tree_on_scroll); - const mt = this.move_tree_container; - this.on("destroy", () => { - mt.removeEventListener("scroll", move_tree_on_scroll); - }); + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + + const radius = Math.floor(ss * 0.5) - 0.5; + let lineWidth = radius * (border_width || 0.1); + if (lineWidth < 0.3) { + lineWidth = 0; + } + + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circ.setAttribute("class", "colored-circle"); + circ.setAttribute("stroke", color); + if (lineWidth > 0) { + circ.setAttribute("stroke-width", `${lineWidth.toFixed(1)}px`); + } else { + circ.setAttribute("stroke-width", "1px"); + } + circ.setAttribute("fill", "none"); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); + this.g.appendChild(circ); + this.last_blue_move = circ; + this.last_blue_move_color = color; + this.last_blue_move_border_width = border_width; + } + public clearBlueMove() { + if (this.last_blue_move) { + this.last_blue_move.remove(); + delete this.last_blue_move; } + } + /* + * Cross + */ + private last_removal_cross?: SVGPathElement; + private last_removal_cross_fill?: string; + private last_removal_cross_radius?: number; + private last_removal_cross_opacity?: number; + + public removalCross(fill: string, radius: number, opacity: number) { if ( - this.move_tree_svg.clientWidth !== this.move_tree_container.clientWidth || - this.move_tree_svg.clientHeight !== this.move_tree_container.clientHeight + this.last_removal_cross && + this.last_removal_cross_fill === fill && + this.last_removal_cross_radius === radius && + this.last_removal_cross_opacity === opacity ) { - this.move_tree_svg.setAttribute( - "width", - this.move_tree_container.clientWidth.toString(), - ); - this.move_tree_svg.setAttribute( - "height", - this.move_tree_container.clientHeight.toString(), - ); + return; } - this.engine.move_tree.recomputeIsobranches(); - const active_path_end = this.engine.cur_move; + this.clearRemovalCross(); - this.engine.move_tree_layout_dirty = false; + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + const r = radius; - active_path_end.setActivePath(++MoveTree.active_path_number); + const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cross.setAttribute("class", "removal-cross"); + cross.setAttribute("fill", fill); - //const canvas = this.move_tree_canvas; - const svg = this.move_tree_svg; - const engine = this.engine; + /* four dagger tip points with a square in the center. Start at top left going clockwise*/ + const dx = r * 0.25; // tip width + const ir = r * 0.3; // inner radius for our box + const ir_dx = ir * 0.4; // offset to where our daggers meet the box - this.engine.move_tree_layout_vector = []; - const layout_hash = {}; - this.engine.move_tree.layout(0, 0, layout_hash, 0); - this.engine.move_tree_layout_hash = layout_hash; - let max_height = 0; - for (let i = 0; i < this.engine.move_tree_layout_vector.length; ++i) { - max_height = Math.max(this.engine.move_tree_layout_vector[i] + 1, max_height); - } + // prettier-ignore + const points = [ + /* top half */ + -r , -r , + -r + dx , -r , + -ir + ir_dx , -ir - ir_dx , + ir - ir_dx , -ir - ir_dx , + r - dx , -r , + r , -r , - const div_clientWidth = this.move_tree_container.clientWidth; - const div_clientHeight = this.move_tree_container.clientHeight; - const width = Math.max( - div_clientWidth, - this.engine.move_tree_layout_vector.length * MoveTree.stone_square_size, - ); - const height = Math.max(div_clientHeight, max_height * MoveTree.stone_square_size); + /* right half */ + r , -r + dx , + ir + ir_dx , -ir + ir_dx , + ir + ir_dx , ir - ir_dx , + r , r - dx , + r , r , - let div_scroll_top = this.move_tree_container.scrollTop; - let div_scroll_left = this.move_tree_container.scrollLeft; + /* bottom half */ + r - dx , r , + ir - ir_dx , ir + ir_dx , + -ir + ir_dx , ir + ir_dx , + -r + dx , r , + -r , r , - this.move_tree_inner_container.style.width = width + "px"; - this.move_tree_inner_container.style.height = height + "px"; + /* left half */ + -r , r - dx , + -ir - ir_dx , ir - ir_dx , + -ir - ir_dx , -ir + ir_dx , + -r , -r + dx , + //-r , -r , + ]; - if (!no_warp) { - /* make sure our active stone is visible, but don't scroll around unnecessarily */ - if ( - div_scroll_left > active_path_end.layout_cx || - div_scroll_left + div_clientWidth - 20 < active_path_end.layout_cx || - div_scroll_top > active_path_end.layout_cy || - div_scroll_top + div_clientHeight - 20 < active_path_end.layout_cy - ) { - this.move_tree_container.scrollLeft = - active_path_end.layout_cx - div_clientWidth / 2; - this.move_tree_container.scrollTop = - active_path_end.layout_cy - div_clientHeight / 2; - div_scroll_top = this.move_tree_container.scrollTop; - div_scroll_left = this.move_tree_container.scrollLeft; - } + const path = + points + .map((v, i) => { + return (i % 2 === 0 ? (i === 0 ? "M" : "L") : " ") + v; + }) + .join(" ") + " Z"; + + cross.setAttribute("stroke", "#888888"); + cross.setAttribute("stroke-width", `${ss * 0.0275}px`); + cross.setAttribute("d", path); + cross.setAttribute("stroke-opacity", opacity.toString()); + cross.setAttribute("transform", `translate(${cx}, ${cy})`); + + this.g.appendChild(cross); + + this.last_removal_cross = cross; + this.last_removal_cross_fill = fill; + this.last_removal_cross_radius = radius; + this.last_removal_cross_opacity = opacity; + } + + public clearRemovalCross() { + if (this.last_removal_cross) { + this.last_removal_cross.remove(); + delete this.last_removal_cross; + delete this.last_removal_cross_fill; + delete this.last_removal_cross_radius; + delete this.last_removal_cross_opacity; } + } - svg.style.top = div_scroll_top + "px"; - svg.style.left = div_scroll_left + "px"; + /* + * Score + */ + private last_score?: SVGRectElement; + private last_score_fill?: string; + private last_score_stroke?: string; + private last_score_opacity?: number; - const viewport = { - offset_x: div_scroll_left, - offset_y: div_scroll_top, - minx: div_scroll_left - MoveTree.stone_square_size, - miny: div_scroll_top - MoveTree.stone_square_size, - maxx: div_scroll_left + div_clientWidth + MoveTree.stone_square_size, - maxy: div_scroll_top + div_clientHeight + MoveTree.stone_square_size, - }; + public score(fill: string, stroke: string, opacity: number): void { + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; - svg.innerHTML = ""; + if ( + this.last_score && + this.last_score_fill === fill && + this.last_score_stroke === stroke && + this.last_score_opacity === opacity + ) { + return; + } - this.move_tree_hilightNode(svg, active_path_end, "#6BAADA", viewport); + this.clearScore(); - if (engine.cur_review_move && engine.cur_review_move.id !== active_path_end.id) { - this.move_tree_hilightNode(svg, engine.cur_review_move, "#6BDA6B", viewport); + const r = ss * 0.15; + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + rect.setAttribute("stroke-width", (Math.ceil(ss * 0.065) - 0.5).toFixed(1)); + + rect.setAttribute("fill", fill); + rect.setAttribute("stroke", stroke); + if (opacity < 1) { + rect.setAttribute("fill-opacity", opacity.toString()); } - this.move_tree_recursiveDrawPath(svg, this.engine.move_tree, viewport); + this.g.appendChild(rect); - this.move_tree_drawRecursive( - svg, - this.engine.move_tree, - MoveTree.active_path_number, - viewport, - ); + this.last_score = rect; + this.last_score_fill = fill; + this.last_score_stroke = stroke; + this.last_score_opacity = opacity; } - public move_tree_bindEvents(svg: SVGElement): void { - const handler = (event: TouchEvent | MouseEvent) => { - try { - if (!this.move_tree_container) { - throw new Error(`move_tree_container was not set`); - } - const ox = this.move_tree_container.scrollLeft; - const oy = this.move_tree_container.scrollTop; - const pos = getRelativeEventPosition(event, this.move_tree_container); - pos.x += ox; - pos.y += oy; - const i = Math.floor(pos.x / MoveTree.stone_square_size); - const j = Math.floor(pos.y / MoveTree.stone_square_size); - const node = this.engine.move_tree.getNodeAtLayoutPosition(i, j); + public clearScore(): void { + if (this.last_score) { + this.last_score.remove(); + delete this.last_score; + delete this.last_score_fill; + delete this.last_score_stroke; + delete this.last_score_opacity; + } + } - if (node) { - if (this.engine.cur_move.id !== node.id) { - this.engine.jumpTo(node); - this.setLabelCharacterFromMarks(); - this.updateTitleAndStonePlacement(); - this.emit("update"); - this.syncReviewMove(); - this.redraw(); - } - if (this.engine.cur_move.played_by) { - // note that getRelativeEventPosition handles various - // nasty looking things to do with Touch etc, so using it here - // gets around that kind of thing, even though in theory it - // might be nicer to sent the client absolute coords, maybe. - const rpos = getRelativeEventPosition(event, this.move_tree_container); - this.emit("played-by-click", { - player_id: this.engine.cur_move.played_by, - x: rpos.x, - y: rpos.y, - }); - } - } - } catch (e) { - console.error(e); - } - }; + /* + * Letter markings + */ + private last_letter?: SVGTextElement; + private last_letter_letter?: string; + private last_letter_color?: string; + private last_letter_font_size?: number; + private last_letter_opacity?: number; + private last_letter_room_for_subscript?: boolean; + + public letter( + letter: string, + color: string, + font_size: number, + opacity: number, + room_for_subscript: boolean, + ): void { + if ( + this.last_letter && + this.last_letter_letter === letter && + this.last_letter_color === color && + this.last_letter_font_size === font_size && + this.last_letter_opacity === opacity && + this.last_letter_room_for_subscript === room_for_subscript + ) { + return; + } - svg.addEventListener("touchstart", handler); - svg.addEventListener("mousedown", handler); + this.clearLetter(); - this.on("destroy", () => { - svg.removeEventListener("touchstart", handler); - svg.removeEventListener("mousedown", handler); - }); + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "letter"); + text.setAttribute("fill", color); + text.setAttribute("font-size", `${font_size}px`); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("x", cx.toString()); + let yy = cy; + yy += ss / 6; + if (room_for_subscript) { + yy -= ss * 0.15; + } + text.setAttribute("y", yy.toString()); + text.textContent = letter; + if (opacity < 1) { + text.setAttribute("fill-opacity", opacity.toString()); + } + this.g.appendChild(text); } - move_tree_drawStone( - svg: SVGElement, - node: MoveTree, - active_path_number: number, - viewport: MoveTreeViewPortInterface, + public clearLetter(): void { + if (this.last_letter) { + this.last_letter.remove(); + delete this.last_letter; + delete this.last_letter_letter; + delete this.last_letter_color; + delete this.last_letter_font_size; + delete this.last_letter_opacity; + delete this.last_letter_room_for_subscript; + } + } + + /* + * Subscript markings + */ + private last_subscript?: SVGTextElement; + private last_subscript_subscript?: string; + private last_subscript_color?: string; + private last_subscript_font_size?: number; + private last_subscript_opacity?: number; + private last_subscript_room_for_letter?: boolean; + private last_subscript_room_for_sub_triangle?: boolean; + + public subscript( + subscript: string, + color: string, + font_size: number, + opacity: number, + room_for_letter: boolean, + room_for_sub_triangle: boolean, ): void { - const stone_idx = node.move_number * 31; - const cx = node.layout_cx - viewport.offset_x; - const cy = node.layout_cy - viewport.offset_y; - const color = node.player; - const on_path = node.active_path_number === active_path_number; - const r = MoveTree.stone_radius; + if ( + this.last_subscript && + this.last_subscript_subscript === subscript && + this.last_subscript_color === color && + this.last_subscript_font_size === font_size && + this.last_subscript_opacity === opacity && + this.last_subscript_room_for_letter === room_for_letter && + this.last_subscript_room_for_sub_triangle === room_for_sub_triangle + ) { + return; + } - const cell = document.createElementNS("http://www.w3.org/2000/svg", "g"); - svg.appendChild(cell); - if (!on_path) { - cell.setAttribute("class", "move-tree-stone"); - cell.setAttribute("fill-opacity", "0.6"); - cell.setAttribute("stroke-opacity", "0.6"); - cell.setAttribute("opacity", "0.6"); + this.clearSubscript(); + + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "subscript"); + text.setAttribute("fill", color); + text.setAttribute("font-size", `${font_size}px`); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("x", cx.toString()); + let yy = cy; + yy -= ss / 6; + if (room_for_letter) { + yy += ss * 0.6; + } else { + yy += ss * 0.31; + } + if (room_for_sub_triangle) { + yy -= ss * 0.08; } + text.setAttribute("y", yy.toString()); + text.textContent = subscript; + if (opacity < 1) { + text.setAttribute("fill-opacity", opacity.toString()); + } + this.g.appendChild(text); - const theme_white_stones = __theme_cache.white[this.themes.white][r]; - const theme_black_stones = __theme_cache.black[this.themes.black][r]; + this.last_subscript = text; + this.last_subscript_subscript = subscript; + this.last_subscript_color = color; + this.last_subscript_font_size = font_size; + this.last_subscript_opacity = opacity; + this.last_subscript_room_for_letter = room_for_letter; + this.last_subscript_room_for_sub_triangle = room_for_sub_triangle; + } - if (!theme_white_stones || !theme_black_stones) { - throw new Error( - "Failed to load stone images for given radius" + this.theme_stone_radius, - ); + public clearSubscript(): void { + if (this.last_subscript) { + this.last_subscript.remove(); + delete this.last_subscript; + delete this.last_subscript_subscript; + delete this.last_subscript_color; + delete this.last_subscript_font_size; + delete this.last_subscript_opacity; + delete this.last_subscript_room_for_letter; + delete this.last_subscript_room_for_sub_triangle; } + } - if (color === 1) { - const stone = theme_black_stones[stone_idx % theme_black_stones.length]; - this.theme_black.placeBlackStoneSVG(cell, undefined, stone, cx, cy, r); - } else if (color === 2) { - const stone = theme_white_stones[stone_idx % theme_white_stones.length]; - this.theme_white.placeWhiteStoneSVG(cell, undefined, stone, cx, cy, r); - } else { + /* + * Symbols + */ + private last_circle_symbol?: SVGCircleElement; + private last_circle_symbol_color?: string; + private last_circle_symbol_opacity?: number; + + public circleSymbol(color: string, opacity: number): void { + if ( + this.last_circle_symbol && + this.last_circle_symbol_color === color && + this.last_circle_symbol_opacity === opacity + ) { return; } - const text_color = color === 1 ? this.theme_black_text_color : this.theme_white_text_color; - - let label = ""; - switch (callbacks.getMoveTreeNumbering ? callbacks.getMoveTreeNumbering() : "move-number") { - case "move-coordinates": - label = node.pretty_coordinates; - break; + this.clearCircleSymbol(); - case "none": - label = ""; - break; + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; - case "move-number": - default: - if (node.pretty_coordinates === "pass") { - label = String("."); - } else { - label = String(node.move_number); - } - break; + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circ.setAttribute("class", "circle"); + circ.setAttribute("fill", "none"); + circ.setAttribute("stroke", color); + circ.setAttribute("stroke-width", `${ss * 0.075}px`); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", Math.max(0.1, ss * this.renderer.circle_radius).toFixed(2)); + if (opacity < 1.0) { + circ.setAttribute("stroke-opacity", opacity.toString()); } + this.g.appendChild(circ); - if (node.label !== label) { - node.label = label; - delete node.label_metrics; - } + this.last_circle_symbol = circ; + this.last_circle_symbol_color = color; + this.last_circle_symbol_opacity = opacity; + } - const font_size = 10; - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", cx.toFixed(0)); - text.setAttribute("y", (cy + font_size / 8).toFixed(1)); - text.setAttribute("width", MoveTree.stone_square_size.toFixed(1)); - text.setAttribute("height", MoveTree.stone_square_size.toFixed(1)); - text.setAttribute("font-size", `${font_size}px`); - text.setAttribute("font-weight", "bold"); - text.setAttribute("alignment-baseline", "middle"); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("dominant-baseline", "middle"); - text.setAttribute("fill", text_color); - text.textContent = node.label; + public clearCircleSymbol(): void { + if (this.last_circle_symbol) { + this.last_circle_symbol.remove(); + delete this.last_circle_symbol; + delete this.last_circle_symbol_color; + delete this.last_circle_symbol_opacity; + } + } - cell.appendChild(text); + private last_triangle_symbol?: SVGPathElement; + private last_triangle_symbol_color?: string; + private last_triangle_symbol_opacity?: number; + private last_triangle_symbol_as_subscript?: boolean; - const ring_color = node.text - ? "#3333ff" - : node.correct_answer - ? "#33ff33" - : node.wrong_answer - ? "#ff3333" - : null; + public triangleSymbol(color: string, opacity: number, as_subscript: boolean): void { + if ( + this.last_triangle_symbol && + this.last_triangle_symbol_color === color && + this.last_triangle_symbol_opacity === opacity && + this.last_triangle_symbol_as_subscript === as_subscript + ) { + return; + } - if (ring_color) { - const ring = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - ring.setAttribute("cx", cx.toFixed(0)); - ring.setAttribute("cy", cy.toFixed(1)); - ring.setAttribute("r", r.toFixed(0)); - ring.setAttribute("fill", "none"); - ring.setAttribute("stroke", ring_color); - ring.setAttribute("stroke-width", "2"); - cell.appendChild(ring); + this.clearTriangleSymbol(); + + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + + let scale = 1.0; + let oy = 0.0; + let line_width = ss * 0.075; + if (as_subscript) { + scale = 0.5; + oy = ss * 0.3; + opacity = 1.0; + line_width *= 0.5; + } + + const triangle = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); + triangle.setAttribute("class", "triangle"); + triangle.setAttribute("fill", "none"); + triangle.setAttribute("stroke", color); + triangle.setAttribute("stroke-width", `${line_width}px`); + const r = ss * 0.3 * scale; + let theta = -(Math.PI * 2) / 4; + const points = []; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + theta += (Math.PI * 2) / 3; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + theta += (Math.PI * 2) / 3; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + theta += (Math.PI * 2) / 3; + points.push([cx + r * Math.cos(theta), cy + oy + r * Math.sin(theta)]); + triangle.setAttribute( + "points", + points.map((p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" "), + ); + if (opacity < 1.0) { + triangle.setAttribute("stroke-opacity", opacity.toString()); } + this.g.appendChild(triangle); + + this.last_triangle_symbol = triangle; + this.last_triangle_symbol_color = color; + this.last_triangle_symbol_opacity = opacity; + this.last_triangle_symbol_as_subscript = as_subscript; } - move_tree_drawRecursive( - svg: SVGElement, - node: MoveTree, - active_path_number: number, - viewport: MoveTreeViewPortInterface, - ): void { - if (node.trunk_next) { - this.move_tree_drawRecursive(svg, node.trunk_next, active_path_number, viewport); - } - for (let i = 0; i < node.branches.length; ++i) { - this.move_tree_drawRecursive(svg, node.branches[i], active_path_number, viewport); + + public clearTriangleSymbol(): void { + if (this.last_triangle_symbol) { + this.last_triangle_symbol.remove(); + delete this.last_triangle_symbol; + delete this.last_triangle_symbol_color; + delete this.last_triangle_symbol_opacity; + delete this.last_triangle_symbol_as_subscript; } + } + + /* + * Cross + */ + private last_cross_symbol?: SVGPathElement; + private last_cross_symbol_color?: string; + private last_cross_symbol_opacity?: number; + + public crossSymbol(color: string, opacity: number): void { if ( - !viewport || - (node.layout_cx >= viewport.minx && - node.layout_cx <= viewport.maxx && - node.layout_cy >= viewport.miny && - node.layout_cy <= viewport.maxy) + this.last_cross_symbol && + this.last_cross_symbol_color === color && + this.last_cross_symbol_opacity === opacity ) { - this.move_tree_drawStone(svg, node, active_path_number, viewport); + return; } - } - move_tree_hilightNode( - svg: SVGElement, - node: MoveTree, - color: string, - viewport: MoveTreeViewPortInterface, - ): void { - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - const sx = - Math.round(node.layout_cx - MoveTree.stone_square_size * 0.5) - viewport.offset_x; - const sy = - Math.round(node.layout_cy - MoveTree.stone_square_size * 0.5) - viewport.offset_y; - rect.setAttribute("x", sx.toFixed(0)); - rect.setAttribute("y", sy.toFixed(0)); - rect.setAttribute("width", MoveTree.stone_square_size.toFixed(0)); - rect.setAttribute("height", MoveTree.stone_square_size.toFixed(0)); - rect.setAttribute("fill", color); - svg.appendChild(rect); - } - move_tree_drawPath(svg: SVGElement, node: MoveTree, viewport: MoveTreeViewPortInterface): void { - if (node.parent) { - if (node.parent.layout_cx < viewport.minx && node.layout_cx < viewport.minx) { - return; - } - if (node.parent.layout_cy < viewport.miny && node.layout_cy < viewport.miny) { - return; - } - if (node.parent.layout_cx > viewport.maxx && node.layout_cx > viewport.maxx) { - return; - } - if (node.parent.layout_cy > viewport.maxy && node.layout_cy > viewport.maxy) { - return; - } + this.clearCrossSymbol(); - const curve = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const ox = viewport.offset_x; - const oy = viewport.offset_y; - curve.setAttribute( - "d", - `M ${node.parent.layout_cx - ox} ${node.parent.layout_cy - oy} Q ${ - node.layout_cx - MoveTree.stone_square_size * 0.5 - ox - } ${node.layout_cy - oy} ${node.layout_cx - ox} ${node.layout_cy - oy}`, - ); - curve.setAttribute("fill", "none"); - curve.setAttribute( - "stroke", - node.trunk ? "#000000" : MoveTree.line_colors[node.line_color], - ); - curve.setAttribute("stroke-width", "1"); - svg.appendChild(curve); + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + const r = Math.max(1, mid * 0.35); + + const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cross.setAttribute("class", "cross"); + cross.setAttribute("fill", "none"); + cross.setAttribute("stroke", color); + cross.setAttribute("stroke-width", `${ss * 0.075}px`); + if (opacity < 1.0) { + cross.setAttribute("stroke-opacity", opacity.toString()); } + cross.setAttribute( + "d", + ` + M ${cx - r} ${cy - r} + L ${cx + r} ${cy + r} + M ${cx + r} ${cy - r} + L ${cx - r} ${cy + r} + `, + ); + this.g.appendChild(cross); + + this.last_cross_symbol = cross; + this.last_cross_symbol_color = color; + this.last_cross_symbol_opacity = opacity; } - move_tree_drawIsoBranchTo( - svg: SVGElement, - from_node: MoveTree, - to_node: MoveTree, - viewport: MoveTreeViewPortInterface, - ): void { - let A: MoveTree = from_node; - let B: MoveTree = to_node; - /* don't render if it's off screen */ - if (A.layout_cx < viewport.minx && B.layout_cx < viewport.minx) { - return; + public clearCrossSymbol(): void { + if (this.last_cross_symbol) { + this.last_cross_symbol.remove(); + delete this.last_cross_symbol; + delete this.last_cross_symbol_color; + delete this.last_cross_symbol_opacity; } - if (A.layout_cy < viewport.miny && B.layout_cy < viewport.miny) { + } + + /* + * Square + */ + private last_square_symbol?: SVGRectElement; + private last_square_symbol_color?: string; + private last_square_symbol_opacity?: number; + + public squareSymbol(color: string, opacity: number): void { + if ( + this.last_square_symbol && + this.last_square_symbol_color === color && + this.last_square_symbol_opacity === opacity + ) { return; } - if (A.layout_cx > viewport.maxx && B.layout_cx > viewport.maxx) { - return; + + this.clearSquareSymbol(); + + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + const r = Math.max(1, mid * 0.4); + + const square = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + square.setAttribute("class", "square"); + square.setAttribute("fill", "none"); + square.setAttribute("stroke", color); + square.setAttribute("stroke-width", `${ss * 0.075}px`); + if (opacity < 1.0) { + square.setAttribute("stroke-opacity", opacity.toString()); } - if (A.layout_cy > viewport.maxy && B.layout_cy > viewport.maxy) { - return; + square.setAttribute("x", (cx - r).toFixed(2)); + square.setAttribute("y", (cy - r).toFixed(2)); + square.setAttribute("width", (r * 2).toFixed(2)); + square.setAttribute("height", (r * 2).toFixed(2)); + this.g.appendChild(square); + + this.last_square_symbol = square; + this.last_square_symbol_color = color; + this.last_square_symbol_opacity = opacity; + } + + public clearSquareSymbol(): void { + if (this.last_square_symbol) { + this.last_square_symbol.remove(); + delete this.last_square_symbol; + delete this.last_square_symbol_color; + delete this.last_square_symbol_opacity; } + } + /* + * Last move + */ + private last_last_move?: SVGElement; + private last_last_move_symbol?: "+" | "?" | "o"; + private last_last_move_color?: string; + private last_last_move_opacity?: number; + + public lastMove(symbol: "+" | "?" | "o", color: string, opacity: number): void { if ( - B.trunk_next === null && - B.branches.length === 0 && - (A.trunk_next !== null || A.branches.length !== 0) + this.last_last_move && + this.last_last_move_symbol === symbol && + this.last_last_move_color === color && + this.last_last_move_opacity === opacity ) { - const t = A; - A = B; - B = t; + return; } - const strong = - A.trunk_next == null && - A.branches.length === 0 && - (B.trunk_next !== null || B.branches.length !== 0); + this.clearLastMove(); + + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + + switch (symbol) { + case "+": + { + const r = Math.max(1, mid * 0.35) * 0.8; + const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cross.setAttribute("class", "last-move"); + cross.setAttribute("stroke", color); + cross.setAttribute("stroke-width", `${ss * 0.075}px`); + cross.setAttribute("fill", "none"); + cross.setAttribute( + "d", + ` + M ${cx - r} ${cy} + L ${cx + r} ${cy} + M ${cx} ${cy - r} + L ${cx} ${cy + r} + `, + ); + this.g.appendChild(cross); + this.last_last_move = cross; + } + break; - const ox = viewport.offset_x; - const oy = viewport.offset_y; + case "?": + { + const letter = "?"; + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "letter"); + text.setAttribute("fill", color); + text.setAttribute("font-size", `${ss * 0.5}px`); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("x", cx.toString()); + let yy = cy; + yy += ss / 6; + text.setAttribute("y", yy.toString()); + text.textContent = letter; + this.g.appendChild(text); + this.last_last_move = text; + } + break; - const curve = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const my = strong ? B.layout_cy : (A.layout_cy + B.layout_cy) / 2; - const mx = (A.layout_cx + B.layout_cx) / 2 + MoveTree.stone_square_size * 0.5; - curve.setAttribute( - "d", - `M ${B.layout_cx - ox} ${B.layout_cy - oy} Q ${mx - ox} ${my - oy} ${ - A.layout_cx - ox - } ${A.layout_cy - oy}`, - ); - curve.setAttribute("fill", "none"); - curve.setAttribute("stroke", MoveTree.isobranch_colors[strong ? "strong" : "weak"]); - curve.setAttribute("stroke-width", "2"); - svg.appendChild(curve); - } - move_tree_recursiveDrawPath( - svg: SVGElement, - node: MoveTree, - viewport: MoveTreeViewPortInterface, - ): void { - if (node.trunk_next) { - this.move_tree_recursiveDrawPath(svg, node.trunk_next, viewport); + case "o": + { + const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + let r = ss * this.renderer.last_move_radius; + if (ss) { + r = ss * 0.3; + } + + r = Math.max(0.1, r); + circ.setAttribute("class", "last-move"); + circ.setAttribute("fill", "none"); + circ.setAttribute("stroke", color); + circ.setAttribute("stroke-width", `${ss * 0.075}px`); + circ.setAttribute("cx", cx.toString()); + circ.setAttribute("cy", cy.toString()); + circ.setAttribute("r", r.toString()); + this.g.appendChild(circ); + this.last_last_move = circ; + } + break; } - for (let i = 0; i < node.branches.length; ++i) { - this.move_tree_recursiveDrawPath(svg, node.branches[i], viewport); + + if (opacity < 1.0) { + this.last_last_move?.setAttribute("opacity", opacity.toString()); } - if (node.isobranches) { - for (let i = 0; i < node.isobranches.length; ++i) { - this.move_tree_drawIsoBranchTo(svg, node, node.isobranches[i], viewport); - } + this.last_last_move_symbol = symbol; + this.last_last_move_color = color; + this.last_last_move_opacity = opacity; + } + + public clearLastMove(): void { + if (this.last_last_move) { + this.last_last_move.remove(); + delete this.last_last_move; } + } - this.move_tree_drawPath(svg, node, viewport); + /* + * Score estimate + */ + private last_score_estimate?: SVGRectElement; + private last_score_estimate_color?: string; + private last_score_estimate_estimate?: number; + + public scoreEstimate(color: string, estimate: number): void { + if ( + this.last_score_estimate && + this.last_score_estimate_color === color && + this.last_score_estimate_estimate === estimate + ) { + return; + } + + this.clearScoreEstimate(); + + const mid = this.renderer.metrics.mid; + const cx = mid; + const cy = mid; + const ss = this.renderer.square_size; + + const r = ss * 0.2 * Math.abs(estimate); + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", (cx - r).toFixed(1)); + rect.setAttribute("y", (cy - r).toFixed(1)); + rect.setAttribute("width", (r * 2).toFixed(1)); + rect.setAttribute("height", (r * 2).toFixed(1)); + if (color === "white") { + rect.setAttribute("fill", this.renderer.theme_black_text_color); + rect.setAttribute("stroke", "#777777"); + } + if (color === "black") { + rect.setAttribute("fill", this.renderer.theme_white_text_color); + rect.setAttribute("stroke", "#888888"); + } + rect.setAttribute("stroke-width", (Math.ceil(ss * 0.035) - 0.5).toFixed(1)); + this.g.appendChild(rect); + + this.last_score_estimate = rect; + this.last_score_estimate_color = color; + this.last_score_estimate_estimate = estimate; + } + + public clearScoreEstimate(): void { + if (this.last_score_estimate) { + this.last_score_estimate.remove(); + delete this.last_score_estimate; + delete this.last_score_estimate_color; + } } } From 47b18413bfcc74ad0e23b562b1bded7bc2de96c8 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 3 Sep 2024 13:32:56 -0600 Subject: [PATCH 2/9] Switch to using shadowDom for Gobans --- engine/package.json | 2 +- examples/main.tsx | 2 +- package.json | 2 +- src/Goban.styl | 10 ---- src/Goban/SVGRenderer.ts | 99 +++++++++++++++++++++++++++++----------- 5 files changed, 75 insertions(+), 40 deletions(-) diff --git a/engine/package.json b/engine/package.json index 9430da5f..b2055e30 100644 --- a/engine/package.json +++ b/engine/package.json @@ -1,6 +1,6 @@ { "name": "goban-engine", - "version": "8.3.31", + "version": "8.3.32", "description": "", "main": "build/goban-engine.js", "types": "build/engine/index.d.ts", diff --git a/examples/main.tsx b/examples/main.tsx index 176e0e54..db29f90c 100644 --- a/examples/main.tsx +++ b/examples/main.tsx @@ -47,7 +47,7 @@ try { Goban.setCallbacks({ getSelectedThemes: () => ({ "board": "Kaya", - //board: "Anime", + //"board": "Anime", "white": "Plain", "black": "Plain", diff --git a/package.json b/package.json index a5b13cd9..8b0d0600 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goban", - "version": "8.3.31", + "version": "8.3.32", "description": "", "main": "build/goban.js", "types": "build/src/index.d.ts", diff --git a/src/Goban.styl b/src/Goban.styl index 1a3ca2af..f67dd933 100644 --- a/src/Goban.styl +++ b/src/Goban.styl @@ -102,16 +102,6 @@ box-shadow: 0px 2px 10px 0px rgba(0, 0, 0, 0.16); } } - - /* SVG */ - svg { - text { - font-family: Verdana, Arial, sans-serif; - text-anchor: middle; - font-weight: bold; - user-select: none; - } - } } diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index ad91d4a2..84ee0471 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -126,6 +126,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { public move_tree_container?: HTMLElement; private move_tree_inner_container?: HTMLDivElement; private move_tree_svg?: SVGElement; + private move_tree_svg_defs?: SVGDefsElement; private autoplaying_puzzle_move: boolean = false; private byoyomi_label: string = ""; @@ -174,11 +175,23 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.title_div = config["title_div"]; this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + + const shadow_root = this.parent.shadowRoot ?? this.parent.attachShadow({ mode: "open" }); + //const shadow_root = this.parent.attachShadow({ mode: "closed" }); + shadow_root.appendChild(this.svg); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(`text { + font-family: Verdana, Arial, sans-serif; + text-anchor: middle; + font-weight: bold; + user-select: none; + }`); + shadow_root.adoptedStyleSheets = [sheet]; + this.svg_defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); this.svg.appendChild(this.svg_defs); this.last_move_opacity = config["last_move_opacity"] ?? 1; - this.parent.appendChild(this.svg); this.event_layer = document.createElement("div"); this.event_layer.style.position = "absolute"; this.event_layer.style.top = "0"; @@ -1208,7 +1221,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { return; } - const CELLS = false; + const CELLS = true; if (CELLS) { this.cellDraw(i, j); @@ -3897,6 +3910,37 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.emit("clear-message"); } + + protected generateSvgDefs(radius: number): SVGDefsElement { + const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const themes = this.themes; + + if (!(themes.white in __theme_cache.white)) { + __theme_cache.white[themes.white] = {}; + } + if (!(themes.black in __theme_cache.black)) { + __theme_cache.black[themes.black] = {}; + } + + this.theme_black.preRenderShadowSVG(defs, "black"); + this.theme_white.preRenderShadowSVG(defs, "white"); + + __theme_cache.white[themes.white][radius] = this.theme_white.preRenderWhiteSVG( + defs, + radius, + 23434, + () => {}, + ); + __theme_cache.black[themes.black][radius] = this.theme_black.preRenderBlackSVG( + defs, + radius, + 2081, + () => {}, + ); + + return defs; + } + protected setTheme(themes: GobanSelectedThemes, dont_redraw: boolean): void { if (this.no_display) { console.log("No display"); @@ -3932,32 +3976,13 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { try { this.svg_defs?.remove(); - this.svg_defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + this.svg_defs = this.generateSvgDefs(this.theme_stone_radius); this.svg.appendChild(this.svg_defs); - if (!(themes.white in __theme_cache.white)) { - __theme_cache.white[themes.white] = {}; - } - if (!(themes.black in __theme_cache.black)) { - __theme_cache.black[themes.black] = {}; - } - - this.theme_white.preRenderShadowSVG(this.svg_defs, "black"); - this.theme_white.preRenderShadowSVG(this.svg_defs, "white"); - - for (const radius of [this.theme_stone_radius, MoveTree.stone_radius]) { - __theme_cache.white[themes.white][radius] = this.theme_white.preRenderWhiteSVG( - this.svg_defs, - radius, - 23434, - () => {}, - ); - __theme_cache.black[themes.black][radius] = this.theme_black.preRenderBlackSVG( - this.svg_defs, - radius, - 2081, - () => {}, - ); + if (this.move_tree_svg) { + this.move_tree_svg_defs?.remove(); + this.move_tree_svg_defs = this.generateSvgDefs(MoveTree.stone_radius); + this.move_tree_svg.appendChild(this.move_tree_svg_defs); } } catch (e) { console.error(`Error pre-rendering stones.`, { @@ -4075,11 +4100,28 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.move_tree_inner_container = document.createElement("div"); //this.move_tree_canvas = allocateCanvasOrError(); this.move_tree_svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - this.move_tree_inner_container.appendChild(this.move_tree_svg); + //this.move_tree_inner_container.appendChild(this.move_tree_svg); + + //const shadow_root = this.move_tree_inner_container.attachShadow({ mode: "closed" }); + const shadow_root = + this.move_tree_inner_container.shadowRoot ?? + this.move_tree_inner_container.attachShadow({ mode: "open" }); + shadow_root.appendChild(this.move_tree_svg); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(`text { + font-family: Verdana, Arial, sans-serif; + text-anchor: middle; + font-weight: bold; + user-select: none; + }`); + shadow_root.adoptedStyleSheets = [sheet]; + + this.move_tree_svg_defs = this.generateSvgDefs(MoveTree.stone_radius); this.move_tree_container.appendChild(this.move_tree_inner_container); this.move_tree_bindEvents(this.move_tree_svg); this.move_tree_container.style.position = "relative"; this.move_tree_svg.style.position = "absolute"; + this.move_tree_svg.appendChild(this.move_tree_svg_defs); try { const observer = new ResizeObserver(() => { @@ -4196,6 +4238,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { }; svg.innerHTML = ""; + if (this.move_tree_svg_defs) { + svg.appendChild(this.move_tree_svg_defs); + } this.move_tree_hilightNode(svg, active_path_end, "#6BAADA", viewport); From b1102e8e21927b7f20688a047c8fed1eb25222cd Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 3 Sep 2024 13:43:54 -0600 Subject: [PATCH 3/9] Cache pre rendered stones --- src/Goban/themes/rendered_stones.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Goban/themes/rendered_stones.ts b/src/Goban/themes/rendered_stones.ts index 4412e74c..77e373be 100644 --- a/src/Goban/themes/rendered_stones.ts +++ b/src/Goban/themes/rendered_stones.ts @@ -299,7 +299,14 @@ export function renderShadow( clearAboveColor(shadow_ctx, sss, sss, 150, 150, 150); } +const render_cache: { [key: string]: StoneTypeArray } = {}; + function preRenderStone(radius: number, seed: number, options: RenderOptions): StoneTypeArray { + const cache_key = `${radius}-${seed}-${options.base_color}-${options.light}-${options.ambient}-${options.specular_hardness}-${options.diffuse_light_distance}-${options.specular_light_distance}`; + if (render_cache[cache_key]) { + return render_cache[cache_key]; + } + // cspell: words dcsr const dcsr = deviceCanvasScalingRatio(); radius *= dcsr; @@ -441,7 +448,8 @@ function preRenderStone(radius: number, seed: number, options: RenderOptions): S renderShadow(shadow_ctx, center, radius, sss); - return [{ stone: stone, shadow: shadow }]; + render_cache[cache_key] = [{ stone: stone, shadow: shadow }]; + return render_cache[cache_key]; } function placeRenderedStone( ctx: CanvasRenderingContext2D, From 24733af7bf05934492887820ba94c71b59e525f6 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 3 Sep 2024 14:12:37 -0600 Subject: [PATCH 4/9] Add back in support for stone shadows --- src/Goban/SVGRenderer.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index 84ee0471..210d45b5 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -118,8 +118,8 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { private grid: Array> = []; public grid_layer?: SVGGraphicsElement; private cells: Array> = []; - private shadow_grid: Array> = []; - private shadow_layer?: SVGGraphicsElement; + public shadow_grid: Array> = []; + public shadow_layer?: SVGGraphicsElement; private pen_layer?: SVGGraphicsElement; private last_move_opacity: number = 1; @@ -4597,12 +4597,13 @@ class GCell { this.g.setAttribute("transform", transform); - /* TODO: Deal with shadows * - /* - if (this.shadow_grid[j][i]) { - this.shadow_grid[j][i].setAttribute("transform", transform); + if (this.last_stone_shadow) { + this.last_stone_shadow.setAttribute("transform", transform); } - */ + } + + public get transform(): string { + return this._transform; } /* @@ -4878,6 +4879,7 @@ class GCell { private last_stone_stone?: string; private last_stone_radius?: number; private last_stone_alpha_value?: number; + private last_stone_shadow?: SVGElement; public stone( color: JGOFNumericPlayerColor, @@ -4907,8 +4909,7 @@ class GCell { color === JGOFNumericPlayerColor.BLACK ? this.renderer.theme_black.placeBlackStoneSVG( this.g, - //transparent ? undefined : this.shadow_layer, - undefined, + transparent ? undefined : this.renderer.shadow_layer, stone, cx, cy, @@ -4916,8 +4917,7 @@ class GCell { ) : this.renderer.theme_black.placeWhiteStoneSVG( this.g, - //transparent ? undefined : this.shadow_layer, - undefined, + transparent ? undefined : this.renderer.shadow_layer, stone, cx, cy, @@ -4926,7 +4926,14 @@ class GCell { if (shadow) { console.warn("Shadows not implemented"); } - //this.shadow_grid[j][i] = shadow; + if (this.last_stone_shadow) { + this.last_stone_shadow.remove(); + } + this.last_stone_shadow = shadow; + if (this.last_stone_shadow) { + this.last_stone_shadow.setAttribute("transform", this.transform); + } + if (transparent) { elt.setAttribute("opacity", stone_alpha_value.toString()); } @@ -4943,6 +4950,10 @@ class GCell { this.last_stone.remove(); delete this.last_stone; } + if (this.last_stone_shadow) { + this.last_stone_shadow.remove(); + delete this.last_stone_shadow; + } } /* From 2bc2bdb0194d2c8eaafbbfd0e99280897b6c2d8e Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 3 Sep 2024 14:58:35 -0600 Subject: [PATCH 5/9] Switch svg bind pointers to parent div (shadowRoot breaks having an internal event handler div) --- src/Goban/Goban.ts | 2 +- src/Goban/SVGRenderer.ts | 42 +++++++++++++------------------- test/unit_tests/GobanSVG.test.ts | 30 +++++++++++------------ 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/src/Goban/Goban.ts b/src/Goban/Goban.ts index 34b1a456..d6e921df 100644 --- a/src/Goban/Goban.ts +++ b/src/Goban/Goban.ts @@ -58,7 +58,7 @@ export abstract class Goban extends OGSConnectivity { protected abstract setTheme(themes: GobanSelectedThemes, dont_redraw: boolean): void; - protected parent!: HTMLElement; + public parent!: HTMLElement; private analysis_scoring_color?: "black" | "white" | string; private analysis_scoring_last_position: { i: number; j: number } = { i: NaN, j: NaN }; diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index 210d45b5..71c36908 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -103,7 +103,6 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { private svg: SVGElement; private svg_defs: SVGDefsElement; //private loaded_pre_rendered_stones: { [radius: number]: boolean } = {}; - public event_layer: HTMLDivElement; private __set_board_height: number = -1; private __set_board_width: number = -1; private ready_to_draw: boolean = false; @@ -180,26 +179,20 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { //const shadow_root = this.parent.attachShadow({ mode: "closed" }); shadow_root.appendChild(this.svg); const sheet = new CSSStyleSheet(); - sheet.replaceSync(`text { - font-family: Verdana, Arial, sans-serif; - text-anchor: middle; - font-weight: bold; - user-select: none; - }`); + if (sheet?.replaceSync) { + sheet.replaceSync(`text { + font-family: Verdana, Arial, sans-serif; + text-anchor: middle; + font-weight: bold; + user-select: none; + }`); + } shadow_root.adoptedStyleSheets = [sheet]; this.svg_defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); this.svg.appendChild(this.svg_defs); this.last_move_opacity = config["last_move_opacity"] ?? 1; - - this.event_layer = document.createElement("div"); - this.event_layer.style.position = "absolute"; - this.event_layer.style.top = "0"; - this.event_layer.style.right = "0"; - this.event_layer.style.left = "0"; - this.event_layer.style.bottom = "0"; - this.parent.appendChild(this.event_layer); - this.bindPointerBindings(this.event_layer); + this.bindPointerBindings(this.parent as any); this.move_tree_container = config.move_tree_container; @@ -4108,12 +4101,14 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.move_tree_inner_container.attachShadow({ mode: "open" }); shadow_root.appendChild(this.move_tree_svg); const sheet = new CSSStyleSheet(); - sheet.replaceSync(`text { - font-family: Verdana, Arial, sans-serif; - text-anchor: middle; - font-weight: bold; - user-select: none; - }`); + if (sheet?.replaceSync) { + sheet.replaceSync(`text { + font-family: Verdana, Arial, sans-serif; + text-anchor: middle; + font-weight: bold; + user-select: none; + }`); + } shadow_root.adoptedStyleSheets = [sheet]; this.move_tree_svg_defs = this.generateSvgDefs(MoveTree.stone_radius); @@ -4923,9 +4918,6 @@ class GCell { cy, radius, ); - if (shadow) { - console.warn("Shadows not implemented"); - } if (this.last_stone_shadow) { this.last_stone_shadow.remove(); } diff --git a/test/unit_tests/GobanSVG.test.ts b/test/unit_tests/GobanSVG.test.ts index 1fdb3785..5ba62440 100644 --- a/test/unit_tests/GobanSVG.test.ts +++ b/test/unit_tests/GobanSVG.test.ts @@ -106,7 +106,7 @@ describe("onTap", () => { test("clicking without enabling stone placement has no effect", () => { const goban = new SVGRenderer(basic3x3Config()); - const event_layer = goban.event_layer; + const event_layer = goban.parent; simulateMouseClick(event_layer, { x: 0, y: 0 }); @@ -119,7 +119,7 @@ describe("onTap", () => { test("clicking the top left intersection places a stone", () => { const goban = new SVGRenderer(basic3x3Config()); - const event_layer = goban.event_layer; + const event_layer = goban.parent; goban.enableStonePlacement(); simulateMouseClick(event_layer, { x: 0, y: 0 }); @@ -133,7 +133,7 @@ describe("onTap", () => { test("clicking the midpoint of two intersections has no effect", () => { const goban = new SVGRenderer(basic3x3Config()); - const event_layer = goban.event_layer; + const event_layer = goban.parent; goban.enableStonePlacement(); simulateMouseClick(event_layer, { x: 0.5, y: 0 }); @@ -156,7 +156,7 @@ describe("onTap", () => { mode: "analyze", }), ); - const event_layer = goban.event_layer; + const event_layer = goban.parent; const mouse_event = new MouseEvent("click", { clientX: 25, clientY: 15, @@ -183,7 +183,7 @@ describe("onTap", () => { test("Clicking with the triangle subtool places a triangle", () => { const goban = new SVGRenderer(basic3x3Config({ mode: "analyze" })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; goban.enableStonePlacement(); goban.setAnalyzeTool("label", "triangle"); @@ -199,7 +199,7 @@ describe("onTap", () => { test("Clicking submits a move in one-click-submit mode", async () => { const goban = new SVGRenderer(basic3x3Config({ one_click_submit: true })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; goban.enableStonePlacement(); simulateMouseClick(event_layer, { x: 0, y: 0 }); @@ -219,7 +219,7 @@ describe("onTap", () => { jest.useFakeTimers(); jest.setSystemTime(0); const goban = new SVGRenderer(basic3x3Config()); - const event_layer = goban.event_layer; + const event_layer = goban.parent; const log_spy = jest.spyOn(console, "info").mockImplementation(() => {}); @@ -255,7 +255,7 @@ describe("onTap", () => { jest.setSystemTime(0); const goban = new SVGRenderer(basic3x3Config({ server_socket: mock_socket })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; await socket_server.connected; @@ -288,7 +288,7 @@ describe("onTap", () => { test("Right clicking in play mode should have no effect.", () => { const goban = new SVGRenderer(basic3x3Config()); - const event_layer = goban.event_layer; + const event_layer = goban.parent; goban.enableStonePlacement(); event_layer.dispatchEvent( @@ -308,7 +308,7 @@ describe("onTap", () => { test("Clicking during stone removal sends remove stones message", async () => { const goban = new SVGRenderer(basicScorableBoardConfig({ phase: "stone removal" })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; // Just some checks that our setup is correct expect(goban.engine.isActivePlayer(123)).toBe(true); @@ -332,7 +332,7 @@ describe("onTap", () => { test("Shift-Clicking during stone removal toggles the group ", async () => { const goban = new SVGRenderer(basicScorableBoardConfig({ phase: "stone removal" })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; // Just some checks that our setup is correct expect(goban.engine.isActivePlayer(123)).toBe(true); @@ -367,7 +367,7 @@ describe("onTap", () => { jest.useFakeTimers(); jest.setSystemTime(0); const goban = new SVGRenderer(basicScorableBoardConfig({ phase: "stone removal" })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; const addCoordinatesToChatInput = jest.fn(); GobanBase.setCallbacks({ addCoordinatesToChatInput }); @@ -391,7 +391,7 @@ describe("onTap", () => { test("Clicking on stones during stone removal sends a socket message", async () => { const goban = new SVGRenderer(basicScorableBoardConfig({ phase: "stone removal" })); - const event_layer = goban.event_layer; + const event_layer = goban.parent; simulateMouseClick(event_layer, { x: 1, y: 0 }); @@ -412,7 +412,7 @@ describe("onTap", () => { test("Clicking while in scoring mode triggers score_estimate.handleClick()", () => { const goban = new SVGRenderer(basicScorableBoardConfig()); - const event_layer = goban.event_layer; + const event_layer = goban.parent; // The scoring API is a real pain to work with, mainly due to dependence // on the wasm module. Therefore, we just mock estimateScore() and @@ -453,7 +453,7 @@ describe("onTap", () => { }), ); goban.enableStonePlacement(); - const event_layer = goban.event_layer; + const event_layer = goban.parent; simulateMouseClick(event_layer, { x: 0, y: 0 }); From bebff2e03b0eba79057682b54c85f79609529c8b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Tue, 3 Sep 2024 15:06:09 -0600 Subject: [PATCH 6/9] Fix lingering analysis letters with cell renderer --- src/Goban/SVGRenderer.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index 71c36908..3262d1b6 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -51,6 +51,8 @@ const __theme_cache: { declare let ResizeObserver: any; +const USE_CELL_RENDERER = true; + export interface SVGRendererGobanConfig extends GobanConfig { board_div?: HTMLElement; title_div?: HTMLElement; @@ -201,7 +203,11 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { if (ev.shiftKey !== this.shift_key_is_down) { this.shift_key_is_down = ev.shiftKey; if (this.last_hover_square) { - this.__drawSquare(this.last_hover_square.x, this.last_hover_square.y); + if (USE_CELL_RENDERER) { + this.cellDraw(this.last_hover_square.x, this.last_hover_square.y); + } else { + this.__drawSquare(this.last_hover_square.x, this.last_hover_square.y); + } } } } catch (e) { @@ -1214,9 +1220,7 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { return; } - const CELLS = true; - - if (CELLS) { + if (USE_CELL_RENDERER) { this.cellDraw(i, j); } else { if (this.__draw_state[j][i] !== this.drawingHash(i, j)) { @@ -1920,6 +1924,10 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { //this.__draw_state[j][i] = this.drawingHash(i, j); } private __drawSquare(i: number, j: number): void { + if (USE_CELL_RENDERER) { + throw new Error(`USE_CELL_RENDERER is set, this should not be called`); + } + if (!this.drawing_enabled || this.no_display || !this.grid || !this.grid[j]) { return; } @@ -5208,6 +5216,13 @@ class GCell { text.setAttribute("fill-opacity", opacity.toString()); } this.g.appendChild(text); + + this.last_letter = text; + this.last_letter_letter = letter; + this.last_letter_color = color; + this.last_letter_font_size = font_size; + this.last_letter_opacity = opacity; + this.last_letter_room_for_subscript = room_for_subscript; } public clearLetter(): void { From 5a85cd90f9b1ab9cd09eb0c9ee9b59992d18ab0b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 4 Sep 2024 07:36:57 -0600 Subject: [PATCH 7/9] Fix svg faded line rendering after set/unset/set --- src/Goban/SVGRenderer.ts | 66 +++++++++------------------------------- 1 file changed, 14 insertions(+), 52 deletions(-) diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index 3262d1b6..fe344ead 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -4574,9 +4574,6 @@ class GCell { _g?: SVGGraphicsElement; _transform: string = ""; - private _faded_lines?: SVGPathElement; - private _faded_star_point?: SVGCircleElement; - constructor(renderer: SVGRenderer, i: number, j: number) { this.renderer = renderer; this.i = i; @@ -4612,20 +4609,14 @@ class GCell { /* * Faded intersection lines */ - private last_faded_intersection_line_parameters?: { - sx: number; - sy: number; - ex: number; - ey: number; - mx: number; - my: number; - draw_star_point: boolean; - star_radius: number; - cx: number; - cy: number; - }; + private last_faded_lines?: SVGPathElement; + private last_faded_star_point?: SVGCircleElement; public drawFadedIntersectionLines(draw_star_point: boolean, star_radius: number): void { + if (this.last_faded_lines) { + return; + } + const mid = this.renderer.metrics.mid; const ss = this.renderer.square_size; const offset = this.renderer.metrics.offset; @@ -4662,40 +4653,11 @@ class GCell { const cx = mid; const cy = mid; - if (this.last_faded_intersection_line_parameters) { - if ( - this.last_faded_intersection_line_parameters.sx === sx && - this.last_faded_intersection_line_parameters.sy === sy && - this.last_faded_intersection_line_parameters.ex === ex && - this.last_faded_intersection_line_parameters.ey === ey && - this.last_faded_intersection_line_parameters.mx === mx && - this.last_faded_intersection_line_parameters.my === my && - this.last_faded_intersection_line_parameters.draw_star_point === draw_star_point && - this.last_faded_intersection_line_parameters.star_radius === star_radius && - this.last_faded_intersection_line_parameters.cx === cx && - this.last_faded_intersection_line_parameters.cy === cy - ) { - return; - } - } - this.last_faded_intersection_line_parameters = { - sx, - sy, - ex, - ey, - mx, - my, - draw_star_point, - star_radius, - cx, - cy, - }; - this.clearFadedLines(); const g = this.g; if (draw_star_point) { const circ = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - this._faded_star_point = circ; + this.last_faded_star_point = circ; circ.setAttribute("cx", cx.toString()); circ.setAttribute("cy", cy.toString()); circ.setAttribute("r", star_radius.toString()); @@ -4704,7 +4666,7 @@ class GCell { } const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - this._faded_lines = path; + this.last_faded_lines = path; path.setAttribute("stroke", this.renderer.theme_faded_line_color); path.setAttribute("stroke-width", this.renderer.square_size < 5 ? "0.2" : "1"); path.setAttribute("fill", "none"); @@ -4719,13 +4681,13 @@ class GCell { } public clearFadedLines(): void { - if (this._faded_lines) { - this._faded_lines.remove(); - delete this._faded_lines; + if (this.last_faded_lines) { + this.last_faded_lines.remove(); + delete this.last_faded_lines; } - if (this._faded_star_point) { - this._faded_star_point.remove(); - delete this._faded_star_point; + if (this.last_faded_star_point) { + this.last_faded_star_point.remove(); + delete this.last_faded_star_point; } } From 8322050cb6316b726299d5dceda60a238591e8a1 Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 4 Sep 2024 07:56:26 -0600 Subject: [PATCH 8/9] Cleanup pointer bindings from parent div when goban is destroyed --- src/Goban/SVGRenderer.ts | 54 ++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index fe344ead..cff490a3 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -178,6 +178,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const shadow_root = this.parent.shadowRoot ?? this.parent.attachShadow({ mode: "open" }); + if (shadow_root.childNodes.length) { + throw new Error("Shadow root already has children"); + } //const shadow_root = this.parent.attachShadow({ mode: "closed" }); shadow_root.appendChild(this.svg); const sheet = new CSSStyleSheet(); @@ -190,6 +193,9 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { }`); } shadow_root.adoptedStyleSheets = [sheet]; + this.on("destroy", () => { + this.svg.remove(); + }); this.svg_defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); this.svg.appendChild(this.svg_defs); @@ -321,6 +327,11 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { div.setAttribute("data-pointers-bound", "true"); + this.on("destroy", () => { + console.log("Clearing pointer bindings"); + div.removeAttribute("data-pointers-bound"); + }); + let dragging = false; let last_click_square = this.xy2ij(0, 0); @@ -464,55 +475,72 @@ export class SVGRenderer extends Goban implements GobanSVGInterface { let mouse_disabled: any = 0; - div.addEventListener("click", (ev) => { + const onClick = (ev: MouseEvent) => { if (!mouse_disabled) { dragging = true; pointerUp(ev, false); } ev.preventDefault(); return false; - }); - div.addEventListener("dblclick", (ev) => { + }; + const onDblClick = (ev: MouseEvent) => { if (!mouse_disabled) { dragging = true; pointerUp(ev, true); } ev.preventDefault(); return false; - }); - div.addEventListener("mousedown", (ev) => { + }; + const onMouseDown = (ev: MouseEvent) => { if (!mouse_disabled) { pointerDown(ev); } ev.preventDefault(); return false; - }); - div.addEventListener("mousemove", (ev) => { + }; + const onMouseMove = (ev: MouseEvent) => { if (!mouse_disabled) { pointerMove(ev); } ev.preventDefault(); return false; - }); - div.addEventListener("mouseout", (ev) => { + }; + const onMouseOut = (ev: MouseEvent) => { if (!mouse_disabled) { pointerOut(ev); } else { ev.preventDefault(); } return false; - }); - div.addEventListener("contextmenu", (ev) => { + }; + const onContextMenu = (ev: MouseEvent) => { if (!mouse_disabled) { pointerUp(ev, false); } else { ev.preventDefault(); } return false; - }); - div.addEventListener("focus", (ev) => { + }; + const onFocus = (ev: FocusEvent) => { ev.preventDefault(); return false; + }; + + div.addEventListener("click", onClick); + div.addEventListener("dblclick", onDblClick); + div.addEventListener("mousedown", onMouseDown); + div.addEventListener("mousemove", onMouseMove); + div.addEventListener("mouseout", onMouseOut); + div.addEventListener("contextmenu", onContextMenu); + div.addEventListener("focus", onFocus); + this.on("destroy", () => { + div.removeEventListener("click", onClick); + div.removeEventListener("dblclick", onDblClick); + div.removeEventListener("mousedown", onMouseDown); + div.removeEventListener("mousemove", onMouseMove); + div.removeEventListener("mouseout", onMouseOut); + div.removeEventListener("contextmenu", onContextMenu); + div.removeEventListener("focus", onFocus); }); let lastX = 0; From ccfb17360883a269ee188ea3a1602305cec5bf6b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Wed, 4 Sep 2024 08:58:04 -0600 Subject: [PATCH 9/9] Be lazier about creating our intersection g element (don't do on omnipresent transform) --- src/Goban/SVGRenderer.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Goban/SVGRenderer.ts b/src/Goban/SVGRenderer.ts index cff490a3..972d5da4 100644 --- a/src/Goban/SVGRenderer.ts +++ b/src/Goban/SVGRenderer.ts @@ -4611,6 +4611,9 @@ class GCell { public get g(): SVGGraphicsElement { if (!this._g) { this._g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + if (this._transform) { + this.g.setAttribute("transform", this._transform); + } this.renderer.grid_layer!.appendChild(this._g); } return this._g; @@ -4623,6 +4626,11 @@ class GCell { this._transform = transform; + if (!this._g) { + // be lazy if we haven't already created the g element + return; + } + this.g.setAttribute("transform", transform); if (this.last_stone_shadow) {