Spaces:
Sleeping
Sleeping
| <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)} | |
| { | |
| 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> | |