diff --git a/frontend/src/components/WordGraphCanvas.vue b/frontend/src/components/WordGraphCanvas.vue index 7ea205c..ae76936 100644 --- a/frontend/src/components/WordGraphCanvas.vue +++ b/frontend/src/components/WordGraphCanvas.vue @@ -31,6 +31,7 @@ let draggingId: string | null = null let dragOffsetX = 0 let dragOffsetY = 0 let pointerMoved = false +let frameCount = 0 const statusColor: Record = { 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` diff --git a/frontend/src/composables/useGraphFilter.ts b/frontend/src/composables/useGraphFilter.ts new file mode 100644 index 0000000..0d80ce2 --- /dev/null +++ b/frontend/src/composables/useGraphFilter.ts @@ -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) { + const graphStatusFilter = ref('') + const graphSearch = ref('') + const linkFilters = ref>({ + 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, + } +} diff --git a/frontend/src/pages/WordLibrary.vue b/frontend/src/pages/WordLibrary.vue index 78a13ac..a1f9c6a 100644 --- a/frontend/src/pages/WordLibrary.vue +++ b/frontend/src/pages/WordLibrary.vue @@ -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(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(() => {

单词关系图

-

类似 Obsidian 的力导向图,展示词与词之间的记忆关联

+

筛选后展示子图,减轻卡顿并突出记忆关联

+ +
+ + +
+ + + +

{{ graphStatsText }}

+ +

当前筛选无匹配单词,请调整条件

+ +
+ + + {{ meta.label }}:{{ meta.desc }} + +
@@ -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; }