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 dragOffsetX = 0
|
||||||
let dragOffsetY = 0
|
let dragOffsetY = 0
|
||||||
let pointerMoved = false
|
let pointerMoved = false
|
||||||
|
let frameCount = 0
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
new: '#94a3b8',
|
new: '#94a3b8',
|
||||||
@@ -86,8 +87,11 @@ function tick() {
|
|||||||
const centerX = width / 2
|
const centerX = width / 2
|
||||||
const centerY = height / 2
|
const centerY = height / 2
|
||||||
|
|
||||||
for (let i = 0; i < simNodes.length; i++) {
|
const heavy = simNodes.length > 35
|
||||||
for (let j = i + 1; j < simNodes.length; j++) {
|
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 a = simNodes[i]
|
||||||
const b = simNodes[j]
|
const b = simNodes[j]
|
||||||
if (a.id === draggingId || b.id === draggingId) continue
|
if (a.id === draggingId || b.id === draggingId) continue
|
||||||
@@ -165,10 +169,21 @@ function draw() {
|
|||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(a.x, a.y)
|
ctx.moveTo(a.x, a.y)
|
||||||
ctx.lineTo(b.x, b.y)
|
ctx.lineTo(b.x, b.y)
|
||||||
ctx.strokeStyle =
|
if (l.kind === 'co_review') {
|
||||||
l.kind === 'co_review' ? 'rgba(79,110,247,0.35)' : 'rgba(148,163,184,0.25)'
|
ctx.strokeStyle = 'rgba(79,110,247,0.45)'
|
||||||
ctx.lineWidth = l.kind === 'co_review' ? 1.5 : 1
|
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.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const n of simNodes) {
|
for (const n of simNodes) {
|
||||||
@@ -191,7 +206,10 @@ function draw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loop() {
|
function loop() {
|
||||||
tick()
|
frameCount += 1
|
||||||
|
const heavy = simNodes.length > 35
|
||||||
|
const runPhysics = !heavy || frameCount % 2 === 0 || draggingId !== null
|
||||||
|
if (runPhysics) tick()
|
||||||
draw()
|
draw()
|
||||||
animId = requestAnimationFrame(loop)
|
animId = requestAnimationFrame(loop)
|
||||||
}
|
}
|
||||||
@@ -200,7 +218,7 @@ function resize() {
|
|||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (!canvas?.parentElement) return
|
if (!canvas?.parentElement) return
|
||||||
width = canvas.parentElement.clientWidth
|
width = canvas.parentElement.clientWidth
|
||||||
height = 320
|
height = 360
|
||||||
canvas.width = width * devicePixelRatio
|
canvas.width = width * devicePixelRatio
|
||||||
canvas.height = height * devicePixelRatio
|
canvas.height = height * devicePixelRatio
|
||||||
canvas.style.width = `${width}px`
|
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,
|
type WordMemoryDetail,
|
||||||
} from '../api/request'
|
} from '../api/request'
|
||||||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||||||
|
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter'
|
||||||
|
|
||||||
const viewTabs = [
|
const viewTabs = [
|
||||||
{ key: 'list', label: '单词列表' },
|
{ key: 'list', label: '单词列表' },
|
||||||
@@ -45,6 +46,16 @@ const detailExpanded = ref(true)
|
|||||||
const wordMemory = ref<WordMemoryDetail | null>(null)
|
const wordMemory = ref<WordMemoryDetail | null>(null)
|
||||||
const wordMemoryLoading = ref(false)
|
const wordMemoryLoading = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
graphStatusFilter,
|
||||||
|
graphSearch,
|
||||||
|
linkFilters,
|
||||||
|
filteredGraphNodes,
|
||||||
|
filteredGraphLinks,
|
||||||
|
graphStatsText,
|
||||||
|
isNodeVisible,
|
||||||
|
} = useGraphFilter(viz)
|
||||||
|
|
||||||
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
|
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
|
||||||
if (!wordMemory.value?.curve_points.length) return []
|
if (!wordMemory.value?.curve_points.length) return []
|
||||||
return wordMemory.value.curve_points.map((p, i) => ({
|
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) => {
|
watch(selectedWord, (w) => {
|
||||||
if (w && activeView.value === 'memory') loadWordMemory(w.id)
|
if (w && activeView.value === 'memory') loadWordMemory(w.id)
|
||||||
})
|
})
|
||||||
@@ -251,12 +275,52 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="card graph-card">
|
<div class="card graph-card">
|
||||||
<h3 class="section-title">单词关系图</h3>
|
<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
|
<WordGraphCanvas
|
||||||
:nodes="viz.graph.nodes"
|
v-if="filteredGraphNodes.length"
|
||||||
:links="viz.graph.links"
|
:nodes="filteredGraphNodes"
|
||||||
|
:links="filteredGraphLinks"
|
||||||
@select="onGraphSelect"
|
@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>
|
||||||
|
|
||||||
<div v-if="selectedWord" class="card word-detail">
|
<div v-if="selectedWord" class="card word-detail">
|
||||||
@@ -361,6 +425,62 @@ onMounted(() => {
|
|||||||
.graph-card {
|
.graph-card {
|
||||||
margin-bottom: 16px;
|
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 {
|
.word-detail {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user