Files
wordloop/frontend/src/components/WordGraphCanvas.vue
T
John 22ed0fa95d Improve word relationship graph with filters, legend, and performance tuning.
Add status/search filters, link-type toggles, 40-node cap with weak-word priority,
distinct edge styles, and reduced physics cost for large graphs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 15:00:07 -07:00

354 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MemoryGraphLink, MemoryGraphNode } from '../api/request'
const props = defineProps<{
nodes: MemoryGraphNode[]
links: MemoryGraphLink[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const selectedId = ref<string | null>(null)
const cursorStyle = ref('default')
interface SimNode extends MemoryGraphNode {
x: number
y: number
vx: number
vy: number
pinned?: boolean
}
let simNodes: SimNode[] = []
let animId = 0
let width = 0
let height = 0
let draggingId: string | null = null
let dragOffsetX = 0
let dragOffsetY = 0
let pointerMoved = false
let frameCount = 0
const statusColor: Record<string, string> = {
new: '#94a3b8',
learning: '#4f6ef7',
mastered: '#22c55e',
weak: '#ef4444',
}
function nodeRadius(n: SimNode) {
return 6 + n.size * 0.25
}
function clampPos(x: number, y: number) {
return {
x: Math.max(24, Math.min(width - 24, x)),
y: Math.max(24, Math.min(height - 24, y)),
}
}
function pointerPos(e: PointerEvent) {
const canvas = canvasRef.value!
const rect = canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
}
}
function hitNode(x: number, y: number): SimNode | null {
for (let i = simNodes.length - 1; i >= 0; i--) {
const n = simNodes[i]
const r = nodeRadius(n) + 6
if ((x - n.x) ** 2 + (y - n.y) ** 2 <= r * r) return n
}
return null
}
function initSim() {
simNodes = props.nodes.map((n, i) => {
const angle = (i / Math.max(props.nodes.length, 1)) * Math.PI * 2
const r = Math.min(width, height) * 0.28
return {
...n,
x: width / 2 + Math.cos(angle) * r,
y: height / 2 + Math.sin(angle) * r,
vx: 0,
vy: 0,
}
})
}
function tick() {
const centerX = width / 2
const centerY = height / 2
const heavy = simNodes.length > 35
const pairStep = heavy ? 2 : 1
for (let i = 0; i < simNodes.length; i += pairStep) {
for (let j = i + 1; j < simNodes.length; j += pairStep) {
const a = simNodes[i]
const b = simNodes[j]
if (a.id === draggingId || b.id === draggingId) continue
let dx = a.x - b.x
let dy = a.y - b.y
let dist = Math.sqrt(dx * dx + dy * dy) || 1
const repulse = (12000 / (dist * dist)) * 0.016
dx /= dist
dy /= dist
a.vx += dx * repulse
a.vy += dy * repulse
b.vx -= dx * repulse
b.vy -= dy * repulse
}
}
for (const l of props.links) {
const a = simNodes.find((n) => n.id === l.source)
const b = simNodes.find((n) => n.id === l.target)
if (!a || !b) continue
if (a.id === draggingId) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
b.vx -= (dx / dist) * pull * 0.5
b.vy -= (dy / dist) * pull * 0.5
continue
}
if (b.id === draggingId) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
a.vx += (dx / dist) * pull * 0.5
a.vy += (dy / dist) * pull * 0.5
continue
}
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
a.vx += (dx / dist) * pull
a.vy += (dy / dist) * pull
b.vx -= (dx / dist) * pull
b.vy -= (dy / dist) * pull
}
for (const n of simNodes) {
if (n.id === draggingId) continue
n.vx += (centerX - n.x) * 0.0008
n.vy += (centerY - n.y) * 0.0008
n.vx *= 0.86
n.vy *= 0.86
n.x += n.vx
n.y += n.vy
const c = clampPos(n.x, n.y)
n.x = c.x
n.y = c.y
}
}
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, width, height)
for (const l of props.links) {
const a = simNodes.find((n) => n.id === l.source)
const b = simNodes.find((n) => n.id === l.target)
if (!a || !b) continue
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
if (l.kind === 'co_review') {
ctx.strokeStyle = 'rgba(79,110,247,0.45)'
ctx.lineWidth = 1.5
ctx.setLineDash([])
} else if (l.kind === 'similar') {
ctx.strokeStyle = 'rgba(245,158,11,0.4)'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
} else {
ctx.strokeStyle = 'rgba(148,163,184,0.3)'
ctx.lineWidth = 1
ctx.setLineDash([2, 3])
}
ctx.stroke()
ctx.setLineDash([])
}
for (const n of simNodes) {
const r = nodeRadius(n)
const color = statusColor[n.status] || '#64748b'
ctx.beginPath()
ctx.arc(n.x, n.y, r, 0, Math.PI * 2)
ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc'
ctx.fill()
if (n.id === selectedId.value) {
ctx.strokeStyle = '#1e293b'
ctx.lineWidth = 2
ctx.stroke()
}
ctx.fillStyle = '#1e293b'
ctx.font = '10px system-ui'
ctx.textAlign = 'center'
ctx.fillText(n.label, n.x, n.y + r + 11)
}
}
function loop() {
frameCount += 1
const heavy = simNodes.length > 35
const runPhysics = !heavy || frameCount % 2 === 0 || draggingId !== null
if (runPhysics) tick()
draw()
animId = requestAnimationFrame(loop)
}
function resize() {
const canvas = canvasRef.value
if (!canvas?.parentElement) return
width = canvas.parentElement.clientWidth
height = 360
canvas.width = width * devicePixelRatio
canvas.height = height * devicePixelRatio
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (ctx) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
}
function onPointerDown(e: PointerEvent) {
const { x, y } = pointerPos(e)
const hit = hitNode(x, y)
if (!hit) return
draggingId = hit.id
dragOffsetX = x - hit.x
dragOffsetY = y - hit.y
hit.vx = 0
hit.vy = 0
pointerMoved = false
selectedId.value = hit.id
emit('select', hit.id)
cursorStyle.value = 'grabbing'
canvasRef.value?.setPointerCapture(e.pointerId)
e.preventDefault()
}
function onPointerMove(e: PointerEvent) {
const { x, y } = pointerPos(e)
if (draggingId) {
const n = simNodes.find((node) => node.id === draggingId)
if (n) {
pointerMoved = true
const c = clampPos(x - dragOffsetX, y - dragOffsetY)
n.x = c.x
n.y = c.y
n.vx = 0
n.vy = 0
}
e.preventDefault()
return
}
cursorStyle.value = hitNode(x, y) ? 'grab' : 'default'
}
function onPointerUp(e: PointerEvent) {
if (draggingId) {
const n = simNodes.find((node) => node.id === draggingId)
if (n && !pointerMoved) {
selectedId.value = n.id
emit('select', n.id)
}
draggingId = null
canvasRef.value?.releasePointerCapture(e.pointerId)
}
pointerMoved = false
cursorStyle.value = 'default'
}
function onResize() {
resize()
if (!draggingId) initSim()
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
resize()
initSim()
loop()
window.addEventListener('resize', onResize)
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('pointerup', onPointerUp)
canvas.addEventListener('pointercancel', onPointerUp)
})
watch(
() => [props.nodes, props.links],
() => {
resize()
draggingId = null
initSim()
},
{ deep: true }
)
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
window.removeEventListener('resize', onResize)
const canvas = canvasRef.value
if (!canvas) return
canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('pointerup', onPointerUp)
canvas.removeEventListener('pointercancel', onPointerUp)
})
</script>
<template>
<div class="graph-wrap">
<canvas
ref="canvasRef"
class="graph-canvas"
:style="{ cursor: cursorStyle }"
/>
<p class="graph-hint">拖动节点调整位置画布固定· 点击节点查看单词</p>
</div>
</template>
<style scoped>
.graph-wrap {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbff;
overflow: hidden;
}
.graph-canvas {
display: block;
touch-action: none;
user-select: none;
}
.graph-hint {
font-size: 11px;
color: var(--muted);
padding: 8px 12px;
margin: 0;
border-top: 1px solid var(--border);
}
</style>