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>
This commit is contained in:
John
2026-06-04 15:00:07 -07:00
parent 68c5efe573
commit 22ed0fa95d
3 changed files with 254 additions and 10 deletions
+25 -7
View File
@@ -31,6 +31,7 @@ let draggingId: string | null = null
let dragOffsetX = 0
let dragOffsetY = 0
let pointerMoved = false
let frameCount = 0
const statusColor: Record<string, string> = {
new: '#94a3b8',
@@ -86,8 +87,11 @@ function tick() {
const centerX = width / 2
const centerY = height / 2
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
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
@@ -165,10 +169,21 @@ function draw() {
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle =
l.kind === 'co_review' ? 'rgba(79,110,247,0.35)' : 'rgba(148,163,184,0.25)'
ctx.lineWidth = l.kind === 'co_review' ? 1.5 : 1
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) {
@@ -191,7 +206,10 @@ function draw() {
}
function loop() {
tick()
frameCount += 1
const heavy = simNodes.length > 35
const runPhysics = !heavy || frameCount % 2 === 0 || draggingId !== null
if (runPhysics) tick()
draw()
animId = requestAnimationFrame(loop)
}
@@ -200,7 +218,7 @@ function resize() {
const canvas = canvasRef.value
if (!canvas?.parentElement) return
width = canvas.parentElement.clientWidth
height = 320
height = 360
canvas.width = width * devicePixelRatio
canvas.height = height * devicePixelRatio
canvas.style.width = `${width}px`
+106
View File
@@ -0,0 +1,106 @@
import { computed, ref, type Ref } from 'vue'
import type { MemoryGraphLink, MemoryGraphNode, MemoryVisualization } from '../api/request'
export const MAX_GRAPH_NODES = 40
export type LinkKind = 'co_review' | 'status' | 'similar'
export const LINK_KIND_META: Record<
LinkKind,
{ label: string; color: string; desc: string }
> = {
co_review: { label: '同日练习', color: '#4f6ef7', desc: '同一天训练过的词' },
status: { label: '同状态', color: '#94a3b8', desc: '学习状态相同' },
similar: { label: '词形相近', color: '#f59e0b', desc: '英文拼写相近' },
}
export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
const graphStatusFilter = ref('')
const graphSearch = ref('')
const linkFilters = ref<Record<LinkKind, boolean>>({
co_review: true,
status: true,
similar: true,
})
const matchedBeforeLimit = computed(() => {
if (!viz.value) return 0
let nodes = viz.value.graph.nodes
if (graphStatusFilter.value) {
nodes = nodes.filter((n) => n.status === graphStatusFilter.value)
}
const q = graphSearch.value.trim().toLowerCase()
if (q) {
nodes = nodes.filter(
(n) => n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q)
)
}
return nodes.length
})
const filteredGraphNodes = computed((): MemoryGraphNode[] => {
if (!viz.value) return []
let nodes = [...viz.value.graph.nodes]
if (graphStatusFilter.value) {
nodes = nodes.filter((n) => n.status === graphStatusFilter.value)
}
const q = graphSearch.value.trim().toLowerCase()
if (q) {
nodes = nodes.filter(
(n) => n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q)
)
}
if (nodes.length <= MAX_GRAPH_NODES) return nodes
return [...nodes]
.sort((a, b) => {
const rank = (s: string) =>
s === 'weak' ? 0 : s === 'learning' ? 1 : s === 'new' ? 2 : 3
const dr = rank(a.status) - rank(b.status)
if (dr !== 0) return dr
return a.mastery - b.mastery
})
.slice(0, MAX_GRAPH_NODES)
})
const graphTruncated = computed(
() => matchedBeforeLimit.value > MAX_GRAPH_NODES
)
const filteredGraphLinks = computed((): MemoryGraphLink[] => {
if (!viz.value) return []
const ids = new Set(filteredGraphNodes.value.map((n) => n.id))
return viz.value.graph.links.filter((l) => {
const kind = l.kind as LinkKind
if (!linkFilters.value[kind]) return false
return ids.has(l.source) && ids.has(l.target)
})
})
const graphStatsText = computed(() => {
const n = filteredGraphNodes.value.length
const e = filteredGraphLinks.value.length
const total = viz.value?.graph.nodes.length ?? 0
let text = `显示 ${n} 个节点、${e} 条连线(全库 ${total} 词)`
if (graphTruncated.value) {
text += ` · 已优先展示易错/薄弱词(最多 ${MAX_GRAPH_NODES} 个)`
}
return text
})
function isNodeVisible(id: string) {
return filteredGraphNodes.value.some((n) => n.id === id)
}
return {
graphStatusFilter,
graphSearch,
linkFilters,
filteredGraphNodes,
filteredGraphLinks,
graphTruncated,
graphStatsText,
isNodeVisible,
LINK_KIND_META,
}
}
+123 -3
View File
@@ -17,6 +17,7 @@ import {
type WordMemoryDetail,
} from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer'
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter'
const viewTabs = [
{ key: 'list', label: '单词列表' },
@@ -45,6 +46,16 @@ const detailExpanded = ref(true)
const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false)
const {
graphStatusFilter,
graphSearch,
linkFilters,
filteredGraphNodes,
filteredGraphLinks,
graphStatsText,
isNodeVisible,
} = useGraphFilter(viz)
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
if (!wordMemory.value?.curve_points.length) return []
return wordMemory.value.curve_points.map((p, i) => ({
@@ -142,6 +153,19 @@ function onGraphSelect(id: string) {
}
}
watch([graphStatusFilter, graphSearch, linkFilters], () => {
if (selectedWord.value && !isNodeVisible(String(selectedWord.value.id))) {
const first = filteredGraphNodes.value[0]
if (first) {
const w = viz.value?.words.find((x) => String(x.id) === first.id)
if (w) {
selectedWord.value = w
loadWordMemory(w.id)
}
}
}
}, { deep: true })
watch(selectedWord, (w) => {
if (w && activeView.value === 'memory') loadWordMemory(w.id)
})
@@ -251,12 +275,52 @@ onMounted(() => {
<div class="card graph-card">
<h3 class="section-title">单词关系图</h3>
<p class="section-desc">类似 Obsidian 的力导向图展示词与词之间的记忆关联</p>
<p class="section-desc">筛选后展示子图减轻卡顿并突出记忆关联</p>
<div class="graph-toolbar">
<input
v-model="graphSearch"
class="graph-search input"
type="search"
placeholder="搜索英文或中文"
/>
<select v-model="graphStatusFilter" class="graph-select">
<option value="">全部状态</option>
<option value="new">新词</option>
<option value="learning">学习中</option>
<option value="mastered">已掌握</option>
<option value="weak">易错词</option>
</select>
</div>
<div class="link-filters">
<label
v-for="(meta, kind) in LINK_KIND_META"
:key="kind"
class="link-filter-item"
>
<input v-model="linkFilters[kind as LinkKind]" type="checkbox" />
<span class="link-swatch" :style="{ background: meta.color }" />
{{ meta.label }}
</label>
</div>
<p class="graph-stats">{{ graphStatsText }}</p>
<WordGraphCanvas
:nodes="viz.graph.nodes"
:links="viz.graph.links"
v-if="filteredGraphNodes.length"
:nodes="filteredGraphNodes"
:links="filteredGraphLinks"
@select="onGraphSelect"
/>
<p v-else class="curve-loading">当前筛选无匹配单词请调整条件</p>
<div class="graph-legend">
<span v-for="(meta, kind) in LINK_KIND_META" :key="kind" class="legend-item">
<span class="link-swatch" :style="{ background: meta.color }" />
{{ meta.label }}{{ meta.desc }}
</span>
</div>
</div>
<div v-if="selectedWord" class="card word-detail">
@@ -361,6 +425,62 @@ onMounted(() => {
.graph-card {
margin-bottom: 16px;
}
.graph-toolbar {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.graph-search {
flex: 1;
min-width: 0;
}
.graph-select {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
background: #fff;
}
.link-filters {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-bottom: 8px;
}
.link-filter-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
}
.link-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.graph-stats {
font-size: 12px;
color: var(--muted);
margin: 0 0 10px;
}
.graph-legend {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.legend-item {
font-size: 11px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.word-detail {
margin-top: 12px;
}