trigo / trigo-web /app /src /components /mcts /MCTSBoardHeatmap.vue
k-l-lambda's picture
Update trigo-web with VS People multiplayer mode
15f353f
<template>
<div class="board-heatmap">
<div v-if="!hasData" class="no-data">
<p>No data loaded. Please upload files to begin.</p>
</div>
<div v-else class="heatmap-wrapper">
<!-- SVG Board -->
<svg
:width="svgWidth"
:height="svgHeight"
@mouseleave="onMouseLeave"
class="board-svg"
>
<!-- Grid lines -->
<g class="grid-lines">
<!-- Vertical lines -->
<line
v-for="i in shape.x + 1"
:key="'v' + i"
:x1="(i - 1) * cellSize + margin"
:y1="margin"
:x2="(i - 1) * cellSize + margin"
:y2="boardHeight + margin"
stroke="#505050"
stroke-width="1"
/>
<!-- Horizontal lines -->
<line
v-for="j in shape.y + 1"
:key="'h' + j"
:x1="margin"
:y1="(j - 1) * cellSize + margin"
:x2="boardWidth + margin"
:y2="(j - 1) * cellSize + margin"
stroke="#505050"
stroke-width="1"
/>
</g>
<!-- Heatmap cells -->
<g class="heatmap-cells">
<rect
v-for="stat in statistics"
:key="stat.actionKey"
v-show="stat.position"
:x="stat.position ? stat.position.x * cellSize + margin : 0"
:y="stat.position ? stat.position.y * cellSize + margin : 0"
:width="cellSize"
:height="cellSize"
:fill="getColor(stat)"
:opacity="0.7"
:class="{ selected: isSelected(stat.actionKey) }"
@mouseenter="onCellHover(stat)"
@click="onCellClick(stat)"
class="heatmap-cell"
/>
</g>
<!-- Stones (current game state) -->
<g class="stones">
<circle
v-for="(stone, idx) in currentStones"
:key="idx"
:cx="(stone.x + 0.5) * cellSize + margin"
:cy="(stone.y + 0.5) * cellSize + margin"
:r="cellSize * 0.35"
:fill="stone.color === 1 ? '#070707' : '#f0f0f0'"
:stroke="stone.color === 1 ? '#202020' : '#d0d0d0'"
stroke-width="1"
class="stone"
/>
</g>
<!-- Last move marker -->
<g class="last-move-marker" v-if="lastMovePosition">
<circle
:cx="(lastMovePosition.x + 0.5) * cellSize + margin"
:cy="(lastMovePosition.y + 0.5) * cellSize + margin"
:r="cellSize * 0.15"
fill="#e94560"
class="last-move-circle"
/>
</g>
<!-- Coordinate labels -->
<g class="coord-labels">
<!-- Column labels (A, B, C...) -->
<text
v-for="i in shape.x"
:key="'col-' + i"
:x="(i - 0.5) * cellSize + margin"
:y="svgHeight - margin / 3"
text-anchor="middle"
class="coord-label"
>
{{ String.fromCharCode(64 + i) }}
</text>
<!-- Row labels (1, 2, 3...) -->
<text
v-for="j in shape.y"
:key="'row-' + j"
:x="margin / 3"
:y="(j - 0.5) * cellSize + margin"
text-anchor="middle"
dominant-baseline="middle"
class="coord-label"
>
{{ j }}
</text>
</g>
</svg>
<!-- Legend -->
<div class="legend">
<div class="legend-title">{{ legendTitle }}</div>
<div class="legend-gradient">
<div
v-for="stop in legendStops"
:key="stop.value"
:style="{ backgroundColor: stop.color }"
class="legend-stop"
></div>
</div>
<div class="legend-labels">
<span class="legend-label-min">{{ minLabel }}</span>
<span class="legend-label-max">{{ maxLabel }}</span>
</div>
</div>
<!-- Tooltip -->
<div
v-if="hoveredCell"
class="tooltip"
:style="{ left: tooltipX + 'px', top: tooltipY + 'px' }"
>
<div class="tooltip-position">{{ formatPosition(hoveredCell.position) }}</div>
<div class="tooltip-stat">N: {{ hoveredCell.N }}</div>
<div class="tooltip-stat">π: {{ (hoveredCell.pi * 100).toFixed(2) }}%</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useMCTSStore } from "@/stores/mctsStore";
import type { MCTSMoveStatistic } from "@/types/mcts";
import { getColorForStatistic, generateLegendStops, getStatisticLabel } from "@/utils/mctsColorScale";
import { formatPosition } from "@/utils/mctsDataParser";
import { StepType } from "../../../../inc/trigo/game";
const mctsStore = useMCTSStore();
// Constants
const cellSize = 50;
const margin = 40;
// Computed
const hasData = computed(() => mctsStore.hasData);
const currentMoveData = computed(() => mctsStore.currentMoveData);
const statistics = computed(() => mctsStore.currentStatistics);
const shape = computed(() => {
if (!currentMoveData.value) return { x: 5, y: 5, z: 1 };
return currentMoveData.value.gameState.getShape();
});
const boardWidth = computed(() => shape.value.x * cellSize);
const boardHeight = computed(() => shape.value.y * cellSize);
const svgWidth = computed(() => boardWidth.value + margin * 2);
const svgHeight = computed(() => boardHeight.value + margin * 2);
const currentStones = computed(() => {
if (!currentMoveData.value) return [];
const board = currentMoveData.value.gameState.getBoard();
const stones: Array<{ x: number; y: number; color: number }> = [];
for (let x = 0; x < shape.value.x; x++) {
for (let y = 0; y < shape.value.y; y++) {
const color = board[x][y][0]; // z=0 for 2D boards
if (color !== 0) {
stones.push({ x, y, color });
}
}
}
return stones;
});
// Get last move position for highlighting
const lastMovePosition = computed(() => {
if (!currentMoveData.value || currentMoveData.value.moveNumber === 0) return null;
const history = currentMoveData.value.gameState.getHistory();
if (history.length === 0) return null;
const lastMove = history[history.length - 1];
// Skip pass moves or moves without position
if (lastMove.type !== StepType.DROP || !lastMove.position) return null;
// Return position for 2D boards (z=0)
return {
x: lastMove.position.x,
y: lastMove.position.y,
z: 0
};
});
// Color scale
const minValue = computed(() => {
if (statistics.value.length === 0) return 0;
const values = statistics.value.map((s) => {
return mctsStore.selectedStatistic === "N" ? s.N : s.pi;
});
return Math.min(...values);
});
const maxValue = computed(() => {
if (statistics.value.length === 0) return 1;
const values = statistics.value.map((s) => {
return mctsStore.selectedStatistic === "N" ? s.N : s.pi;
});
return Math.max(...values);
});
// Legend
const legendTitle = computed(() => getStatisticLabel(mctsStore.selectedStatistic));
const legendStops = computed(() => {
return generateLegendStops(mctsStore.selectedStatistic, minValue.value, maxValue.value, 5);
});
const minLabel = computed(() => {
if (mctsStore.selectedStatistic === "pi") {
return `${(minValue.value * 100).toFixed(1)}%`;
}
return Math.round(minValue.value).toString();
});
const maxLabel = computed(() => {
if (mctsStore.selectedStatistic === "pi") {
return `${(maxValue.value * 100).toFixed(1)}%`;
}
return Math.round(maxValue.value).toString();
});
// Hover state
const hoveredCell = ref<MCTSMoveStatistic | null>(null);
const tooltipX = ref(0);
const tooltipY = ref(0);
// Functions
function getColor(stat: MCTSMoveStatistic): string {
const value = mctsStore.selectedStatistic === "N" ? stat.N : stat.pi;
return getColorForStatistic(
value,
mctsStore.selectedStatistic,
minValue.value,
maxValue.value,
mctsStore.colorScale
);
}
function isSelected(actionKey: string): boolean {
return mctsStore.selectedActionKey === actionKey;
}
function onCellHover(stat: MCTSMoveStatistic) {
hoveredCell.value = stat;
// Tooltip position will be updated via mouse position
}
function onCellClick(stat: MCTSMoveStatistic) {
mctsStore.selectAction(stat.actionKey);
}
function onMouseLeave() {
hoveredCell.value = null;
}
// Track mouse position for tooltip
function onMouseMove(event: MouseEvent) {
tooltipX.value = event.clientX + 10;
tooltipY.value = event.clientY + 10;
}
// Set up mouse tracking
if (typeof window !== "undefined") {
window.addEventListener("mousemove", onMouseMove);
}
</script>
<style scoped lang="scss">
.board-heatmap {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.no-data {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #606060;
font-size: 14px;
}
.heatmap-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 100%;
max-height: 100%;
}
.board-svg {
border-radius: 4px;
}
.heatmap-cell {
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9 !important;
stroke: #4a90e2;
stroke-width: 2;
}
&.selected {
stroke: #e94560;
stroke-width: 3;
}
}
.stone {
pointer-events: none;
}
.last-move-circle {
pointer-events: none;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.coord-label {
fill: #808080;
font-size: 11px;
user-select: none;
}
/* ============================================================================
Legend
============================================================================ */
.legend {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
}
.legend-title {
font-size: 13px;
font-weight: 500;
color: #c0c0c0;
text-align: center;
}
.legend-gradient {
display: flex;
height: 20px;
border-radius: 3px;
overflow: hidden;
border: 1px solid #404040;
}
.legend-stop {
flex: 1;
}
.legend-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #808080;
}
/* ============================================================================
Tooltip
============================================================================ */
.tooltip {
position: fixed;
background-color: rgba(0, 0, 0, 0.9);
border: 1px solid #4a90e2;
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
color: #e0e0e0;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.tooltip-position {
font-weight: 600;
color: #4a90e2;
margin-bottom: 4px;
}
.tooltip-stat {
color: #c0c0c0;
line-height: 1.4;
}
</style>