Skip to content

Commit dde4b1d

Browse files
ystemsrxclaude
andcommitted
feat(layout): add continuous force-direct toggle to canvas
Adds a fifth canvas overlay button that runs a lightweight physics simulation (repulsion + edge-spring + damping) on top of G6, so dragging a node pushes/pulls the rest in real time — mirroring the hero ER demo on the landing page. Loop starts immediately on enable via a 36-frame easeOutCubic warmup, avoiding the underdamped overshoot from cold- starting on a non-equilibrium layout. Drag interactions skip the warmup to keep full responsiveness. Auto-disables when the user invokes smart layout, force align, hide- attributes toggle, regenerate, or restores a snapshot, since each of those rebuilds positions or node sets the controller's velocity map can't track. The slash overlay style (mirroring colorize/attrs toggles) makes the off-state visible at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b6f957e commit dde4b1d

7 files changed

Lines changed: 300 additions & 12 deletions

File tree

css/style.css

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,8 @@ body::before {
464464
.background-toggle,
465465
.colorize-toggle,
466466
.attrs-toggle,
467-
.history-toggle {
467+
.history-toggle,
468+
.force-toggle {
468469
position: absolute;
469470
left: 16px;
470471
width: 40px;
@@ -502,10 +503,15 @@ body::before {
502503
top: 160px;
503504
}
504505

506+
.force-toggle {
507+
top: 208px;
508+
}
509+
505510
.background-toggle:hover,
506511
.colorize-toggle:hover,
507512
.attrs-toggle:hover,
508-
.history-toggle:hover {
513+
.history-toggle:hover,
514+
.force-toggle:hover {
509515
transform: scale(1.06);
510516
border-color: var(--app-accent);
511517
box-shadow: 0 6px 16px rgba(20, 20, 19, 0.1);
@@ -514,14 +520,16 @@ body::before {
514520
.background-toggle:active,
515521
.colorize-toggle:active,
516522
.attrs-toggle:active,
517-
.history-toggle:active {
523+
.history-toggle:active,
524+
.force-toggle:active {
518525
transform: scale(0.95);
519526
}
520527

521528
.background-toggle > svg,
522529
.colorize-toggle > svg,
523530
.attrs-toggle > svg,
524-
.history-toggle > svg {
531+
.history-toggle > svg,
532+
.force-toggle > svg {
525533
font-size: 16px;
526534
color: var(--app-ink-2);
527535
display: block;
@@ -531,10 +539,30 @@ body::before {
531539
.background-toggle:hover > svg,
532540
.colorize-toggle:hover > svg,
533541
.attrs-toggle:hover > svg,
534-
.history-toggle:hover > svg {
542+
.history-toggle:hover > svg,
543+
.force-toggle:hover > svg {
535544
color: var(--app-accent-warm);
536545
}
537546

547+
.force-toggle.active {
548+
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, #fed7aa 100%);
549+
border-color: rgba(217, 119, 87, 0.4);
550+
box-shadow: 0 4px 14px rgba(217, 119, 87, 0.22);
551+
}
552+
553+
.force-toggle.active > svg {
554+
color: #c96442;
555+
}
556+
557+
.force-toggle.active:hover {
558+
background: linear-gradient(135deg, #fde68a 0%, #fcd34d 50%, #fdba74 100%);
559+
border-color: rgba(217, 119, 87, 0.55);
560+
}
561+
562+
.force-toggle.active:hover > svg {
563+
color: #b04a2c;
564+
}
565+
538566
.colorize-toggle.active {
539567
background: linear-gradient(135deg, #dbeafe 0%, #f3e8ff 50%, #ffe4e6 100%);
540568
border-color: rgba(14, 165, 233, 0.3);
@@ -555,12 +583,14 @@ body::before {
555583
}
556584

557585
.attrs-toggle,
558-
.colorize-toggle {
586+
.colorize-toggle,
587+
.force-toggle {
559588
overflow: hidden;
560589
}
561590

562591
.attrs-toggle::after,
563-
.colorize-toggle::after {
592+
.colorize-toggle::after,
593+
.force-toggle::after {
564594
content: "";
565595
position: absolute;
566596
left: 50%;
@@ -581,12 +611,14 @@ body::before {
581611
transform: translate(-50%, -50%) rotate(-45deg) scaleX(1);
582612
}
583613

584-
.colorize-toggle:not(.active)::after {
614+
.colorize-toggle:not(.active)::after,
615+
.force-toggle:not(.active)::after {
585616
transform: translate(-50%, -50%) rotate(-45deg) scaleX(1);
586617
}
587618

588619
.attrs-toggle:hover::after,
589-
.colorize-toggle:hover::after {
620+
.colorize-toggle:hover::after,
621+
.force-toggle:hover::after {
590622
background: var(--app-accent-warm);
591623
}
592624

@@ -936,7 +968,8 @@ body::before {
936968
.background-toggle:hover,
937969
.colorize-toggle:hover,
938970
.attrs-toggle:hover,
939-
.history-toggle:hover {
971+
.history-toggle:hover,
972+
.force-toggle:hover {
940973
transform: none;
941974
border-color: var(--app-border);
942975
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.06);
@@ -954,7 +987,8 @@ body::before {
954987
.background-toggle:active,
955988
.colorize-toggle:active,
956989
.attrs-toggle:active,
957-
.history-toggle:active {
990+
.history-toggle:active,
991+
.force-toggle:active {
958992
transform: scale(1.1);
959993
}
960994

@@ -1571,6 +1605,7 @@ body.is-lang-fading .lang-fade-target {
15711605
.colorize-toggle:focus-visible,
15721606
.attrs-toggle:focus-visible,
15731607
.history-toggle:focus-visible,
1608+
.force-toggle:focus-visible,
15741609
.github-corner:focus-visible {
15751610
outline: 2px solid var(--app-accent);
15761611
outline-offset: 3px;

src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { patchRelationshipLinkPoints, registerCustomNodes } from "./builder";
77
import { CodeEditor } from "./editor";
88
import { SwitchControl } from "./components/SwitchControl";
99
import {
10+
CircleNodesIcon,
1011
ClockRotateLeftIcon,
1112
EyeIcon,
1213
EyeSlashIcon,
@@ -43,6 +44,7 @@ const App = () => {
4344
isColored,
4445
showComment,
4546
hideFields,
47+
forceOn,
4648
hasGraph,
4749
error,
4850
loading,
@@ -51,6 +53,7 @@ const App = () => {
5153
setIsColored,
5254
setShowComment,
5355
setHideFields,
56+
setForceOn,
5457
handleGenerate,
5558
handleForceAlign,
5659
handleArrangeLayout,
@@ -618,6 +621,13 @@ const App = () => {
618621
>
619622
<ClockRotateLeftIcon />
620623
</div>
624+
<div
625+
className={`force-toggle ${forceOn ? "active" : ""}`}
626+
onClick={() => setForceOn(!forceOn)}
627+
title={forceOn ? t.tipForceOff : t.tipForceOn}
628+
>
629+
<CircleNodesIcon />
630+
</div>
621631
{loading && (
622632
<div className="loading-overlay">
623633
<div className="spinner"></div>

src/components/icons.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export const TrashIcon = makeIcon(
6464
"M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z",
6565
);
6666

67+
export const CircleNodesIcon = makeIcon(
68+
"0 0 512 512",
69+
"M418.4 157.9c35.3-8.3 61.6-40 61.6-77.9c0-44.2-35.8-80-80-80c-43.4 0-78.7 34.5-80 77.5L136.2 151.1C121.7 136.8 101.9 128 80 128c-44.2 0-80 35.8-80 80s35.8 80 80 80c12.2 0 23.8-2.7 34.1-7.6L259.7 407.8c-2.4 7.6-3.7 15.8-3.7 24.2c0 44.2 35.8 80 80 80s80-35.8 80-80c0-27.7-14-52.1-35.4-66.4l37.8-207.7zM156.3 232.2c2.2-6.9 3.5-14.2 3.7-21.7l183.8-73.5c3.6 3.5 7.4 6.7 11.6 9.5L317.6 354.1c-5.5 1.3-10.8 3.1-15.8 5.5L156.3 232.2z",
70+
);
71+
6772
export const ArrowsLeftRightIcon = makeIcon(
6873
"0 0 512 512",
6974
"M406.6 374.6l96-96c12.5-12.5 12.5-32.8 0-45.3l-96-96c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224l-293.5 0 41.4-41.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-96 96c-12.5 12.5-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288l293.5 0-41.4 41.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0z",

src/graph/attachEntityDragSync.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ interface DraggableGraph extends GraphLike {
1313
* 3. 拖实体节点时同步带动它的属性节点(共同位移)
1414
*
1515
* 由 useGraph 在创建图后调用一次;不需要解绑(图本身 destroy 时事件随之消失)。
16+
*
17+
* 当 isForceActive 返回 true 时,跳过 (3) —— 让持续力导向控制器接管属性节点
18+
* 的位移;否则两者会同时改 attribute 坐标,互相覆盖。
1619
*/
1720
export function attachEntityDragSync(
1821
graph: DraggableGraph,
1922
history: HistoryManager,
23+
isForceActive?: () => boolean,
2024
): void {
2125
graph.on("node:mouseenter", (e: any) => {
2226
graph.setItemState(e.item, "hover", true);
@@ -64,6 +68,7 @@ export function attachEntityDragSync(
6468
const nodeModel = node.getModel();
6569

6670
if (nodeModel.type === "entity" && draggedEntity === node) {
71+
if (isForceActive && isForceActive()) return;
6772
const startPos = dragStartPositions.get(nodeModel.id);
6873
if (startPos) {
6974
const deltaX = nodeModel.x - startPos.x;

src/graph/forceLoop.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type { GraphLike } from "../types";
2+
3+
interface ForceableGraph extends GraphLike {
4+
on(event: string, handler: (e: any) => void): void;
5+
}
6+
7+
export interface ForceLoopController {
8+
setEnabled(enabled: boolean): void;
9+
isEnabled(): boolean;
10+
destroy(): void;
11+
}
12+
13+
// 持续力导向控制器
14+
// 不依赖 G6 自带 layout tick(首次收敛后就不再跑),而是自写一个轻量
15+
// 物理步骤。开关一旦打开,RAF 循环立刻起跑(不必先拖一下);拖动期间
16+
// 被拖节点由 drag-node 钉在鼠标上,其余节点在循环里被斥力 + 连边引力
17+
// 推拉;关闭时立即停止。
18+
export function attachForceLoop(graph: ForceableGraph): ForceLoopController {
19+
let enabled = false;
20+
let raf: number | null = null;
21+
let pinnedId: string | null = null;
22+
// 冷启动 ramp-up:当前布局相对我们这套力参数通常不在平衡点,
23+
// 直接放力会先把节点推远再拉回(欠阻尼弹簧)。让力 / 速度上限
24+
// 在前 WARMUP_TOTAL 帧从 0 平滑升到 1,节点就能贴着等势线滑过去。
25+
const WARMUP_TOTAL = 36;
26+
let warmupRemaining = 0;
27+
const velocities = new Map<string, { vx: number; vy: number }>();
28+
29+
const radius = (m: any): number => {
30+
const sizes: Record<string, number> = {
31+
entity: 80,
32+
relationship: 50,
33+
attribute: 50,
34+
};
35+
return sizes[m?.nodeType] || 50;
36+
};
37+
38+
const buildAdj = (): Map<string, Set<string>> => {
39+
const adj = new Map<string, Set<string>>();
40+
graph.getEdges().forEach((e) => {
41+
const m = e.getModel() as any;
42+
if (!adj.has(m.source)) adj.set(m.source, new Set());
43+
if (!adj.has(m.target)) adj.set(m.target, new Set());
44+
adj.get(m.source)!.add(m.target);
45+
adj.get(m.target)!.add(m.source);
46+
});
47+
return adj;
48+
};
49+
50+
const step = () => {
51+
if (!graph || graph.destroyed || !enabled) {
52+
raf = null;
53+
return;
54+
}
55+
56+
const adj = buildAdj();
57+
const nodes = graph.getNodes();
58+
const pos: Record<string, { x: number; y: number }> = {};
59+
const radii: Record<string, number> = {};
60+
nodes.forEach((n) => {
61+
const m = n.getModel() as any;
62+
pos[m.id] = { x: m.x || 0, y: m.y || 0 };
63+
radii[m.id] = radius(m);
64+
});
65+
66+
const ids = Object.keys(pos);
67+
const IDEAL = 130;
68+
const K_ATTRACT = 0.04;
69+
const K_REPEL = 9000;
70+
const DAMPING = 0.78;
71+
const MAX_V = 16;
72+
73+
// easeOutCubic:第一帧位移≈0,第 WARMUP_TOTAL 帧及之后位移=正常
74+
const t = warmupRemaining > 0 ? 1 - warmupRemaining / WARMUP_TOTAL : 1;
75+
const ramp = 1 - Math.pow(1 - t, 3);
76+
if (warmupRemaining > 0) warmupRemaining--;
77+
78+
nodes.forEach((n) => {
79+
const m = n.getModel() as any;
80+
const id = m.id as string;
81+
if (id === pinnedId) return;
82+
const p = pos[id];
83+
const r = radii[id];
84+
let fx = 0;
85+
let fy = 0;
86+
87+
// 斥力:所有其它节点
88+
for (let i = 0; i < ids.length; i++) {
89+
const oid = ids[i];
90+
if (oid === id) continue;
91+
const op = pos[oid];
92+
const orr = radii[oid];
93+
const dx = p.x - op.x;
94+
const dy = p.y - op.y;
95+
let d2 = dx * dx + dy * dy;
96+
if (d2 < 1) d2 = 1;
97+
const d = Math.sqrt(d2);
98+
const minD = r + orr + 8;
99+
const mag = K_REPEL / d2 + (d < minD ? (minD - d) * 0.8 : 0);
100+
fx += (dx / d) * mag;
101+
fy += (dy / d) * mag;
102+
}
103+
104+
// 引力:连边邻居
105+
const nb = adj.get(id);
106+
if (nb) {
107+
nb.forEach((nid) => {
108+
const op = pos[nid];
109+
if (!op) return;
110+
const dx = op.x - p.x;
111+
const dy = op.y - p.y;
112+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
113+
const delta = (d - IDEAL) * K_ATTRACT;
114+
fx += (dx / d) * delta;
115+
fy += (dy / d) * delta;
116+
});
117+
}
118+
119+
const v = velocities.get(id) || { vx: 0, vy: 0 };
120+
// 冷启动阶段把净力按 ramp 缩小:速度积累得慢,等阻尼把过冲压住后
121+
// 再放开到完整力度。稳态时 ramp=1,行为不变。
122+
v.vx = (v.vx + fx * ramp) * DAMPING;
123+
v.vy = (v.vy + fy * ramp) * DAMPING;
124+
const cap = MAX_V * (0.25 + 0.75 * ramp);
125+
const sp = Math.sqrt(v.vx * v.vx + v.vy * v.vy);
126+
if (sp > cap) {
127+
v.vx = (v.vx / sp) * cap;
128+
v.vy = (v.vy / sp) * cap;
129+
}
130+
velocities.set(id, v);
131+
132+
if (Math.abs(v.vx) > 0.05 || Math.abs(v.vy) > 0.05) {
133+
graph.updateItem(n, { x: p.x + v.vx, y: p.y + v.vy }, false);
134+
}
135+
});
136+
137+
raf = requestAnimationFrame(step);
138+
};
139+
140+
graph.on("node:dragstart", (e: any) => {
141+
if (!enabled || !e.item) return;
142+
pinnedId = e.item.getID ? e.item.getID() : (e.item.getModel() as any).id;
143+
velocities.clear();
144+
// 拖动应当走完整力度。若开关刚打开还在 warmup 中,立即收尾,
145+
// 这样用户从开启 → 立即拖动 的过程中也不会感到"卡顿/迟滞"。
146+
warmupRemaining = 0;
147+
});
148+
149+
graph.on("node:dragend", () => {
150+
pinnedId = null;
151+
});
152+
153+
const stop = () => {
154+
pinnedId = null;
155+
warmupRemaining = 0;
156+
if (raf != null) {
157+
cancelAnimationFrame(raf);
158+
raf = null;
159+
}
160+
velocities.clear();
161+
};
162+
163+
return {
164+
setEnabled(en: boolean) {
165+
if (en === enabled) return;
166+
enabled = en;
167+
if (en) {
168+
velocities.clear();
169+
warmupRemaining = WARMUP_TOTAL;
170+
if (raf == null) raf = requestAnimationFrame(step);
171+
} else {
172+
stop();
173+
}
174+
},
175+
isEnabled() {
176+
return enabled;
177+
},
178+
destroy() {
179+
enabled = false;
180+
stop();
181+
},
182+
};
183+
}

0 commit comments

Comments
 (0)