trigo / trigo-web /app /src /components /mcts /MCTSTreeVisualization.vue
k-l-lambda's picture
Update trigo-web with VS People multiplayer mode
15f353f
<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>