feat: add TreeFlow horizontal flowchart, replace vertical tree in StoryGallery
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ChapterInfo, SceneNode, EndingDef, PlayerTreeNode } from '@engine/types'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import TreeNode from './TreeNode.vue'
|
||||
import TreeFlow from './TreeFlow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -267,10 +267,9 @@ const totalChaptersComplete = computed(() => {
|
||||
<div class="detail-tree">
|
||||
<div class="section-label">故事树</div>
|
||||
<div class="tree-container">
|
||||
<TreeNode
|
||||
<TreeFlow
|
||||
v-if="buildTreeForChapter(selectedChapter.id)"
|
||||
:node="buildTreeForChapter(selectedChapter.id)!"
|
||||
:depth="0"
|
||||
/>
|
||||
<div v-else class="tree-empty">暂无数据</div>
|
||||
</div>
|
||||
@@ -620,14 +619,14 @@ const totalChaptersComplete = computed(() => {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
max-height: 240px;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
298
src/components/TreeFlow.vue
Normal file
298
src/components/TreeFlow.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, nextTick } from 'vue'
|
||||
import type { PlayerTreeNode } from '@engine/types'
|
||||
import dagre from 'dagre'
|
||||
|
||||
const props = defineProps<{
|
||||
node: PlayerTreeNode | null
|
||||
}>()
|
||||
|
||||
interface FlowNode {
|
||||
id: string
|
||||
label: string
|
||||
visited: boolean
|
||||
isMystery: boolean
|
||||
locked: boolean
|
||||
lockHint?: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
interface FlowEdge {
|
||||
from: string
|
||||
to: string
|
||||
visited: boolean
|
||||
points: { x: number; y: number }[]
|
||||
}
|
||||
|
||||
const nodes = ref<FlowNode[]>([])
|
||||
const edges = ref<FlowEdge[]>([])
|
||||
const containerW = ref(800)
|
||||
const containerH = ref(400)
|
||||
|
||||
function buildFlow(root: PlayerTreeNode) {
|
||||
const dagreNodes: { id: string; parent: string | null; label: string; visited: boolean; isMystery: boolean; locked: boolean; lockHint?: string }[] = []
|
||||
const dagreEdges: { from: string; to: string; visited: boolean }[] = []
|
||||
|
||||
function walk(node: PlayerTreeNode, parentId: string | null) {
|
||||
if (node.visited) {
|
||||
dagreNodes.push({
|
||||
id: node.sceneId,
|
||||
parent: parentId,
|
||||
label: node.label,
|
||||
visited: true,
|
||||
isMystery: false,
|
||||
locked: node.locked,
|
||||
lockHint: node.lockHint,
|
||||
})
|
||||
if (parentId) {
|
||||
dagreEdges.push({ from: parentId, to: node.sceneId, visited: true })
|
||||
}
|
||||
|
||||
const unvisited: PlayerTreeNode[] = []
|
||||
for (const child of node.children) {
|
||||
if (child.visited) {
|
||||
walk(child, node.sceneId)
|
||||
} else {
|
||||
unvisited.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
if (unvisited.length > 0) {
|
||||
const mysteryId = `${node.sceneId}__mystery`
|
||||
dagreNodes.push({
|
||||
id: mysteryId,
|
||||
parent: node.sceneId,
|
||||
label: '? ?',
|
||||
visited: false,
|
||||
isMystery: true,
|
||||
locked: true,
|
||||
})
|
||||
dagreEdges.push({ from: node.sceneId, to: mysteryId, visited: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root, null)
|
||||
|
||||
if (dagreNodes.length === 0) return
|
||||
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setGraph({ rankdir: 'LR', nodesep: 20, ranksep: 60, marginx: 20, marginy: 20 })
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
|
||||
const nodeW = 120
|
||||
const nodeH = 44
|
||||
|
||||
for (const n of dagreNodes) {
|
||||
g.setNode(n.id, { width: nodeW, height: nodeH })
|
||||
}
|
||||
for (const e of dagreEdges) {
|
||||
g.setEdge(e.from, e.to)
|
||||
}
|
||||
|
||||
dagre.layout(g)
|
||||
|
||||
const resultNodes: FlowNode[] = dagreNodes.map((n) => {
|
||||
const pos = g.node(n.id)
|
||||
return {
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
visited: n.visited,
|
||||
isMystery: n.isMystery,
|
||||
locked: n.locked,
|
||||
lockHint: n.lockHint,
|
||||
x: pos.x - nodeW / 2,
|
||||
y: pos.y - nodeH / 2,
|
||||
w: nodeW,
|
||||
h: nodeH,
|
||||
}
|
||||
})
|
||||
|
||||
const resultEdges: FlowEdge[] = dagreEdges.map((e) => {
|
||||
const edge = g.edge(e.from, e.to)
|
||||
return {
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
visited: e.visited,
|
||||
points: edge.points || [],
|
||||
}
|
||||
})
|
||||
|
||||
nodes.value = resultNodes
|
||||
edges.value = resultEdges
|
||||
|
||||
const maxX = resultNodes.reduce((m, n) => Math.max(m, n.x + n.w), 0) + 40
|
||||
const maxY = resultNodes.reduce((m, n) => Math.max(m, n.y + n.h), 0) + 40
|
||||
containerW.value = Math.max(400, maxX)
|
||||
containerH.value = Math.max(100, maxY)
|
||||
}
|
||||
|
||||
function buildFromRoot() {
|
||||
if (props.node) buildFlow(props.node)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(buildFromRoot)
|
||||
})
|
||||
|
||||
function edgePath(e: FlowEdge): string {
|
||||
if (e.points.length === 0) return ''
|
||||
let d = `M ${e.points[0].x} ${e.points[0].y}`
|
||||
for (let i = 1; i < e.points.length; i++) {
|
||||
d += ` L ${e.points[i].x} ${e.points[i].y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const svgW = computed(() => containerW.value)
|
||||
const svgH = computed(() => containerH.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tree-flow">
|
||||
<div v-if="!node || nodes.length === 0" class="flow-empty">暂无故事数据</div>
|
||||
<svg
|
||||
v-else
|
||||
class="flow-svg"
|
||||
:viewBox="`0 0 ${svgW} ${svgH}`"
|
||||
:width="svgW"
|
||||
:height="svgH"
|
||||
>
|
||||
<g
|
||||
v-for="edge in edges"
|
||||
:key="edge.from + '-' + edge.to"
|
||||
class="flow-edge-group"
|
||||
>
|
||||
<path
|
||||
:d="edgePath(edge)"
|
||||
fill="none"
|
||||
:stroke="edge.visited ? '#c9a84c' : '#333'"
|
||||
:stroke-width="edge.visited ? 1.5 : 1"
|
||||
:stroke-dasharray="edge.visited ? '' : '4 3'"
|
||||
/>
|
||||
<polygon
|
||||
v-if="edge.points.length >= 2"
|
||||
:points="(() => {
|
||||
const pts = edge.points
|
||||
const last = pts[pts.length - 1]
|
||||
const prev = pts[pts.length - 2]
|
||||
const angle = Math.atan2(last.y - prev.y, last.x - prev.x)
|
||||
const s = 5
|
||||
return [
|
||||
`${last.x + Math.cos(angle) * s} ${last.y + Math.sin(angle) * s}`,
|
||||
`${last.x + Math.cos(angle + 2.5) * s} ${last.y + Math.sin(angle + 2.5) * s}`,
|
||||
`${last.x + Math.cos(angle - 2.5) * s} ${last.y + Math.sin(angle - 2.5) * s}`,
|
||||
].join(' ')
|
||||
})()"
|
||||
:fill="edge.visited ? '#c9a84c' : '#333'"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="flow-nodes" :style="{ width: svgW + 'px', height: svgH + 'px' }">
|
||||
<div
|
||||
v-for="n in nodes"
|
||||
:key="n.id"
|
||||
class="flow-node"
|
||||
:class="{ visited: n.visited, mystery: n.isMystery, locked: n.locked }"
|
||||
:style="{ left: n.x + 'px', top: n.y + 'px', width: n.w + 'px', height: n.h + 'px' }"
|
||||
:title="n.lockHint || ''"
|
||||
>
|
||||
<span class="node-icon">{{ n.visited ? '✦' : n.isMystery ? '?' : '⬜' }}</span>
|
||||
<span class="node-label">{{ n.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-flow {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.flow-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.flow-nodes {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-node {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flow-node.visited {
|
||||
background: rgba(201, 168, 76, 0.12);
|
||||
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||
}
|
||||
|
||||
.flow-node.locked {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.flow-node.mystery {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-node.visited .node-icon {
|
||||
color: #c9a84c;
|
||||
}
|
||||
|
||||
.flow-node.locked .node-icon,
|
||||
.flow-node.mystery .node-icon {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.flow-node.locked .node-label {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.flow-node.mystery .node-label {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.flow-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.flow-edge-group {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user