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:
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user