22ed0fa95d
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>
354 lines
8.5 KiB
Vue
354 lines
8.5 KiB
Vue
<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>
|