文章摘要 FakeGPT
加载中...|
受L站佬友启发,用开源库写了一个 3D 动力学双链关系图谱 (双链点群)。
安装依赖:
bash
npm install 3d-force-graph1. 静态网络提取与 WebGL 渲染
- 提取双链:在静态构建期分析各文章对
/posts/YYYY/MMDD的交叉引用关系,输出references双链映射。 - 动态 WebGL 渲染:利用
3d-force-graph在客户端进行 3D 动力学粒子网络模拟。为保证服务端渲染(SSR)打包安全,该插件必须在onMounted生命周期内使用await import动态引入。
2. 极佳无障碍与高对比度交互设计
为了防止用户探索图谱时产生 3D 眩晕,系统默认关闭背景自动旋转,并提供微缩自旋/重置控制台。 为了在黑暗背景和不同主题色下展现完美质感,悬浮选中通知卡片采用了极低反光底板与高对比度配色组合:
- 卡片底色:使用了
rgba(15, 15, 26, 0.95)深度拟态毛玻璃。 - 外边框与光晕:废除了杂乱的紫色发光,统一采用亮丽的 电网青(Cyan)
rgba(0, 229, 255, 0.35)边框和微弱发光阴影。 - 字体高反差:将状态 badge 设为
#00e5ff电网青,将引导操作提示.action-hint改为醒目温暖的 暖阳金黄 (#ffe082),各元素在暗底色下拥有极其完美的清晰度和视觉无障碍可读性。
scss
/* 选中节点顶部悬浮卡片高反差调优样式 */
.toast-inner {
background: rgba(15, 15, 26, 0.95);
border: 1px solid rgba(0, 229, 255, 0.35);
box-shadow: 0 8px 32px rgba(0, 229, 255, 0.2);
.badge {
color: #00e5ff;
background: rgba(0, 229, 255, 0.12);
}
.node-name {
color: #ffffff;
}
.action-hint {
color: #ffe082;
}
}3. 3D 点群关系图谱组件完整代码 (PointGroup.vue)
点击查看 PointGroup.vue 完整代码
vue
// .vitepress/theme/views/PointGroup.vue
<template>
<div class="point-group-page s-card">
<!-- 顶栏标题区 -->
<div class="page-header">
<div class="header-text">
<h1 class="title">双链点群关系图</h1>
<p class="subtitle">
交互式 3D 动力学网络结构。绿色轨迹呈递文章双向链接(双链)流通路径,圆点联动邻点聚焦。
</p>
</div>
<!-- 移动端专用的切换标签页 -->
<div class="mobile-tabs-header">
<button
class="tab-btn"
:class="{ active: activeMobileTab === 'graph' }"
@click="activeMobileTab = 'graph'"
>
<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="6" cy="12" r="2"></circle>
<circle cx="18" cy="6" r="2"></circle>
<circle cx="18" cy="18" r="2"></circle>
<path d="m8 11 8-4"></path>
<path d="m8 13 8 4"></path>
</svg>
3D 点群图谱
</button>
<button
class="tab-btn"
:class="{ active: activeMobileTab === 'list' }"
@click="activeMobileTab = 'list'"
>
<i class="iconfont icon-toc"></i>
目录文字索引
</button>
</div>
</div>
<!-- 主体内容布局 -->
<div class="page-body">
<!-- 左侧:目录文字索引面板 (桌面端常驻,移动端在 activeMobileTab === 'list' 时显示) -->
<aside class="list-panel" :class="{ 'mobile-show': activeMobileTab === 'list', 'mobile-hidden': activeMobileTab !== 'list' }">
<div class="panel-header">
<h3>目录索引</h3>
<span class="count">共 {{ postData.length }} 篇文章</span>
</div>
<div class="tree-shell">
<ul class="tree-root">
<!-- 独立文章 (无分类) -->
<li class="tree-group" v-if="rootPosts.length > 0">
<div class="group-header no-collapse">
<i class="iconfont icon-star"></i>
<span class="group-title">独立文章</span>
</div>
<ul class="group-children">
<li v-for="post in rootPosts" :key="post.id" class="tree-item" :class="{ active: currentActiveNode === 'article:' + post.id }">
<a @click="handleArticleClick(post)" class="item-link">
{{ post.title }}
</a>
</li>
</ul>
</li>
<!-- 分类文章 -->
<li v-for="(catInfo, catName) in categoriesData" :key="catName" class="tree-group">
<div class="group-header" @click="toggleGroup(catName)">
<i class="iconfont" :class="collapsedGroups[catName] ? 'icon-right' : 'icon-down'"></i>
<i class="iconfont icon-folder"></i>
<span class="group-title">{{ catName }}</span>
<span class="group-count">{{ catInfo.articles.length }}</span>
</div>
<ul v-show="!collapsedGroups[catName]" class="group-children">
<li v-for="post in catInfo.articles" :key="post.id" class="tree-item" :class="{ active: currentActiveNode === 'article:' + post.id }">
<a @click="handleArticleClick(post)" class="item-link">
{{ post.title }}
</a>
</li>
</ul>
</li>
</ul>
</div>
</aside>
<!-- 右侧:3D 图谱渲染区 (桌面端常驻,移动端在 activeMobileTab === 'graph' 时显示) -->
<main class="canvas-panel" :class="{ 'mobile-show': activeMobileTab === 'graph', 'mobile-hidden': activeMobileTab !== 'graph' }">
<!-- 3D Canvas 容器 -->
<div ref="graphContainerRef" class="graph-container-element"></div>
<!-- 选中节点浮动卡片 (点击跳转) -->
<Transition name="slide-up">
<div
v-if="selectedNode && selectedNode.type === 'article'"
class="selected-node-toast"
@click="handleArticleJump(selectedNode)"
title="点击阅读全文"
>
<div class="toast-inner">
<span class="badge">📄 当前选中</span>
<span class="node-name">{{ selectedNode.label }}</span>
<span class="action-hint">点击进入 ➔</span>
</div>
</div>
</Transition>
<!-- 3D 动力学图谱浮动控制栏 -->
<div class="graph-controls">
<div class="btn-group">
<button class="ctrl-btn" :class="{ active: autoRotate }" @click="toggleAutoRotate" title="开/关自旋转">
<i class="iconfont icon-refresh"></i>
<span>自旋</span>
</button>
<button class="ctrl-btn" @click="resetCamera" title="重置相机视角">
<i class="iconfont icon-home"></i>
<span>重置</span>
</button>
</div>
<!-- 3D 点色说明 -->
<div class="legends">
<div class="legend-item"><span class="dot root-dot"></span><span>核心</span></div>
<div class="legend-item"><span class="dot cat-dot"></span><span>分类</span></div>
<div class="legend-item"><span class="dot art-dot"></span><span>文章</span></div>
<div class="legend-item"><span class="dot ref-dot"></span><span>双链连线</span></div>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup>
const router = useRouter();
const { theme } = useData();
// 切换标签页 (仅在移动端起效)
const activeMobileTab = ref("graph");
// 获取文章和分类的依赖数据
const postData = computed(() => theme.value.postData || []);
const categoriesData = computed(() => theme.value.categoriesData || {});
// 没有分类的文章
const rootPosts = computed(() => {
return postData.value.filter(post => !post.categories || post.categories.length === 0);
});
// 折叠控制
const collapsedGroups = ref({});
const toggleGroup = (catName) => {
collapsedGroups.value[catName] = !collapsedGroups.value[catName];
};
// 3D 动力学属性
const graphContainerRef = ref(null);
const autoRotate = ref(false); // 默认关闭自旋转以支持更好的手动探索
const currentActiveNode = ref(null);
const selectedNode = ref(null); // 当前选中节点
let graphInstance = null;
let rotationTimer = null;
// 配色配置 (融合了 HSL Premium 暗色主题的深邃视觉)
const COLORS = {
root: "#ff4d6d",
category: "#00d2ff",
article: "#9c27b0",
structure: "rgba(128, 128, 128, 0.45)",
reference: "#39ff14" // 荧光绿双向引用
};
// 格式化 3D 节点和连线数据
const prepareGraphData = () => {
const nodes = [];
const links = [];
// 1. 核心 root
nodes.push({ id: "root", label: "点群主节点", type: "root", color: COLORS.root, size: 8 });
const pathToId = {};
// 2. 文件夹/分类节点
Object.keys(categoriesData.value).forEach((catName) => {
const catId = `category:${catName}`;
nodes.push({ id: catId, label: catName, type: "category", color: COLORS.category, size: 5.5 });
links.push({ source: "root", target: catId, kind: "structure" });
});
// 3. 文章节点
postData.value.forEach((post) => {
const articleId = `article:${post.id}`;
pathToId[post.regularPath] = articleId;
nodes.push({
id: articleId,
label: post.title,
type: "article",
color: COLORS.article,
size: 4,
route: post.regularPath
});
if (post.categories && post.categories.length > 0) {
post.categories.forEach((catName) => {
links.push({ source: `category:${catName}`, target: articleId, kind: "structure" });
});
} else {
links.push({ source: "root", target: articleId, kind: "structure" });
}
});
// 4. 双链引用线
postData.value.forEach((post) => {
const sourceId = `article:${post.id}`;
if (post.references && post.references.length > 0) {
post.references.forEach((refPath) => {
const targetId = pathToId[refPath];
if (targetId && targetId !== sourceId) {
links.push({ source: sourceId, target: targetId, kind: "reference" });
}
});
}
});
return { nodes, links };
};
// 初始化 3D 渲染图谱
const init3DGraph = async () => {
if (typeof window === "undefined" || !graphContainerRef.value) return;
// 避免重复初始化
if (graphInstance) return;
try {
const { default: ForceGraph3D } = await import("3d-force-graph");
const graphData = prepareGraphData();
graphInstance = ForceGraph3D()(graphContainerRef.value)
.graphData(graphData)
.backgroundColor("rgba(0,0,0,0)") // 透明底层,融合卡片磨砂效果
.showNavInfo(false)
.cooldownTicks(80)
// 节点大小及颜色
.nodeRelSize(1)
.nodeVal(node => node.size)
.nodeColor(node => node.color)
.nodeLabel(node => {
const icon = node.type === 'root' ? '💡 ' : node.type === 'category' ? '📂 ' : '📄 ';
return `<div class="graph-tooltip"><strong>${icon}${node.label}</strong></div>`;
})
// 连线配置
.linkWidth(link => link.kind === "reference" ? 1.8 : 1.2)
.linkColor(link => link.kind === "reference" ? COLORS.reference : COLORS.structure)
// 双链连线发光流动粒子效果
.linkDirectionalParticles(link => link.kind === "reference" ? 3 : 0)
.linkDirectionalParticleSpeed(0.01)
.linkDirectionalParticleWidth(1.6)
.linkDirectionalParticleColor(() => COLORS.reference)
// 节点交互与跳转逻辑
.onNodeClick((node) => {
if (node.type === 'article') {
if (selectedNode.value && selectedNode.value.id === node.id) {
handleArticleJump(node);
} else {
selectedNode.value = node;
focusOnNode(node);
}
} else {
selectedNode.value = null;
focusOnNode(node);
}
})
.onBackgroundClick(() => {
selectedNode.value = null;
currentActiveNode.value = null;
});
resetCamera();
startSelfRotation();
} catch (error) {
console.error("加载 3d-force-graph 图谱模块失败", error);
}
};
// 自旋算法
const startSelfRotation = () => {
if (!graphInstance) return;
stopSelfRotation();
let angle = 0;
const radius = 280;
rotationTimer = setInterval(() => {
if (!autoRotate.value) return;
angle += 0.0025;
graphInstance.cameraPosition({
x: radius * Math.sin(angle),
z: radius * Math.cos(angle)
});
}, 25);
};
const stopSelfRotation = () => {
if (rotationTimer) {
clearInterval(rotationTimer);
rotationTimer = null;
}
};
const toggleAutoRotate = () => {
autoRotate.value = !autoRotate.value;
if (autoRotate.value) {
startSelfRotation();
} else {
stopSelfRotation();
}
};
// 相机聚焦节点
const focusOnNode = (node) => {
if (!graphInstance) return;
currentActiveNode.value = node.id;
if (autoRotate.value) {
stopSelfRotation();
}
const distance = 85;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
graphInstance.cameraPosition(
{ x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio },
node,
2000
);
setTimeout(() => {
if (autoRotate.value) {
startSelfRotation();
}
}, 3000);
};
// 树形列表中的文章点击
const handleArticleClick = (post) => {
if (graphInstance) {
const nodeId = `article:${post.id}`;
const myData = graphInstance.graphData();
const node = myData.nodes.find(n => n.id === nodeId);
if (node) {
focusOnNode(node);
// 在页面上,如果是本页,可以平滑定位,也可以直接跳转
setTimeout(() => {
router.go(post.regularPath);
}, 1500);
} else {
router.go(post.regularPath);
}
} else {
router.go(post.regularPath);
}
};
// 触发跳转至文章页面
const handleArticleJump = (node) => {
if (node && node.route) {
router.go(node.route);
}
};
const resetCamera = () => {
if (!graphInstance) return;
currentActiveNode.value = null;
graphInstance.cameraPosition(
{ x: 0, y: 0, z: 280 },
{ x: 0, y: 0, z: 0 },
1500
);
};
const destroyGraph = () => {
stopSelfRotation();
if (graphInstance) {
try {
if (graphContainerRef.value) {
graphContainerRef.value.innerHTML = "";
}
graphInstance = null;
} catch (e) {
console.warn("销毁 3D 图谱失败", e);
}
}
};
// 在组件可见且在移动端标签切换时处理图谱实例的激活
watch(activeMobileTab, (newVal) => {
if (newVal === "graph") {
nextTick(() => {
init3DGraph();
});
} else {
destroyGraph();
}
});
onMounted(() => {
nextTick(() => {
// 桌面端或者移动端默认为 graph 时初始化
if (activeMobileTab.value === "graph") {
init3DGraph();
}
});
});
onBeforeUnmount(() => {
destroyGraph();
});
</script>
<style lang="scss" scoped>
.point-group-page {
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
box-shadow: 0 12px 32px -4px var(--main-border-shadow);
border-radius: 16px;
padding: 1.5rem 2rem;
margin-top: 1rem;
animation: fade-up 0.6s backwards;
@media (max-width: 768px) {
padding: 1rem;
border-radius: 12px;
}
}
.page-header {
border-bottom: 1px solid var(--main-card-border);
padding-bottom: 1.2rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding-bottom: 0.8rem;
margin-bottom: 1rem;
}
.header-text {
.title {
font-size: 1.8rem;
font-weight: 800;
color: var(--main-font-color);
margin: 0 0 6px 0;
letter-spacing: -0.01em;
}
.subtitle {
font-size: 0.9rem;
color: var(--main-font-second-color);
margin: 0;
opacity: 0.8;
max-width: 680px;
line-height: 1.5;
}
}
}
/* 移动端专用的标签页切换头部 */
.mobile-tabs-header {
display: none;
background: var(--main-card-second-background);
border: 1px solid var(--main-card-border);
border-radius: 8px;
padding: 3px;
gap: 4px;
@media (max-width: 768px) {
display: flex;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
background: none;
border: none;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: var(--main-font-second-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.25s;
.icon-svg {
width: 14px;
height: 14px;
}
.iconfont {
font-size: 14px;
}
&.active {
background: var(--main-color);
color: var(--main-card-background);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
}
}
.page-body {
width: 100%;
height: 600px;
display: flex;
gap: 1.5rem;
overflow: hidden;
@media (max-width: 768px) {
height: 520px;
gap: 0;
}
}
/* 左侧列表面板 */
.list-panel {
width: 300px;
border: 1px solid var(--main-card-border);
border-radius: 12px;
background-color: var(--main-card-second-background);
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s ease;
@media (max-width: 768px) {
width: 100%;
border: none;
background-color: transparent;
&.mobile-hidden {
display: none;
}
&.mobile-show {
display: flex;
}
}
.panel-header {
padding: 1rem 1.2rem;
border-bottom: 1px solid var(--main-card-border);
display: flex;
justify-content: space-between;
align-items: center;
h3 {
font-size: 13px;
font-weight: 700;
margin: 0;
color: var(--main-font-color);
}
.count {
font-size: 11px;
color: var(--main-font-second-color);
opacity: 0.6;
}
}
.tree-shell {
flex: 1;
overflow-y: auto;
padding: 0.8rem;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--main-card-border);
border-radius: 4px;
}
}
}
.tree-root {
list-style: none;
padding: 0;
margin: 0;
}
.tree-group {
margin-bottom: 0.6rem;
.group-header {
display: flex;
align-items: center;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--main-font-color);
transition: background-color 0.25s;
&:hover {
background-color: var(--main-card-border);
}
.iconfont {
font-size: 13px;
margin-right: 6px;
opacity: 0.8;
}
.icon-right, .icon-down {
font-size: 10px;
width: 10px;
}
.group-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-count {
font-size: 10px;
background: var(--main-card-border);
padding: 1px 5px;
border-radius: 8px;
color: var(--main-font-second-color);
}
}
.no-collapse {
cursor: default;
&:hover {
background: none;
}
}
}
.group-children {
list-style: none;
padding-left: 20px;
margin: 2px 0 0 0;
}
.tree-item {
margin: 3px 0;
.item-link {
display: block;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
color: var(--main-font-second-color);
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: var(--main-card-border);
color: var(--main-color);
}
}
&.active .item-link {
background: var(--main-card-border);
color: var(--main-color);
font-weight: 600;
}
}
/* 右侧画布面板 */
.canvas-panel {
flex: 1;
border: 1px solid var(--main-card-border);
border-radius: 12px;
background-color: var(--main-card-second-background);
position: relative;
overflow: hidden;
@media (max-width: 768px) {
width: 100%;
border: none;
border-radius: 8px;
background-color: var(--main-card-second-background);
&.mobile-hidden {
display: none;
}
&.mobile-show {
display: block;
}
}
}
.graph-container-element {
width: 100%;
height: 100%;
outline: none;
}
/* 控制覆层 */
.graph-controls {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
display: flex;
justify-content: space-between;
align-items: flex-end;
pointer-events: none;
z-index: 10;
@media (max-width: 768px) {
bottom: 8px;
left: 8px;
right: 8px;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.btn-group {
display: flex;
gap: 6px;
pointer-events: auto;
}
.ctrl-btn {
background: var(--main-card-background);
border: 1px solid var(--main-card-border);
color: var(--main-font-color);
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: all 0.25s;
.iconfont {
font-size: 12px;
}
&:hover {
background: var(--main-card-border);
transform: translateY(-1px);
}
&.active {
background: var(--main-color);
color: var(--main-card-background);
border-color: var(--main-color);
}
}
.legends {
background: var(--main-card-background);
border: 1px solid var(--main-card-border);
padding: 8px 12px;
border-radius: 10px;
display: flex;
gap: 12px;
pointer-events: auto;
@media (max-width: 768px) {
gap: 6px;
padding: 6px 10px;
flex-wrap: wrap;
justify-content: space-between;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
color: var(--main-font-second-color);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.root-dot { background-color: var(--COLORS-root, #ff4d6d); }
.cat-dot { background-color: var(--COLORS-cat, #00d2ff); }
.art-dot { background-color: var(--COLORS-art, #9c27b0); }
.ref-dot {
background-color: var(--COLORS-ref, #39ff14);
box-shadow: 0 0 4px var(--COLORS-ref, #39ff14);
}
}
}
/* 选中节点顶部悬浮通知栏 (Premium 磨砂及粒子呼吸灯特效) */
.selected-node-toast {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
pointer-events: auto;
cursor: pointer;
animation: pulse-glow 2s infinite alternate;
@media (max-width: 768px) {
top: 12px;
width: 90%;
}
}
.toast-inner {
display: flex;
align-items: center;
gap: 12px;
background: rgba(15, 15, 26, 0.95);
border: 1px solid rgba(0, 229, 255, 0.35);
backdrop-filter: blur(20px);
padding: 8px 18px;
border-radius: 30px;
box-shadow: 0 8px 32px rgba(0, 229, 255, 0.2);
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
@media (max-width: 768px) {
padding: 6px 14px;
gap: 8px;
justify-content: space-between;
}
&:hover {
background: rgba(25, 25, 40, 0.95);
border-color: rgba(0, 229, 255, 0.6);
box-shadow: 0 10px 40px rgba(0, 229, 255, 0.35);
transform: translateY(-2px);
}
.badge {
font-size: 10px;
font-weight: 700;
color: #00e5ff;
background: rgba(0, 229, 255, 0.12);
padding: 2px 8px;
border-radius: 20px;
white-space: nowrap;
}
.node-name {
font-size: 13px;
font-weight: 700;
color: #ffffff;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 768px) {
max-width: 150px;
}
}
.action-hint {
font-size: 11px;
font-weight: 600;
color: #ffe082;
white-space: nowrap;
animation: bounce-right 1s infinite alternate;
}
}
@keyframes pulse-glow {
0% {
box-shadow: 0 0 12px rgba(0, 229, 255, 0.12);
}
100% {
box-shadow: 0 0 24px rgba(0, 229, 255, 0.3);
}
}
@keyframes bounce-right {
0% { transform: translateX(0); }
100% { transform: translateX(5px); }
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide-up-enter-from {
opacity: 0;
transform: translate(-50%, -24px);
}
.slide-up-leave-to {
opacity: 0;
transform: translate(-50%, -24px);
}
</style>3D 双链点群动力学图谱的设计与落地https://blog.chgr.cc/posts/2026/0531
评论 隐私政策