Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="tree-visualization"> | |
| <div v-if="!hasData" class="no-data"> | |
| <p>No data loaded. Please upload files to begin.</p> | |
| <p class="note">Note: Only root-level statistics available from saved data</p> | |
| </div> | |
| <div v-else class="tree-content"> | |
| <svg | |
| :width="svgWidth" | |
| :height="svgHeight" | |
| :viewBox="`0 0 ${svgWidth} ${svgHeight}`" | |
| preserveAspectRatio="xMidYMid meet" | |
| class="tree-svg" | |
| ref="svgRef" | |
| > | |
| <g :transform="`translate(${centerX}, ${rootY})`"> | |
| <!-- Root node --> | |
| <g class="root-node"> | |
| <circle | |
| :r="rootRadius" | |
| fill="#4a90e2" | |
| stroke="#357abd" | |
| stroke-width="2" | |
| class="node-circle" | |
| /> | |
| <text y="5" text-anchor="middle" class="node-label">Root</text> | |
| <text y="20" text-anchor="middle" class="node-value"> | |
| {{ totalVisits }} visits | |
| </text> | |
| </g> | |
| <!-- Links to children --> | |
| <g class="links"> | |
| <path | |
| v-for="(child, idx) in visibleChildren" | |
| :key="child.actionKey" | |
| :d="getLinkPath(idx)" | |
| :stroke="getChildColor(child)" | |
| :stroke-width="getStrokeWidth(child)" | |
| fill="none" | |
| opacity="0.6" | |
| class="tree-link" | |
| /> | |
| </g> | |
| <!-- Child nodes --> | |
| <g class="child-nodes"> | |
| <g | |
| v-for="(child, idx) in visibleChildren" | |
| :key="child.actionKey" | |
| :transform="getChildTransform(idx)" | |
| @mouseenter="onChildHover(child)" | |
| @mouseleave="onChildLeave" | |
| @click="onChildClick(child)" | |
| class="child-node" | |
| :class="{ selected: isSelected(child.actionKey) }" | |
| > | |
| <circle | |
| :r="getChildRadius(child)" | |
| :fill="getChildColor(child)" | |
| :stroke="isSelected(child.actionKey) ? '#e94560' : '#2a2a2a'" | |
| :stroke-width="isSelected(child.actionKey) ? 3 : 1" | |
| class="node-circle" | |
| /> | |
| <text y="-15" text-anchor="middle" class="node-label"> | |
| {{ formatPosition(child.position) }} | |
| </text> | |
| <text y="0" text-anchor="middle" class="node-value"> | |
| {{ child.N }} | |
| </text> | |
| <text y="12" text-anchor="middle" class="node-percent"> | |
| {{ (child.pi * 100).toFixed(1) }}% | |
| </text> | |
| </g> | |
| </g> | |
| </g> | |
| </svg> | |
| <!-- Settings --> | |
| <div class="tree-settings"> | |
| <label class="setting-label"> | |
| Show top: | |
| <input | |
| type="range" | |
| min="5" | |
| max="20" | |
| :value="topN" | |
| @input="onTopNChange" | |
| class="setting-slider" | |
| /> | |
| <span class="setting-value">{{ topN }} moves</span> | |
| </label> | |
| </div> | |
| <!-- Tooltip --> | |
| <div | |
| v-if="hoveredChild" | |
| class="tooltip" | |
| :style="{ left: tooltipX + 'px', top: tooltipY + 'px' }" | |
| > | |
| <div class="tooltip-pos">{{ formatPosition(hoveredChild.position) }}</div> | |
| <div class="tooltip-line">Visit count: {{ hoveredChild.N }}</div> | |
| <div class="tooltip-line"> | |
| Percentage: {{ ((hoveredChild.N / totalVisits) * 100).toFixed(1) }}% | |
| </div> | |
| <div class="tooltip-line">Policy π: {{ hoveredChild.pi.toFixed(3) }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed } from "vue"; | |
| import { useMCTSStore } from "@/stores/mctsStore"; | |
| import type { MCTSMoveStatistic } from "@/types/mcts"; | |
| import { formatPosition } from "@/utils/mctsDataParser"; | |
| import { getColorForStatistic } from "@/utils/mctsColorScale"; | |
| const mctsStore = useMCTSStore(); | |
| // Layout constants | |
| const rootRadius = 30; | |
| const rootY = 50; // Root node Y position | |
| const childY = 150; // Child nodes Y position | |
| const padding = 20; // Padding around content | |
| // Settings | |
| const topN = ref(10); | |
| // Computed | |
| const hasData = computed(() => mctsStore.hasData); | |
| const totalVisits = computed(() => mctsStore.totalVisits); | |
| const currentStatistics = computed(() => mctsStore.currentStatistics); | |
| const visibleChildren = computed(() => { | |
| return currentStatistics.value | |
| .filter((stat: MCTSMoveStatistic) => stat.position !== null) // Exclude pass for tree view | |
| .sort((a: MCTSMoveStatistic, b: MCTSMoveStatistic) => b.N - a.N) | |
| .slice(0, topN.value); | |
| }); | |
| // Calculate max child radius for accurate bounds | |
| const maxChildRadius = computed(() => { | |
| if (visibleChildren.value.length === 0) return 15; | |
| const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N)); | |
| const minRadius = 15; | |
| const maxRadius = 35; | |
| return maxRadius; // Use max possible radius for bounds calculation | |
| }); | |
| // Calculate horizontal spread | |
| const horizontalSpread = computed(() => { | |
| const count = visibleChildren.value.length; | |
| if (count === 0) return 100; | |
| return Math.min(count * 80, 800); | |
| }); | |
| // Calculate SVG dimensions based on content | |
| const svgWidth = computed(() => { | |
| const calculatedWidth = horizontalSpread.value + padding * 2 + maxChildRadius.value * 2; | |
| return Math.max(calculatedWidth, 300); // Minimum width of 300px | |
| }); | |
| const svgHeight = computed(() => { | |
| // Root at rootY with rootRadius, children at childY with maxChildRadius | |
| const calculatedHeight = childY + maxChildRadius.value + padding * 2; | |
| return Math.max(calculatedHeight, 250); // Minimum height of 250px | |
| }); | |
| const centerX = computed(() => svgWidth.value / 2); | |
| // Hover state | |
| const hoveredChild = ref<MCTSMoveStatistic | null>(null); | |
| const tooltipX = ref(0); | |
| const tooltipY = ref(0); | |
| // Functions | |
| function getChildTransform(index: number): string { | |
| const count = visibleChildren.value.length; | |
| const spread = horizontalSpread.value; | |
| const startX = -spread / 2; | |
| const x = startX + (spread / (count - 1 || 1)) * index; | |
| return `translate(${x}, ${childY})`; | |
| } | |
| function getLinkPath(index: number): string { | |
| const count = visibleChildren.value.length; | |
| const spread = horizontalSpread.value; | |
| const startX = -spread / 2; | |
| const x = startX + (spread / (count - 1 || 1)) * index; | |
| // Curved path from root to child | |
| return `M 0,${rootRadius} Q 0,${childY / 2} ${x},${childY - getChildRadius(visibleChildren.value[index])}`; | |
| } | |
| function getChildRadius(child: MCTSMoveStatistic): number { | |
| // Size based on visit count | |
| const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N)); | |
| const minRadius = 15; | |
| const maxRadius = 35; | |
| return minRadius + ((child.N / maxN) * (maxRadius - minRadius)); | |
| } | |
| function getChildColor(child: MCTSMoveStatistic): string { | |
| const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N)); | |
| return getColorForStatistic(child.N, "N", 0, maxN, mctsStore.colorScale); | |
| } | |
| function getStrokeWidth(child: MCTSMoveStatistic): number { | |
| // Thicker lines for more visited nodes | |
| const maxN = Math.max(...visibleChildren.value.map((c: MCTSMoveStatistic) => c.N)); | |
| return 1 + (child.N / maxN) * 4; | |
| } | |
| function isSelected(actionKey: string): boolean { | |
| return mctsStore.selectedActionKey === actionKey; | |
| } | |
| function onChildHover(child: MCTSMoveStatistic) { | |
| hoveredChild.value = child; | |
| } | |
| function onChildLeave() { | |
| hoveredChild.value = null; | |
| } | |
| function onChildClick(child: MCTSMoveStatistic) { | |
| mctsStore.selectAction(child.actionKey); | |
| } | |
| function onTopNChange(event: Event) { | |
| const target = event.target as HTMLInputElement; | |
| topN.value = parseInt(target.value, 10); | |
| mctsStore.setTopNFilter(topN.value); | |
| } | |
| // Track mouse for tooltip | |
| function onMouseMove(event: MouseEvent) { | |
| tooltipX.value = event.clientX + 10; | |
| tooltipY.value = event.clientY + 10; | |
| } | |
| if (typeof window !== "undefined") { | |
| window.addEventListener("mousemove", onMouseMove); | |
| } | |
| </script> | |
| <style scoped lang="scss"> | |
| .tree-visualization { | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: flex-start; | |
| } | |
| .no-data { | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| height: 100%; | |
| gap: 10px; | |
| p { | |
| color: #606060; | |
| font-size: 14px; | |
| margin: 0; | |
| } | |
| .note { | |
| font-size: 11px; | |
| color: #808080; | |
| font-style: italic; | |
| } | |
| } | |
| .tree-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 15px; | |
| width: 100%; | |
| align-items: center; | |
| justify-content: flex-start; | |
| } | |
| .tree-svg { | |
| display: block; | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| .node-circle { | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .child-node { | |
| cursor: pointer; | |
| &:hover .node-circle { | |
| filter: brightness(1.2); | |
| } | |
| &.selected .node-circle { | |
| filter: brightness(1.3); | |
| } | |
| } | |
| .node-label { | |
| fill: #e0e0e0; | |
| font-size: 11px; | |
| font-weight: 600; | |
| pointer-events: none; | |
| } | |
| .node-value { | |
| fill: #e0e0e0; | |
| font-size: 10px; | |
| pointer-events: none; | |
| } | |
| .node-percent { | |
| fill: #a0a0a0; | |
| font-size: 9px; | |
| pointer-events: none; | |
| } | |
| .tree-link { | |
| pointer-events: none; | |
| } | |
| /* Tree Settings */ | |
| .tree-settings { | |
| padding: 10px 15px; | |
| background-color: #1a1a1a; | |
| border-radius: 4px; | |
| display: flex; | |
| justify-content: center; | |
| } | |
| .setting-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-size: 12px; | |
| color: #c0c0c0; | |
| } | |
| .setting-slider { | |
| width: 150px; | |
| height: 4px; | |
| border-radius: 2px; | |
| background-color: #3a3a3a; | |
| outline: none; | |
| -webkit-appearance: none; | |
| &::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background-color: #4a90e2; | |
| cursor: pointer; | |
| } | |
| &::-moz-range-thumb { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background-color: #4a90e2; | |
| cursor: pointer; | |
| border: none; | |
| } | |
| } | |
| .setting-value { | |
| font-weight: 500; | |
| color: #e0e0e0; | |
| } | |
| /* Tooltip */ | |
| .tooltip { | |
| position: fixed; | |
| background-color: rgba(0, 0, 0, 0.9); | |
| border: 1px solid #4a90e2; | |
| border-radius: 4px; | |
| padding: 8px 12px; | |
| font-size: 11px; | |
| color: #e0e0e0; | |
| pointer-events: none; | |
| z-index: 1000; | |
| box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); | |
| } | |
| .tooltip-pos { | |
| font-weight: 600; | |
| color: #4a90e2; | |
| margin-bottom: 4px; | |
| } | |
| .tooltip-line { | |
| color: #c0c0c0; | |
| line-height: 1.4; | |
| } | |
| </style> | |