☰
纯前端手账碎片灵感聚类图谱生成器:基于 Force-Directed 弹性力导向算法与 Canvas 零延迟交互
2026/10/23 6:02:13 网站建设 项目流程

在深秋的周四,经过了前半周的代码沉淀与灵感爆发,创作者的便签夹与草稿本里往往堆满了大量碎片的想法:

  • 一句关于“水车流体力学动效”的随笔小注;
  • 一段关于“CRDT 离线状态合并”的技术推演;
  • 两张从窗外拍摄的深秋银杏树叶剪影;
  • 若干条亟待在周末整理发布的文章标题草案。

这些零散的素材如果直接平铺在二维手账画板上,往往是一团杂乱无章的混乱毛线球;而如果强行把它们塞进严苛的树状大纲(Tree Outline)里,又会生硬地割裂灵感之间千丝万缕的网状关联。

为了在纯前端以最自然、最符合直觉的方式梳理这些灵感碎片,“秋日手账杂货铺”构建了一款手账碎片灵感聚类图谱生成器。系统采用经典计算机图形学中的弹性力导向图算法(Force-Directed Graph Layout),在纯浏览器 Canvas 沙箱内实时模拟物理世界中的“库仑排斥力”与“胡克弹簧引力”,让散乱的灵感卡片在几秒钟内像星系一样自动聚类、自平衡排布,并支持流畅的拖拽微交互,帮助创作者在动态舒展的力学平衡中,一眼看清思维的脉络。


一、物理力学建模:从库仑斥力到胡克弹簧引力

力导向图之所以能够呈现出充满呼吸感与自然和谐的拓扑布局,是因为它把图论中的“节点(Nodes)”与“连线(Edges)”映射为了带电粒子与物理弹簧系统:

(节点 A: 核心灵感) │ 胡克弹簧引力 F_spring = -k * (|d| - L) * (d / |d|) ▼ (节点 B: 子任务) <====== 库仑静电斥力 ======> (节点 C: 关联笔记) F_repulse = (Cr / |d|^2) * (d / |d|)

1. 节点间的库仑排斥力(Coulomb Repulsion)

为了防止不同的手账便签卡片相互重叠穿模,系统假设画布上的所有节点都携带相同极性的电荷。任意两个节点 $u$ 与 $v$ 之间,存在一个与距离平方成反比的相互排斥力:

$$\vec{F}_{\text{repulse}}(u, v) = \frac{C_r}{|\vec{p}_u - \vec{p}_v|^2 + \epsilon} \cdot \frac{\vec{p}_u - \vec{p}_v}{|\vec{p}_u - \vec{p}_v|}$$

其中 $C_r$ 为斥力常数,$\epsilon$ 为防止距离过近时斥力除零溢出的微小阻尼因子。

