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
+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;
}