知识图谱组件
约 4012 字大约 13 分钟
2025-08-16
概述
知识图谱组件是 Evoliant 前端的核心可视化组件,基于 D3.js 和 Three.js 构建,提供交互式的知识点关系可视化功能。组件支持多种过滤、搜索、缩放等交互功能,为教育场景提供直观的知识结构展示。
技术栈
- D3.js v7.9.0: 数据驱动的文档操作,核心可视化引擎
- Three.js v0.179.1: 3D 图形渲染(可选)
- Vue 3 Composition API: 响应式组件开发
- TypeScript: 完整的类型安全
- Tailwind CSS: 样式和动画
组件架构
文件结构
components/KnowledgeGraph/
├── KnowledgeGraph.vue # 主组件
├── NodeDetailDialog.vue # 节点详情对话框
├── LinkDetailDialog.vue # 连接详情对话框
├── types.ts # 类型定义
├── config.ts # 配置常量
├── data-transformer.ts # 数据转换工具
├── data-filter.ts # 数据过滤工具
├── graph-utils.ts # 图谱工具函数
├── stats-calculator.ts # 统计计算工具
└── modules.ts # 模块导出核心架构
类型系统
核心类型定义
// types.ts
import type { Tables, Enums } from '@/types/database.types'
// 数据库类型映射
export type KnowledgePoint = Tables<'knowledge_points'>
export type KnowledgeRelation = Tables<'knowledge_relations'>
export type RelationType = Enums<'relation_type'>
export type DifficultyLevel = Enums<'difficulty_level'>
// 图谱节点接口
export interface GraphNode extends Omit<KnowledgePoint, 'content' | 'difficulty_level'> {
// 重新定义难度级别字段
difficulty_level?: DifficultyLevel | null
// 可视化扩展字段
group?: string // 节点分组
size?: number // 节点大小
color?: string // 节点颜色
connections?: number // 连接数量
// D3.js 仿真位置数据
x?: number
y?: number
fx?: number | null // 固定位置X
fy?: number | null // 固定位置Y
vx?: number // 速度X
vy?: number // 速度Y
// 解析后的内容
content?: any
}
// 图谱连接接口
export interface GraphLink extends Omit<KnowledgeRelation, 'source_point_id' | 'target_point_id'> {
// D3.js 格式的源和目标
source: string | GraphNode
target: string | GraphNode
// 可视化扩展属性
type?: string // 关系类型标签
strength?: number // 连接强度
}
// 图谱数据结构
export interface GraphData {
nodes: GraphNode[]
links: GraphLink[]
}组件属性
export interface KnowledgeGraphProps {
data: GraphData
width?: number
height?: number
enableDrag?: boolean
enableZoom?: boolean
showLabels?: boolean
nodeSize?: number
linkDistance?: number
chargeStrength?: number
colorByDifficulty?: boolean
filterByRelationType?: RelationType[]
onNodeClick?: (node: GraphNode) => void
onNodeHover?: (node: GraphNode | null) => void
onLinkClick?: (link: GraphLink) => void
}配置系统
基础配置
// config.ts
export const GRAPH_CONFIG = {
// 默认尺寸
DEFAULT_WIDTH: 800,
DEFAULT_HEIGHT: 600,
// 节点配置
NODE_SIZE: 16,
NODE_COLLIDE_RADIUS: 2,
// 连接配置
LINK_DISTANCE: 150,
LINK_STRENGTH: 0.1,
// 力学仿真配置
CHARGE_STRENGTH: -800,
ALPHA_TARGET: 0,
ALPHA_DECAY: 0.01,
// 交互配置
ENABLE_DRAG: true,
ENABLE_ZOOM: true,
ZOOM_SCALE_EXTENT: [0.1, 4] as [number, number],
// 显示配置
SHOW_LABELS: true,
COLOR_BY_DIFFICULTY: true,
// 动画配置
TRANSITION_DURATION: 750
}难度级别配置
export const DIFFICULTY_CONFIG: Record<DifficultyLevel, {
label: string
color: string
multiplier: number
}> = {
easy: {
label: '简单',
color: '#10b981', // 绿色
multiplier: 0.8
},
medium: {
label: '中等',
color: '#f59e0b', // 橙色
multiplier: 1.0
},
hard: {
label: '困难',
color: '#ef4444', // 红色
multiplier: 1.3
}
}关系类型配置
export const RELATION_TYPE_CONFIG: Record<RelationType, {
label: string
color: string
}> = {
prerequisite: {
label: '前置知识',
color: '#3b82f6' // 蓝色
},
follow_up: {
label: '后续知识',
color: '#8b5cf6' // 紫色
},
related: {
label: '相关知识',
color: '#06b6d4' // 青色
},
contains: {
label: '包含关系',
color: '#f97316' // 深橙色
}
}数据处理
数据转换器
// data-transformer.ts
import type { KnowledgePoint, KnowledgeRelation, GraphData, GraphNode, GraphLink } from './types'
export class DataTransformer {
/**
* 将数据库数据转换为图谱数据
*/
static transformToGraphData(
knowledgePoints: KnowledgePoint[],
knowledgeRelations: KnowledgeRelation[]
): GraphData {
// 转换节点
const nodes: GraphNode[] = knowledgePoints.map(point => ({
...point,
// 解析JSON内容
content: point.content ? JSON.parse(point.content) : null,
// 计算连接数量
connections: this.calculateConnections(point.id, knowledgeRelations),
// 设置节点大小(基于连接数)
size: this.calculateNodeSize(point.id, knowledgeRelations),
// 设置节点颜色(基于难度)
color: this.getDifficultyColor(point.difficulty_level)
}))
// 转换连接
const links: GraphLink[] = knowledgeRelations.map(relation => ({
...relation,
source: relation.source_point_id,
target: relation.target_point_id,
strength: this.calculateLinkStrength(relation),
type: RELATION_TYPE_CONFIG[relation.relation_type]?.label
}))
return { nodes, links }
}
/**
* 计算节点连接数量
*/
private static calculateConnections(nodeId: string, relations: KnowledgeRelation[]): number {
return relations.filter(
rel => rel.source_point_id === nodeId || rel.target_point_id === nodeId
).length
}
/**
* 计算节点大小
*/
private static calculateNodeSize(nodeId: string, relations: KnowledgeRelation[]): number {
const connections = this.calculateConnections(nodeId, relations)
const baseSize = GRAPH_CONFIG.NODE_SIZE
return baseSize + Math.log(connections + 1) * 3
}
/**
* 获取难度对应的颜色
*/
private static getDifficultyColor(difficulty: DifficultyLevel | null): string {
if (!difficulty) return '#6b7280' // 默认灰色
return DIFFICULTY_CONFIG[difficulty]?.color || '#6b7280'
}
/**
* 计算连接强度
*/
private static calculateLinkStrength(relation: KnowledgeRelation): number {
// 基于权重和关系类型计算强度
const baseStrength = GRAPH_CONFIG.LINK_STRENGTH
const weight = relation.weight || 1
// 前置知识关系更强
const typeMultiplier = relation.relation_type === 'prerequisite' ? 1.5 : 1.0
return baseStrength * weight * typeMultiplier
}
}数据过滤器
// data-filter.ts
export class DataFilter {
/**
* 过滤图谱数据
*/
static filterGraphData(
data: GraphData,
filters: {
difficulties?: DifficultyLevel[]
relationTypes?: RelationType[]
searchQuery?: string
}
): GraphData {
let filteredNodes = [...data.nodes]
let filteredLinks = [...data.links]
// 难度过滤
if (filters.difficulties && filters.difficulties.length > 0) {
filteredNodes = filteredNodes.filter(node => {
return !node.difficulty_level || filters.difficulties!.includes(node.difficulty_level)
})
}
// 搜索过滤
if (filters.searchQuery && filters.searchQuery.trim()) {
const query = filters.searchQuery.toLowerCase().trim()
filteredNodes = filteredNodes.filter(node => {
return node.name.toLowerCase().includes(query) ||
(node.description && node.description.toLowerCase().includes(query))
})
}
// 获取过滤后节点的ID集合
const nodeIds = new Set(filteredNodes.map(node => node.id))
// 过滤连接 - 只保留两端节点都存在的连接
filteredLinks = filteredLinks.filter(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id
const targetId = typeof link.target === 'string' ? link.target : link.target.id
return nodeIds.has(sourceId) && nodeIds.has(targetId)
})
// 关系类型过滤
if (filters.relationTypes && filters.relationTypes.length > 0) {
filteredLinks = filteredLinks.filter(link => {
return filters.relationTypes!.includes(link.relation_type)
})
}
return {
nodes: filteredNodes,
links: filteredLinks
}
}
/**
* 高亮相关节点
*/
static highlightRelatedNodes(data: GraphData, targetNodeId: string): {
highlighted: Set<string>
dimmed: Set<string>
} {
const highlighted = new Set<string>([targetNodeId])
const dimmed = new Set<string>()
// 找到所有相关连接
data.links.forEach(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id
const targetId = typeof link.target === 'string' ? link.target : link.target.id
if (sourceId === targetNodeId) {
highlighted.add(targetId)
} else if (targetId === targetNodeId) {
highlighted.add(sourceId)
}
})
// 其余节点设为暗淡
data.nodes.forEach(node => {
if (!highlighted.has(node.id)) {
dimmed.add(node.id)
}
})
return { highlighted, dimmed }
}
}组件实现
主组件结构
<!-- KnowledgeGraph.vue -->
<template>
<div ref="containerRef" class="knowledge-graph-container">
<!-- SVG 图谱 -->
<svg ref="svgRef" class="w-full h-full">
<defs>
<!-- 箭头标记 -->
<marker v-for="relationType in relationTypes"
:key="relationType"
:id="`arrow-${relationType}`">
<polygon points="0 0, 6 2, 0 4"
:fill="getRelationTypeColor(relationType)" />
</marker>
</defs>
<g class="links"></g>
<g class="nodes"></g>
<g class="labels"></g>
</svg>
<!-- 控制面板 -->
<div class="absolute top-4 right-4">
<div class="control-panel">
<!-- 基础控制 -->
<div class="controls">
<Button @click="resetZoom">重置视图</Button>
<Button @click="centerGraph">居中显示</Button>
</div>
<!-- 过滤控制 -->
<div class="filters">
<!-- 难度过滤 -->
<div class="filter-group">
<label>难度过滤</label>
<div class="filter-badges">
<Badge v-for="difficulty in difficultyLevels"
:key="difficulty"
:variant="activeDifficultyFilters.includes(difficulty) ? 'default' : 'outline'"
@click="toggleDifficultyFilter(difficulty)">
{{ getDifficultyLabel(difficulty) }}
</Badge>
</div>
</div>
<!-- 关系过滤 -->
<div class="filter-group">
<label>关系过滤</label>
<div class="filter-badges">
<Badge v-for="relationType in relationTypes"
:key="relationType"
:variant="activeRelationFilters.includes(relationType) ? 'default' : 'outline'"
@click="toggleRelationFilter(relationType)">
{{ getRelationTypeLabel(relationType) }}
</Badge>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="stats">
<div>节点: {{ filteredData.nodes.length }}/{{ originalData.nodes.length }}</div>
<div>连接: {{ filteredData.links.length }}/{{ originalData.links.length }}</div>
<div>平均连接: {{ averageConnections.toFixed(1) }}</div>
</div>
</div>
</div>
<!-- 搜索框 -->
<div class="absolute top-4 left-4">
<div class="relative">
<Search class="search-icon" />
<Input v-model="searchQuery"
placeholder="搜索知识点..."
@input="onSearch" />
</div>
</div>
<!-- 对话框 -->
<NodeDetailDialog v-model:open="showNodeDialog"
:node="selectedNode" />
<LinkDetailDialog v-model:open="showLinkDialog"
:link="selectedLink" />
</div>
</template>核心逻辑实现
// KnowledgeGraph.vue <script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
import type { GraphData, GraphNode, GraphLink } from './types'
import { DataTransformer, DataFilter } from './utils'
const props = defineProps<{
data: GraphData
width?: number
height?: number
}>()
// 响应式引用
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
const searchQuery = ref('')
const selectedNode = ref<GraphNode | null>(null)
const selectedLink = ref<GraphLink | null>(null)
const showNodeDialog = ref(false)
const showLinkDialog = ref(false)
// 过滤状态
const activeDifficultyFilters = ref<DifficultyLevel[]>([])
const activeRelationFilters = ref<RelationType[]>([])
// 计算属性
const dimensions = computed(() => ({
width: props.width || GRAPH_CONFIG.DEFAULT_WIDTH,
height: props.height || GRAPH_CONFIG.DEFAULT_HEIGHT
}))
const filteredData = computed(() => {
return DataFilter.filterGraphData(props.data, {
difficulties: activeDifficultyFilters.value,
relationTypes: activeRelationFilters.value,
searchQuery: searchQuery.value
})
})
const averageConnections = computed(() => {
const totalConnections = filteredData.value.links.length * 2
const nodeCount = filteredData.value.nodes.length
return nodeCount > 0 ? totalConnections / nodeCount : 0
})
// D3 相关变量
let simulation: d3.Simulation<GraphNode, GraphLink>
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined>
let g: d3.Selection<SVGGElement, unknown, null, undefined>
let zoom: d3.ZoomBehavior<SVGSVGElement, unknown>
// 初始化图谱
const initializeGraph = () => {
if (!svgRef.value) return
svg = d3.select(svgRef.value)
// 清除现有内容
svg.selectAll('*').remove()
// 创建主要分组
g = svg.append('g')
const linksGroup = g.append('g').attr('class', 'links')
const nodesGroup = g.append('g').attr('class', 'nodes')
const labelsGroup = g.append('g').attr('class', 'labels')
// 设置缩放行为
zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent(GRAPH_CONFIG.ZOOM_SCALE_EXTENT)
.on('zoom', (event) => {
g.attr('transform', event.transform)
})
svg.call(zoom)
// 初始化力仿真
simulation = d3.forceSimulation<GraphNode>(filteredData.value.nodes)
.force('link', d3.forceLink<GraphNode, GraphLink>(filteredData.value.links)
.id((d: GraphNode) => d.id)
.distance(GRAPH_CONFIG.LINK_DISTANCE)
.strength((d: GraphLink) => d.strength || GRAPH_CONFIG.LINK_STRENGTH)
)
.force('charge', d3.forceManyBody().strength(GRAPH_CONFIG.CHARGE_STRENGTH))
.force('center', d3.forceCenter(dimensions.value.width / 2, dimensions.value.height / 2))
.force('collision', d3.forceCollide().radius(GRAPH_CONFIG.NODE_COLLIDE_RADIUS))
updateGraph()
}
// 更新图谱
const updateGraph = () => {
if (!svg || !simulation) return
const { nodes, links } = filteredData.value
// 更新连接
const link = svg.select('.links')
.selectAll<SVGLineElement, GraphLink>('line')
.data(links, (d: GraphLink) => d.id)
link.exit().remove()
const linkEnter = link.enter().append('line')
.attr('stroke', (d: GraphLink) => RELATION_TYPE_CONFIG[d.relation_type]?.color || '#999')
.attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight || 1))
.attr('marker-end', (d: GraphLink) => `url(#arrow-${d.relation_type})`)
.style('cursor', 'pointer')
.on('click', onLinkClick)
// 更新节点
const node = svg.select('.nodes')
.selectAll<SVGCircleElement, GraphNode>('circle')
.data(nodes, (d: GraphNode) => d.id)
node.exit().remove()
const nodeEnter = node.enter().append('circle')
.attr('r', (d: GraphNode) => d.size || GRAPH_CONFIG.NODE_SIZE)
.attr('fill', (d: GraphNode) => d.color || '#69b3a2')
.style('cursor', 'pointer')
.on('click', onNodeClick)
.on('mouseover', onNodeHover)
.on('mouseout', onNodeLeave)
.call(d3.drag<SVGCircleElement, GraphNode>()
.on('start', dragStarted)
.on('drag', dragged)
.on('end', dragEnded)
)
// 更新标签
const label = svg.select('.labels')
.selectAll<SVGTextElement, GraphNode>('text')
.data(nodes, (d: GraphNode) => d.id)
label.exit().remove()
const labelEnter = label.enter().append('text')
.text((d: GraphNode) => d.name)
.attr('font-size', '12px')
.attr('fill', 'currentColor')
.attr('text-anchor', 'middle')
.attr('dy', '.35em')
.style('pointer-events', 'none')
// 更新仿真
simulation.nodes(nodes)
simulation.force<d3.ForceLink<GraphNode, GraphLink>>('link')!.links(links)
simulation.alpha(1).restart()
// 设置仿真tick事件
simulation.on('tick', () => {
linkEnter.merge(link)
.attr('x1', (d: GraphLink) => (d.source as GraphNode).x!)
.attr('y1', (d: GraphLink) => (d.source as GraphNode).y!)
.attr('x2', (d: GraphLink) => (d.target as GraphNode).x!)
.attr('y2', (d: GraphLink) => (d.target as GraphNode).y!)
nodeEnter.merge(node)
.attr('cx', (d: GraphNode) => d.x!)
.attr('cy', (d: GraphNode) => d.y!)
labelEnter.merge(label)
.attr('x', (d: GraphNode) => d.x!)
.attr('y', (d: GraphNode) => d.y! + (d.size || GRAPH_CONFIG.NODE_SIZE) + 15)
})
}
// 事件处理函数
const onNodeClick = (event: MouseEvent, d: GraphNode) => {
selectedNode.value = d
showNodeDialog.value = true
}
const onLinkClick = (event: MouseEvent, d: GraphLink) => {
selectedLink.value = d
showLinkDialog.value = true
}
const onNodeHover = (event: MouseEvent, d: GraphNode) => {
// 高亮相关节点
const { highlighted, dimmed } = DataFilter.highlightRelatedNodes(filteredData.value, d.id)
svg.selectAll('.nodes circle')
.style('opacity', (node: GraphNode) => {
if (highlighted.has(node.id)) return 1
if (dimmed.has(node.id)) return 0.3
return 1
})
svg.selectAll('.links line')
.style('opacity', (link: GraphLink) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id
const targetId = typeof link.target === 'string' ? link.target : link.target.id
return (sourceId === d.id || targetId === d.id) ? 1 : 0.3
})
}
const onNodeLeave = () => {
// 恢复所有节点和连接的透明度
svg.selectAll('.nodes circle').style('opacity', 1)
svg.selectAll('.links line').style('opacity', 1)
}
// 拖拽处理
const dragStarted = (event: d3.D3DragEvent<SVGCircleElement, GraphNode, GraphNode>) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
event.subject.fx = event.subject.x
event.subject.fy = event.subject.y
}
const dragged = (event: d3.D3DragEvent<SVGCircleElement, GraphNode, GraphNode>) => {
event.subject.fx = event.x
event.subject.fy = event.y
}
const dragEnded = (event: d3.D3DragEvent<SVGCircleElement, GraphNode, GraphNode>) => {
if (!event.active) simulation.alphaTarget(0)
event.subject.fx = null
event.subject.fy = null
}
// 控制函数
const resetZoom = () => {
svg.transition()
.duration(GRAPH_CONFIG.TRANSITION_DURATION)
.call(zoom.transform, d3.zoomIdentity)
}
const centerGraph = () => {
const bounds = g.node()!.getBBox()
const parent = svg.node()!.getBoundingClientRect()
const fullWidth = parent.width
const fullHeight = parent.height
const width = bounds.width
const height = bounds.height
const midX = bounds.x + width / 2
const midY = bounds.y + height / 2
if (width === 0 || height === 0) return
const scale = Math.min(fullWidth / width, fullHeight / height) * 0.9
const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY]
svg.transition()
.duration(GRAPH_CONFIG.TRANSITION_DURATION)
.call(zoom.transform, d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale))
}
const toggleDifficultyFilter = (difficulty: DifficultyLevel) => {
const index = activeDifficultyFilters.value.indexOf(difficulty)
if (index === -1) {
activeDifficultyFilters.value.push(difficulty)
} else {
activeDifficultyFilters.value.splice(index, 1)
}
}
const toggleRelationFilter = (relationType: RelationType) => {
const index = activeRelationFilters.value.indexOf(relationType)
if (index === -1) {
activeRelationFilters.value.push(relationType)
} else {
activeRelationFilters.value.splice(index, 1)
}
}
const onSearch = () => {
// 搜索逻辑由 computed 属性 filteredData 自动处理
}
// 生命周期
onMounted(() => {
initializeGraph()
})
watch(() => filteredData.value, () => {
updateGraph()
}, { deep: true })
onUnmounted(() => {
if (simulation) {
simulation.stop()
}
})工具函数
统计计算器
// stats-calculator.ts
export class StatsCalculator {
/**
* 计算图谱统计信息
*/
static calculateGraphStats(data: GraphData) {
const { nodes, links } = data
const nodeCount = nodes.length
const linkCount = links.length
const averageConnections = nodeCount > 0 ? (linkCount * 2) / nodeCount : 0
// 计算难度分布
const difficultyDistribution = nodes.reduce((acc, node) => {
const difficulty = node.difficulty_level || 'unknown'
acc[difficulty] = (acc[difficulty] || 0) + 1
return acc
}, {} as Record<string, number>)
// 计算关系类型分布
const relationTypeDistribution = links.reduce((acc, link) => {
const type = link.relation_type
acc[type] = (acc[type] || 0) + 1
return acc
}, {} as Record<string, number>)
// 计算连接度分布
const connectionCounts = nodes.map(node => node.connections || 0)
const maxConnections = Math.max(...connectionCounts)
const minConnections = Math.min(...connectionCounts)
return {
nodeCount,
linkCount,
averageConnections,
difficultyDistribution,
relationTypeDistribution,
connectionStats: {
max: maxConnections,
min: minConnections,
distribution: connectionCounts
}
}
}
/**
* 计算图谱密度
*/
static calculateGraphDensity(data: GraphData): number {
const { nodes, links } = data
const nodeCount = nodes.length
if (nodeCount < 2) return 0
const maxPossibleEdges = nodeCount * (nodeCount - 1) / 2
return links.length / maxPossibleEdges
}
/**
* 识别关键节点(高连接度)
*/
static identifyKeyNodes(data: GraphData, threshold: number = 0.8): GraphNode[] {
const connectionCounts = data.nodes.map(node => node.connections || 0)
const maxConnections = Math.max(...connectionCounts)
const keyThreshold = maxConnections * threshold
return data.nodes.filter(node => (node.connections || 0) >= keyThreshold)
}
}图谱工具函数
// graph-utils.ts
export class GraphUtils {
/**
* 寻找两节点间的最短路径
*/
static findShortestPath(data: GraphData, sourceId: string, targetId: string): GraphNode[] | null {
const adjacencyList = new Map<string, string[]>()
// 构建邻接列表
data.links.forEach(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id
const targetId = typeof link.target === 'string' ? link.target : link.target.id
if (!adjacencyList.has(sourceId)) adjacencyList.set(sourceId, [])
if (!adjacencyList.has(targetId)) adjacencyList.set(targetId, [])
adjacencyList.get(sourceId)!.push(targetId)
adjacencyList.get(targetId)!.push(sourceId)
})
// BFS 寻找最短路径
const queue: string[] = [sourceId]
const visited = new Set<string>([sourceId])
const parent = new Map<string, string>()
while (queue.length > 0) {
const current = queue.shift()!
if (current === targetId) {
// 重建路径
const path: string[] = []
let node = targetId
while (node !== sourceId) {
path.unshift(node)
node = parent.get(node)!
}
path.unshift(sourceId)
// 转换为节点对象
return path.map(id => data.nodes.find(node => node.id === id)!).filter(Boolean)
}
const neighbors = adjacencyList.get(current) || []
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.add(neighbor)
parent.set(neighbor, current)
queue.push(neighbor)
}
}
}
return null // 没有找到路径
}
/**
* 计算节点的中心性
*/
static calculateCentrality(data: GraphData): Map<string, number> {
const centrality = new Map<string, number>()
data.nodes.forEach(node => {
let score = 0
// 基于连接数的中心性
const connections = node.connections || 0
score += connections * 0.4
// 基于难度的权重
if (node.difficulty_level === 'hard') score += 0.3
else if (node.difficulty_level === 'medium') score += 0.2
else if (node.difficulty_level === 'easy') score += 0.1
// 基于关系类型的权重
const prerequisiteCount = data.links.filter(link => {
const targetId = typeof link.target === 'string' ? link.target : link.target.id
return targetId === node.id && link.relation_type === 'prerequisite'
}).length
score += prerequisiteCount * 0.3
centrality.set(node.id, score)
})
return centrality
}
/**
* 检测图谱中的社区(聚类)
*/
static detectCommunities(data: GraphData): Map<string, string> {
// 简化的社区检测算法
const communities = new Map<string, string>()
const adjacencyList = new Map<string, Set<string>>()
// 构建邻接列表
data.nodes.forEach(node => {
adjacencyList.set(node.id, new Set())
})
data.links.forEach(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id
const targetId = typeof link.target === 'string' ? link.target : link.target.id
adjacencyList.get(sourceId)?.add(targetId)
adjacencyList.get(targetId)?.add(sourceId)
})
// 使用连通分量作为社区
const visited = new Set<string>()
let communityId = 0
const dfs = (nodeId: string, community: string) => {
if (visited.has(nodeId)) return
visited.add(nodeId)
communities.set(nodeId, community)
const neighbors = adjacencyList.get(nodeId) || new Set()
neighbors.forEach(neighbor => {
if (!visited.has(neighbor)) {
dfs(neighbor, community)
}
})
}
data.nodes.forEach(node => {
if (!visited.has(node.id)) {
dfs(node.id, `community-${communityId++}`)
}
})
return communities
}
}使用示例
基础使用
<template>
<div class="h-screen">
<KnowledgeGraph
:data="graphData"
:width="800"
:height="600"
:enable-drag="true"
:enable-zoom="true"
:show-labels="true"
@node-click="handleNodeClick"
@link-click="handleLinkClick"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import KnowledgeGraph from '@/components/KnowledgeGraph/KnowledgeGraph.vue'
import type { GraphData, GraphNode, GraphLink } from '@/components/KnowledgeGraph/types'
const graphData = ref<GraphData>({ nodes: [], links: [] })
const loadGraphData = async () => {
const supabase = useSupabaseClient()
// 加载知识点
const { data: knowledgePoints } = await supabase
.from('knowledge_points')
.select('*')
.eq('is_active', true)
// 加载知识关系
const { data: knowledgeRelations } = await supabase
.from('knowledge_relations')
.select('*')
if (knowledgePoints && knowledgeRelations) {
graphData.value = DataTransformer.transformToGraphData(
knowledgePoints,
knowledgeRelations
)
}
}
const handleNodeClick = (node: GraphNode) => {
console.log('Node clicked:', node.name)
// 可以导航到知识点详情页面
// await navigateTo(`/knowledge-points/${node.id}`)
}
const handleLinkClick = (link: GraphLink) => {
console.log('Link clicked:', link.relation_type)
// 可以显示关系详情
}
onMounted(loadGraphData)
</script>高级配置
<template>
<div>
<div class="controls mb-4">
<Button @click="loadCourseKnowledge">加载课程知识图谱</Button>
<Button @click="exportGraph">导出图谱</Button>
<Button @click="analyzeGraph">分析图谱</Button>
</div>
<KnowledgeGraph
:data="filteredGraphData"
:width="containerWidth"
:height="containerHeight"
:node-size="16"
:link-distance="120"
:charge-strength="-500"
:color-by-difficulty="true"
@node-hover="handleNodeHover"
/>
<div v-if="graphStats" class="stats mt-4">
<h3>图谱统计</h3>
<p>节点数量: {{ graphStats.nodeCount }}</p>
<p>连接数量: {{ graphStats.linkCount }}</p>
<p>平均连接度: {{ graphStats.averageConnections.toFixed(2) }}</p>
<p>图谱密度: {{ graphDensity.toFixed(3) }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { StatsCalculator } from '@/components/KnowledgeGraph/stats-calculator'
const containerWidth = ref(1200)
const containerHeight = ref(800)
const graphStats = computed(() => {
if (graphData.value.nodes.length === 0) return null
return StatsCalculator.calculateGraphStats(graphData.value)
})
const graphDensity = computed(() => {
return StatsCalculator.calculateGraphDensity(graphData.value)
})
const filteredGraphData = computed(() => {
// 可以基于用户选择进行动态过滤
return graphData.value
})
const loadCourseKnowledge = async (courseId: string) => {
// 加载特定课程的知识图谱
const supabase = useSupabaseClient()
const { data } = await supabase
.from('knowledge_points')
.select(`
*,
knowledge_relations!knowledge_relations_source_point_id_fkey(*)
`)
.eq('course_id', courseId)
.eq('is_active', true)
// 转换数据...
}
const exportGraph = () => {
// 导出图谱为图片或数据
const svg = document.querySelector('.knowledge-graph-container svg')
if (svg) {
const serializer = new XMLSerializer()
const svgString = serializer.serializeToString(svg)
const blob = new Blob([svgString], { type: 'image/svg+xml' })
// 创建下载链接...
}
}
const analyzeGraph = () => {
// 分析图谱结构
const keyNodes = StatsCalculator.identifyKeyNodes(graphData.value)
console.log('关键节点:', keyNodes.map(node => node.name))
const communities = GraphUtils.detectCommunities(graphData.value)
console.log('社区检测结果:', communities)
}
const handleNodeHover = (node: GraphNode | null) => {
if (node) {
// 显示节点详情提示
console.log('Hovering node:', node.name)
}
}
</script>性能优化
1. 数据优化
// 大数据集的性能优化
const optimizeForLargeDataset = (data: GraphData): GraphData => {
// 限制节点数量
const maxNodes = 500
if (data.nodes.length > maxNodes) {
// 保留最重要的节点
const sortedNodes = data.nodes
.sort((a, b) => (b.connections || 0) - (a.connections || 0))
.slice(0, maxNodes)
const nodeIds = new Set(sortedNodes.map(node => node.id))
const filteredLinks = data.links.filter(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id
const targetId = typeof link.target === 'string' ? link.target : link.target.id
return nodeIds.has(sourceId) && nodeIds.has(targetId)
})
return { nodes: sortedNodes, links: filteredLinks }
}
return data
}2. 渲染优化
// 虚拟化渲染(仅显示视窗内的元素)
const optimizeRendering = () => {
const svg = d3.select(svgRef.value)
const transform = d3.zoomTransform(svg.node()!)
// 计算视窗范围
const viewport = {
x: -transform.x / transform.k,
y: -transform.y / transform.k,
width: dimensions.value.width / transform.k,
height: dimensions.value.height / transform.k
}
// 只渲染视窗内的节点
svg.selectAll('.nodes circle')
.style('display', (d: GraphNode) => {
return (d.x! >= viewport.x && d.x! <= viewport.x + viewport.width &&
d.y! >= viewport.y && d.y! <= viewport.y + viewport.height)
? 'block' : 'none'
})
}3. 交互优化
// 防抖的搜索
import { useDebouncedRef } from '@/composables/useDebounce'
const { value: searchQuery } = useDebouncedRef('', (query) => {
// 搜索逻辑
filterGraphBySearch(query)
}, { delay: 300 })最佳实践
1. 数据结构设计
- 使用适当的数据结构存储节点和连接
- 预计算节点的连接数和重要性指标
- 合理设计数据库索引以优化查询性能
2. 用户体验
- 提供多种过滤和搜索方式
- 实现平滑的动画过渡
- 添加加载状态和错误处理
- 支持键盘快捷键操作
3. 性能考虑
- 对于大型图谱,实现数据分页和懒加载
- 使用 Web Workers 处理复杂计算
- 实现视窗裁剪以减少渲染负担
- 缓存计算结果和布局信息
4. 可访问性
- 添加适当的 ARIA 标签
- 支持键盘导航
- 提供高对比度主题选项
- 为屏幕阅读器提供文本描述
知识图谱组件是 Evoliant 前端的重要特性,通过合理的架构设计和性能优化,可以为用户提供直观、流畅的知识结构可视化体验。
版权所有
版权归属:Evoliant
许可证:MIT