2. 边之间的胡克弹簧引力(Hooke's Spring Attraction)

如果两张手账卡片之间存在逻辑关联(例如“父子层级”或“同类标签”),它们之间便连接着一根理想的虚拟弹簧。当两者之间的欧氏距离 $d$ 偏离自然静止长度 $L$ 时,胡克定律会产生拉回或推开的弹性恢复力:

$$\vec{F}_{\text{spring}}(u, v) = - k_s \cdot (|\vec{p}_u - \vec{p}_v| - L) \cdot \frac{\vec{p}_u - \vec{p}_v}{|\vec{p}_u - \vec{p}_v|}$$

其中 $k_s$ 为弹簧劲度系数,$L$ 为我们期望维持的优雅留白间距(通常设为 $110\text{px} \sim 160\text{px}$)。

3. 欧拉数值积分与摩擦力退火(Verlet / Euler Integration with Damping)

在每一个物理时间步 $\Delta t$ 内,合力驱动节点的加速度与速度更新:

$$\vec{v}_u(t + \Delta t) = (\vec{v}u(t) + \frac{\vec{F}{\text{total}}}{m} \cdot \Delta t) \cdot \mu$$
$$\vec{p}_u(t + \Delta t) = \vec{p}_u(t) + \vec{v}_u(t + \Delta t) \cdot \Delta t$$

其中 $\mu \in [0.85, 0.92]$ 为黏滞阻尼系数(模拟空气阻力),它能够使整个力学系统在摆动数秒后,平稳收敛(Annealing)至全局能量最低的静止最优布局。


二、架构设计与 Canvas 零延迟渲染

为了确保在包含数百个手账节点的场景下,页面依然保持坚挺的 60FPS 帧率,我们拒绝使用沉重的 DOM 节点来渲染图形,而是采用轻量自闭环的纯原生 Canvas 2D 渲染架构:

  1. 内存状态模型(Node-Link Registry):节点包含坐标 $(x, y)$、速度 $(vx, vy)$、标题、分类色温与半径;
  2. 力学模拟器(Physics Step Loop):每次requestAnimationFrame执行单步物理迭代,若合速度低于收敛阈值则自动休眠,杜绝空转耗电;
  3. 指针拖拽交互(Pointer Drag Hook):当用户用鼠标拖拽某个节点时,该节点的坐标被强制钉在鼠标位置(速度归零),周围所有相连的子节点会像弹簧一样跟着柔软晃动,交互手感丝滑温润。

三、纯前端核心工程实战代码

下面是完整的纯前端灵感聚类图谱生成器工程代码,单文件原生实现,零第三方依赖:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>手账碎片灵感聚类图谱 · 澹锦汐</title> <style> :root { --bg-nebula: #181513; --card-panel: rgba(36, 31, 26, 0.92); --accent-gold: #d4af37; --parchment-tag: #f4ede1; --text-main: #f5eee8; --text-dim: #99887b; } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: flex; background: radial-gradient(circle at center, #2e2621 0%, var(--bg-nebula) 100%); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text-main); overflow: hidden; user-select: none; } .toolbox-sidebar { width: 320px; padding: 32px 24px; background: var(--card-panel); backdrop-filter: blur(16px); border-right: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 20px; box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5); z-index: 10; } h1 { font-size: 15px; letter-spacing: 2px; color: var(--accent-gold); margin: 0; text-transform: uppercase; } .form-group { display: flex; flex-direction: column; gap: 8px; } label { font-size: 12px; color: var(--text-dim); letter-spacing: 1px; } input[type="text"], select { width: 100%; padding: 10px 12px; border-radius: 8px; background: rgba(0, 0, 0, 0.35); border: 1px solid rgba(255, 255, 255, 0.12); color: #ffffff; font-size: 13px; outline: none; } input[type="text"]:focus { border-color: var(--accent-gold); } .btn-submit { padding: 12px; border-radius: 12px; border: none; background: var(--accent-gold); color: #1a1614; font-weight: 600; font-size: 13px; cursor: pointer; letter-spacing: 1px; transition: all 0.2s; } .btn-submit:hover { background: #f7b76e; transform: translateY(-2px); } .canvas-stage { flex: 1; position: relative; cursor: grab; } .canvas-stage:active { cursor: grabbing; } canvas { width: 100%; height: 100%; display: block; } .status-hud { position: absolute; bottom: 24px; right: 32px; font-size: 12px; letter-spacing: 1px; color: var(--text-dim); pointer-events: none; } </style> </head> <body> <aside class="toolbox-sidebar"> <div> <h1>灵感聚类图谱 · 澹锦汐</h1> <div style="font-size: 11px; color: #7f6e62; margin-top: 4px;">FORCE-DIRECTED GRAPH · 1022</div> </div> <div class="form-group"> <label>添加新灵感碎片</label> <input type="text" id="newIdeaInput" placeholder="输入手账点子或子任务..."> </div> <div class="form-group"> <label>所属主题星系 (Cluster Category)</label> <select id="categorySelect"> <option value="code">技术攻坚 (Amber Gold)</option> <option value="life">生活慢调 (Sage Green)</option> <option value="art">手账排版 (Rose Dust)</option> </select> </div> <button class="btn-submit" id="addIdeaBtn">投掷进灵感星系</button> <div style="margin-top: auto; font-size: 12px; color: #827265; line-height: 1.6;"> ✨ 鼠标拖拽任意节点可感受物理弹簧拉扯;点击节点可自由建立或解除关联。 </div> </aside> <main class="canvas-stage" id="stage"> <canvas id="graphCanvas"></canvas> <div class="status-hud" id="hudText">物理引擎收敛就绪 · 60FPS</div> </main> <script> const canvas = document.getElementById('graphCanvas'); const ctx = canvas.getContext('2d'); const stage = document.getElementById('stage'); const newIdeaInput = document.getElementById('newIdeaInput'); const categorySelect = document.getElementById('categorySelect'); const addIdeaBtn = document.getElementById('addIdeaBtn'); const hudText = document.getElementById('hudText'); let width = canvas.width = stage.clientWidth; let height = canvas.height = stage.clientHeight; window.addEventListener('resize', () => { width = canvas.width = stage.clientWidth; height = canvas.height = stage.clientHeight; }); // 主题色彩体系 const THEME_COLORS = { code: { bg: '#c99c42', glow: 'rgba(201, 156, 66, 0.4)' }, life: { bg: '#607d67', glow: 'rgba(96, 125, 103, 0.4)' }, art: { bg: '#b25d48', glow: 'rgba(178, 93, 72, 0.4)' } }; // 预置的秋日手账灵感碎片数据集 const nodes = [ { id: 1, title: '秋日手账杂货铺', cat: 'code', x: width / 2, y: height / 2, vx: 0, vy: 0, r: 36, isCore: true }, { id: 2, title: '离线 CRDT 引擎', cat: 'code', x: width / 2 + 100, y: height / 2, vx: 0, vy: 0, r: 24 }, { id: 3, title: 'Float16Array 优化', cat: 'code', x: width / 2 - 90, y: height / 2 - 80, vx: 0, vy: 0, r: 22 }, { id: 4, title: '周四深潜热咖啡', cat: 'life', x: width / 2 - 120, y: height / 2 + 80, vx: 0, vy: 0, r: 22 }, { id: 5, title: '弄堂糖炒栗子', cat: 'life', x: width / 2 - 180, y: height / 2 + 120, vx: 0, vy: 0, r: 20 }, { id: 6, title: '古典金石拓印', cat: 'art', x: width / 2 + 120, y: height / 2 - 100, vx: 0, vy: 0, r: 24 }, { id: 7, title: '复古风琴折相册', cat: 'art', x: width / 2 + 160, y: height / 2 + 60, vx: 0, vy: 0, r: 22 } ]; const edges = [ { source: 1, target: 2 }, { source: 1, target: 3 }, { source: 1, target: 4 }, { source: 4, target: 5 }, { source: 1, target: 6 }, { source: 1, target: 7 } ]; // 力导向物理参数 const REPULSION = 4200; // 库仑斥力强度 const SPRING_K = 0.042; // 弹簧弹性系数 const REST_LENGTH = 130; // 弹簧静止理想长度 const DAMPING = 0.88; // 黏滞空气阻尼 let draggedNode = null; // 核心物理仿真单步迭代 function stepSimulation() { // 1. 计算所有节点两两之间的库仑排斥力 for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const u = nodes[i]; const v = nodes[j]; const dx = v.x - u.x; const dy = v.y - u.y; const distSq = dx * dx + dy * dy + 100; // 避免除零 const dist = Math.sqrt(distSq); const force = REPULSION / distSq; const fx = (dx / dist) * force; const fy = (dy / dist) * force; u.vx -= fx; u.vy -= fy; v.vx += fx; v.vy += fy; } } // 2. 计算边相连节点之间的胡克弹簧引力 for (let e of edges) { const u = nodes.find(n => n.id === e.source); const v = nodes.find(n => n.id === e.target); if (!u || !v) continue; const dx = v.x - u.x; const dy = v.y - u.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const delta = dist - REST_LENGTH; const force = delta * SPRING_K; const fx = (dx / dist) * force; const fy = (dy / dist) * force; u.vx += fx; u.vy += fy; v.vx -= fx; v.vy -= fy; } // 3. 欧拉积分更新位置与速度阻尼 for (let node of nodes) { if (node === draggedNode) continue; // 被拖拽的节点不更新速度 // 弱向心力,防止节点散落出屏幕 node.vx += (width / 2 - node.x) * 0.003; node.vy += (height / 2 - node.y) * 0.003; node.vx *= DAMPING; node.vy *= DAMPING; node.x += node.vx; node.y += node.vy; } } // 核心渲染循环 function renderGraph() { stepSimulation(); ctx.clearRect(0, 0, width, height); // 1. 绘制带有柔和微光的连线 (Spring Edges) ctx.lineWidth = 1.6; for (let e of edges) { const u = nodes.find(n => n.id === e.source); const v = nodes.find(n => n.id === e.target); if (!u || !v) continue; ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; ctx.beginPath(); ctx.moveTo(u.x, u.y); ctx.lineTo(v.x, v.y); ctx.stroke(); } // 2. 绘制灵感微卡片节点 (Nodes) for (let node of nodes) { const color = THEME_COLORS[node.cat]; // 节点外围弥散柔光 ctx.beginPath(); ctx.arc(node.x, node.y, node.r + 6, 0, Math.PI * 2); ctx.fillStyle = color.glow; ctx.fill(); // 节点实体圆环 ctx.beginPath(); ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2); ctx.fillStyle = color.bg; ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0; // 重置阴影 ctx.lineWidth = node.isCore ? 3 : 1.5; ctx.strokeStyle = '#ffffff'; ctx.stroke(); // 节点文字标题 ctx.font = `${node.isCore ? 'bold 13px' : '11.5px'} -apple-system, sans-serif`; ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.title, node.x, node.y + node.r + 14); } requestAnimationFrame(renderGraph); } // 鼠标拖拽事件处理 function getMousePos(e) { const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } canvas.addEventListener('mousedown', (e) => { const { x, y } = getMousePos(e); for (let node of nodes) { const dx = node.x - x; const dy = node.y - y; if (Math.sqrt(dx * dx + dy * dy) <= node.r) { draggedNode = node; break; } } }); window.addEventListener('mousemove', (e) => { if (draggedNode) { const { x, y } = getMousePos(e); draggedNode.x = x; draggedNode.y = y; draggedNode.vx = 0; draggedNode.vy = 0; } }); window.addEventListener('mouseup', () => { draggedNode = null; }); // 添加新碎片 addIdeaBtn.addEventListener('click', () => { const title = newIdeaInput.value.trim(); if (!title) return; const newNode = { id: Date.now(), title, cat: categorySelect.value, x: width / 2 + (Math.random() - 0.5) * 80, y: height / 2 + (Math.random() - 0.5) * 80, vx: (Math.random() - 0.5) * 4, vy: (Math.random() - 0.5) * 4, r: 20 }; nodes.push(newNode); // 默认与核心节点连线 edges.push({ source: 1, target: newNode.id }); newIdeaInput.value = ''; hudText.textContent = `已收录碎片: "${title}" · 正在自平衡聚类`; }); // 启动动效循环 renderGraph(); </script> </body> </html>

四、听汐的思维手记:允许灵感像星团一样自由呼吸

在做笔记和手账时,很多人常常苦恼于“分类强迫症”:一句话不知道该放进生活文件夹、还是技术归档目录,最终在纠结中错失了记录灵感的最好时机。

思维从来不是规规矩矩的抽屉,而是一片互相吸引、相互拉扯的星系。

通过力导向图物理引擎,我们赋予了代码以生命力:你只需要把闪光的念头随手投掷进画布,算法会在微秒之间替你找到它们最舒服的相对位置。在相互推拉与晃动中,混乱自然退化为秩序,散乱的碎石最终凝聚成了有力的群山。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询