pmx-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { signal } from '@preact/signals';
|
|
2
|
+
import type { SemanticAttentionRegion } from '../../shared/semantic-attention.js';
|
|
3
|
+
|
|
4
|
+
export type AttentionTone =
|
|
5
|
+
| 'context'
|
|
6
|
+
| 'relationship'
|
|
7
|
+
| 'group'
|
|
8
|
+
| 'cluster'
|
|
9
|
+
| 'neighborhood'
|
|
10
|
+
| 'remove';
|
|
11
|
+
|
|
12
|
+
export interface AttentionEntry {
|
|
13
|
+
id: string;
|
|
14
|
+
tone: AttentionTone;
|
|
15
|
+
title: string;
|
|
16
|
+
detail: string;
|
|
17
|
+
nodeIds: string[];
|
|
18
|
+
createdAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const attentionToast = signal<AttentionEntry | null>(null);
|
|
22
|
+
export const attentionHistory = signal<AttentionEntry[]>([]);
|
|
23
|
+
export const attentionPrimaryNodeIds = signal<Set<string>>(new Set());
|
|
24
|
+
export const attentionSecondaryNodeIds = signal<Set<string>>(new Set());
|
|
25
|
+
export const attentionRegions = signal<SemanticAttentionRegion[]>([]);
|
|
26
|
+
export const attentionPulseNodeIds = signal<Set<string>>(new Set());
|
|
27
|
+
export const attentionHistoryOpen = signal<boolean>(false);
|
|
28
|
+
export const attentionHistoryUnread = signal<number>(0);
|
|
29
|
+
|
|
30
|
+
export function resetAttentionState(): void {
|
|
31
|
+
attentionToast.value = null;
|
|
32
|
+
attentionHistory.value = [];
|
|
33
|
+
attentionPrimaryNodeIds.value = new Set();
|
|
34
|
+
attentionSecondaryNodeIds.value = new Set();
|
|
35
|
+
attentionRegions.value = [];
|
|
36
|
+
attentionPulseNodeIds.value = new Set();
|
|
37
|
+
attentionHistoryOpen.value = false;
|
|
38
|
+
attentionHistoryUnread.value = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function openAttentionHistory(): void {
|
|
42
|
+
attentionHistoryOpen.value = true;
|
|
43
|
+
attentionHistoryUnread.value = 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function closeAttentionHistory(): void {
|
|
47
|
+
attentionHistoryOpen.value = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function setAttentionFocus(
|
|
51
|
+
primaryNodeIds: string[],
|
|
52
|
+
secondaryNodeIds: string[],
|
|
53
|
+
regions: SemanticAttentionRegion[],
|
|
54
|
+
): void {
|
|
55
|
+
attentionPrimaryNodeIds.value = new Set(primaryNodeIds);
|
|
56
|
+
attentionSecondaryNodeIds.value = new Set(secondaryNodeIds);
|
|
57
|
+
attentionRegions.value = regions;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setAttentionToast(entry: AttentionEntry | null): void {
|
|
61
|
+
attentionToast.value = entry;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pushAttentionHistory(entry: AttentionEntry, limit = 6): void {
|
|
65
|
+
attentionHistory.value = [entry, ...attentionHistory.value].slice(0, limit);
|
|
66
|
+
if (!attentionHistoryOpen.value) {
|
|
67
|
+
attentionHistoryUnread.value = Math.min(99, attentionHistoryUnread.value + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setAttentionPulse(nodeIds: string[]): void {
|
|
72
|
+
attentionPulseNodeIds.value = new Set(nodeIds);
|
|
73
|
+
}
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { batch, computed, signal } from '@preact/signals';
|
|
2
|
+
import type { CanvasEdge, CanvasLayout, CanvasNodeState, ConnectionStatus, ViewportState } from '../types';
|
|
3
|
+
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
4
|
+
import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
|
|
5
|
+
|
|
6
|
+
function logCanvasStoreError(action: string, error: unknown): void {
|
|
7
|
+
console.error(`[canvas-store] ${action} failed`, error);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ── Core signals ──────────────────────────────────────────────
|
|
11
|
+
export const viewport = signal<ViewportState>({ x: 0, y: 0, scale: 1 });
|
|
12
|
+
export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
|
|
13
|
+
export const edges = signal<Map<string, CanvasEdge>>(new Map());
|
|
14
|
+
export const activeNodeId = signal<string | null>(null);
|
|
15
|
+
export const connectionStatus = signal<ConnectionStatus>('connecting');
|
|
16
|
+
export const sessionId = signal<string>('');
|
|
17
|
+
export const traceEnabled = signal<boolean>(false);
|
|
18
|
+
export const canvasTheme = signal<string>('dark');
|
|
19
|
+
export const hasInitialServerLayout = signal<boolean>(false);
|
|
20
|
+
|
|
21
|
+
// ── Expanded (focus) node ─────────────────────────────────────
|
|
22
|
+
// Only one node at a time can be in expanded/focus mode. When expanded, the
|
|
23
|
+
// node renders as a full-viewport overlay for deep editing/reading.
|
|
24
|
+
export const expandedNodeId = signal<string | null>(null);
|
|
25
|
+
|
|
26
|
+
// ── Pending edge connection (for context menu "Connect from") ─
|
|
27
|
+
export const pendingConnection = signal<{ from: string } | null>(null);
|
|
28
|
+
|
|
29
|
+
// ── Drag-to-connect (live edge preview) ─────────────────────
|
|
30
|
+
export const draggingEdge = signal<{
|
|
31
|
+
fromId: string;
|
|
32
|
+
fromX: number;
|
|
33
|
+
fromY: number;
|
|
34
|
+
cursorX: number;
|
|
35
|
+
cursorY: number;
|
|
36
|
+
} | null>(null);
|
|
37
|
+
|
|
38
|
+
// ── Spatial search highlight (command palette live results) ──
|
|
39
|
+
export const searchHighlightIds = signal<Set<string> | null>(null);
|
|
40
|
+
|
|
41
|
+
// ── Multi-node selection ──────────────────────────────────────
|
|
42
|
+
export const selectedNodeIds = signal<Set<string>>(new Set());
|
|
43
|
+
|
|
44
|
+
// ── Context pins (persistent context for agent queries) ──────
|
|
45
|
+
export const contextPinnedNodeIds = signal<Set<string>>(new Set());
|
|
46
|
+
|
|
47
|
+
export function getNeighborNodeIds(
|
|
48
|
+
nodeId: string | null,
|
|
49
|
+
edgeMap: Map<string, CanvasEdge>,
|
|
50
|
+
): Set<string> {
|
|
51
|
+
if (!nodeId) return new Set();
|
|
52
|
+
|
|
53
|
+
const neighborIds = new Set<string>();
|
|
54
|
+
for (const edge of edgeMap.values()) {
|
|
55
|
+
if (edge.from === nodeId) neighborIds.add(edge.to);
|
|
56
|
+
if (edge.to === nodeId) neighborIds.add(edge.from);
|
|
57
|
+
}
|
|
58
|
+
return neighborIds;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const activeNeighborNodeIds = computed(() => getNeighborNodeIds(activeNodeId.value, edges.value));
|
|
62
|
+
|
|
63
|
+
function filterNodeIdSet(ids: Set<string>, nodeMap: Map<string, CanvasNodeState>): Set<string> {
|
|
64
|
+
const next = new Set<string>();
|
|
65
|
+
for (const id of ids) {
|
|
66
|
+
if (nodeMap.has(id)) next.add(id);
|
|
67
|
+
}
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sameSetValues(left: Set<string>, right: Set<string>): boolean {
|
|
72
|
+
if (left.size !== right.size) return false;
|
|
73
|
+
for (const value of left) {
|
|
74
|
+
if (!right.has(value)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function toggleSelected(id: string): void {
|
|
80
|
+
const next = new Set(selectedNodeIds.value);
|
|
81
|
+
if (next.has(id)) {
|
|
82
|
+
next.delete(id);
|
|
83
|
+
} else {
|
|
84
|
+
next.add(id);
|
|
85
|
+
}
|
|
86
|
+
selectedNodeIds.value = next;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function selectNodes(ids: string[]): void {
|
|
90
|
+
selectedNodeIds.value = new Set(ids);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clearSelection(): void {
|
|
94
|
+
if (selectedNodeIds.value.size === 0) return;
|
|
95
|
+
selectedNodeIds.value = new Set();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getSelectedNodes(): CanvasNodeState[] {
|
|
99
|
+
const sel = selectedNodeIds.value;
|
|
100
|
+
if (sel.size === 0) return [];
|
|
101
|
+
return Array.from(sel)
|
|
102
|
+
.map((id) => nodes.value.get(id))
|
|
103
|
+
.filter((n): n is CanvasNodeState => n !== undefined);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Context pin actions ──────────────────────────────────────
|
|
107
|
+
export function toggleContextPin(id: string): void {
|
|
108
|
+
const next = new Set(contextPinnedNodeIds.value);
|
|
109
|
+
if (next.has(id)) {
|
|
110
|
+
next.delete(id);
|
|
111
|
+
} else {
|
|
112
|
+
next.add(id);
|
|
113
|
+
}
|
|
114
|
+
contextPinnedNodeIds.value = next;
|
|
115
|
+
persistLayout();
|
|
116
|
+
syncContextPinsToServer(next);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function addContextPins(ids: string[]): void {
|
|
120
|
+
const next = new Set(contextPinnedNodeIds.value);
|
|
121
|
+
for (const id of ids) next.add(id);
|
|
122
|
+
contextPinnedNodeIds.value = next;
|
|
123
|
+
persistLayout();
|
|
124
|
+
syncContextPinsToServer(next);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function clearContextPins(): void {
|
|
128
|
+
if (contextPinnedNodeIds.value.size === 0) return;
|
|
129
|
+
contextPinnedNodeIds.value = new Set();
|
|
130
|
+
persistLayout();
|
|
131
|
+
syncContextPinsToServer(new Set());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function replaceContextPinsFromServer(ids: string[]): void {
|
|
135
|
+
contextPinnedNodeIds.value = new Set(ids);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getContextPinnedNodes(): CanvasNodeState[] {
|
|
139
|
+
const pins = contextPinnedNodeIds.value;
|
|
140
|
+
if (pins.size === 0) return [];
|
|
141
|
+
return Array.from(pins)
|
|
142
|
+
.map((id) => nodes.value.get(id))
|
|
143
|
+
.filter((n): n is CanvasNodeState => n !== undefined);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function syncContextPinsToServer(ids: Set<string>): void {
|
|
147
|
+
fetch('/api/canvas/context-pins', {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify({ nodeIds: Array.from(ids) }),
|
|
151
|
+
}).catch((error) => {
|
|
152
|
+
logCanvasStoreError('syncContextPinsToServer', error);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let maxZ = 1;
|
|
157
|
+
|
|
158
|
+
// ── Node CRUD ─────────────────────────────────────────────────
|
|
159
|
+
export function addNode(node: CanvasNodeState): void {
|
|
160
|
+
batch(() => {
|
|
161
|
+
const next = new Map(nodes.value);
|
|
162
|
+
if (node.zIndex >= maxZ) maxZ = node.zIndex + 1;
|
|
163
|
+
next.set(node.id, node);
|
|
164
|
+
nodes.value = next;
|
|
165
|
+
activeNodeId.value = node.id;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function updateNode(id: string, patch: Partial<CanvasNodeState>): void {
|
|
170
|
+
const existing = nodes.value.get(id);
|
|
171
|
+
if (!existing) return;
|
|
172
|
+
const next = new Map(nodes.value);
|
|
173
|
+
if (existing.type === 'group' && patch.position) {
|
|
174
|
+
const deltaX = patch.position.x - existing.position.x;
|
|
175
|
+
const deltaY = patch.position.y - existing.position.y;
|
|
176
|
+
if (deltaX !== 0 || deltaY !== 0) {
|
|
177
|
+
const childIds = Array.isArray(existing.data.children)
|
|
178
|
+
? existing.data.children.filter((childId): childId is string => typeof childId === 'string')
|
|
179
|
+
: [];
|
|
180
|
+
for (const childId of childIds) {
|
|
181
|
+
const child = next.get(childId);
|
|
182
|
+
if (!child || child.type === 'group') continue;
|
|
183
|
+
next.set(childId, {
|
|
184
|
+
...child,
|
|
185
|
+
position: {
|
|
186
|
+
x: child.position.x + deltaX,
|
|
187
|
+
y: child.position.y + deltaY,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
next.set(id, { ...existing, ...patch });
|
|
194
|
+
nodes.value = next;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function updateNodeData(id: string, dataPatch: Record<string, unknown>): void {
|
|
198
|
+
const existing = nodes.value.get(id);
|
|
199
|
+
if (!existing) return;
|
|
200
|
+
updateNode(id, { data: { ...existing.data, ...dataPatch } });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function removeNode(id: string): void {
|
|
204
|
+
removeEdgesForNode(id);
|
|
205
|
+
const next = new Map(nodes.value);
|
|
206
|
+
next.delete(id);
|
|
207
|
+
nodes.value = next;
|
|
208
|
+
if (activeNodeId.value === id) activeNodeId.value = null;
|
|
209
|
+
if (expandedNodeId.value === id) expandedNodeId.value = null;
|
|
210
|
+
if (selectedNodeIds.value.has(id)) {
|
|
211
|
+
const sel = new Set(selectedNodeIds.value);
|
|
212
|
+
sel.delete(id);
|
|
213
|
+
selectedNodeIds.value = sel;
|
|
214
|
+
}
|
|
215
|
+
if (contextPinnedNodeIds.value.has(id)) {
|
|
216
|
+
const pins = new Set(contextPinnedNodeIds.value);
|
|
217
|
+
pins.delete(id);
|
|
218
|
+
contextPinnedNodeIds.value = pins;
|
|
219
|
+
syncContextPinsToServer(pins);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Edge CRUD ────────────────────────────────────────────────
|
|
224
|
+
export function addEdge(edge: CanvasEdge): void {
|
|
225
|
+
const next = new Map(edges.value);
|
|
226
|
+
next.set(edge.id, edge);
|
|
227
|
+
edges.value = next;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function removeEdge(id: string): void {
|
|
231
|
+
const next = new Map(edges.value);
|
|
232
|
+
next.delete(id);
|
|
233
|
+
edges.value = next;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function removeEdgesForNode(nodeId: string): void {
|
|
237
|
+
let changed = false;
|
|
238
|
+
const next = new Map(edges.value);
|
|
239
|
+
for (const [id, edge] of next) {
|
|
240
|
+
if (edge.from === nodeId || edge.to === nodeId) {
|
|
241
|
+
next.delete(id);
|
|
242
|
+
changed = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (changed) edges.value = next;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function resizeNode(id: string, size: { width: number; height: number }): void {
|
|
249
|
+
const existing = nodes.value.get(id);
|
|
250
|
+
if (!existing) return;
|
|
251
|
+
updateNode(id, { size });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function bringToFront(id: string): void {
|
|
255
|
+
const existing = nodes.value.get(id);
|
|
256
|
+
if (!existing) return;
|
|
257
|
+
updateNode(id, { zIndex: maxZ++ });
|
|
258
|
+
activeNodeId.value = id;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function toggleCollapsed(id: string): void {
|
|
262
|
+
const existing = nodes.value.get(id);
|
|
263
|
+
if (!existing) return;
|
|
264
|
+
updateNode(id, { collapsed: !existing.collapsed });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function dockNode(id: string, position: 'left' | 'right'): void {
|
|
268
|
+
const existing = nodes.value.get(id);
|
|
269
|
+
if (!existing) return;
|
|
270
|
+
updateNode(id, { dockPosition: position });
|
|
271
|
+
persistLayout();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function undockNode(id: string): void {
|
|
275
|
+
const existing = nodes.value.get(id);
|
|
276
|
+
if (!existing) return;
|
|
277
|
+
// Place at center of current viewport in world-space
|
|
278
|
+
const v = viewport.value;
|
|
279
|
+
const cx = (window.innerWidth / 2 - v.x) / v.scale;
|
|
280
|
+
const cy = (window.innerHeight / 2 - v.y) / v.scale;
|
|
281
|
+
updateNode(id, {
|
|
282
|
+
dockPosition: null,
|
|
283
|
+
position: { x: cx - existing.size.width / 2, y: cy - existing.size.height / 2 },
|
|
284
|
+
});
|
|
285
|
+
persistLayout();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Viewport ──────────────────────────────────────────────────
|
|
289
|
+
export function setViewport(v: Partial<ViewportState>): void {
|
|
290
|
+
viewport.value = { ...viewport.value, ...v };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function replaceViewport(next: ViewportState): void {
|
|
294
|
+
viewport.value = next;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function commitViewport(next: ViewportState): void {
|
|
298
|
+
viewport.value = next;
|
|
299
|
+
persistLayout();
|
|
300
|
+
void updateViewportFromClient(next);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState }): void {
|
|
304
|
+
const nextNodes = new Map<string, CanvasNodeState>();
|
|
305
|
+
let nextMaxZ = 1;
|
|
306
|
+
for (const node of layout.nodes) {
|
|
307
|
+
nextNodes.set(node.id, node);
|
|
308
|
+
if (node.zIndex >= nextMaxZ) nextMaxZ = node.zIndex + 1;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const edgeSource = layout.edges.filter(
|
|
312
|
+
(edge) => nextNodes.has(edge.from) && nextNodes.has(edge.to),
|
|
313
|
+
);
|
|
314
|
+
const nextEdges = new Map<string, CanvasEdge>();
|
|
315
|
+
for (const edge of edgeSource) {
|
|
316
|
+
nextEdges.set(edge.id, edge);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const nextActiveNodeId =
|
|
320
|
+
activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
|
|
321
|
+
const nextExpandedNodeId =
|
|
322
|
+
expandedNodeId.value !== null && nextNodes.has(expandedNodeId.value) ? expandedNodeId.value : null;
|
|
323
|
+
const nextSelectedNodeIds = filterNodeIdSet(selectedNodeIds.value, nextNodes);
|
|
324
|
+
const nextContextPinnedNodeIds = filterNodeIdSet(contextPinnedNodeIds.value, nextNodes);
|
|
325
|
+
|
|
326
|
+
batch(() => {
|
|
327
|
+
if (layout.viewport) {
|
|
328
|
+
viewport.value = layout.viewport;
|
|
329
|
+
}
|
|
330
|
+
maxZ = nextMaxZ;
|
|
331
|
+
nodes.value = nextNodes;
|
|
332
|
+
edges.value = nextEdges;
|
|
333
|
+
activeNodeId.value = nextActiveNodeId;
|
|
334
|
+
expandedNodeId.value = nextExpandedNodeId;
|
|
335
|
+
if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
|
|
336
|
+
selectedNodeIds.value = nextSelectedNodeIds;
|
|
337
|
+
}
|
|
338
|
+
if (!sameSetValues(contextPinnedNodeIds.value, nextContextPinnedNodeIds)) {
|
|
339
|
+
contextPinnedNodeIds.value = nextContextPinnedNodeIds;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Animated viewport transitions ────────────────────────────
|
|
345
|
+
let animationId: number | null = null;
|
|
346
|
+
|
|
347
|
+
function easeOutCubic(t: number): number {
|
|
348
|
+
return 1 - (1 - t) ** 3;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Smoothly animate the viewport to a target state.
|
|
353
|
+
* Cancels any in-flight animation. Direct manipulation (pan/zoom gestures)
|
|
354
|
+
* should use setViewport() instead for instant response.
|
|
355
|
+
*/
|
|
356
|
+
export function animateViewport(
|
|
357
|
+
target: ViewportState,
|
|
358
|
+
duration = 300,
|
|
359
|
+
): void {
|
|
360
|
+
if (animationId !== null) cancelAnimationFrame(animationId);
|
|
361
|
+
|
|
362
|
+
const from = { ...viewport.value };
|
|
363
|
+
const start = performance.now();
|
|
364
|
+
|
|
365
|
+
function tick(now: number) {
|
|
366
|
+
const elapsed = now - start;
|
|
367
|
+
const t = Math.min(1, elapsed / duration);
|
|
368
|
+
const e = easeOutCubic(t);
|
|
369
|
+
|
|
370
|
+
viewport.value = {
|
|
371
|
+
x: from.x + (target.x - from.x) * e,
|
|
372
|
+
y: from.y + (target.y - from.y) * e,
|
|
373
|
+
scale: from.scale + (target.scale - from.scale) * e,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (t < 1) {
|
|
377
|
+
animationId = requestAnimationFrame(tick);
|
|
378
|
+
} else {
|
|
379
|
+
animationId = null;
|
|
380
|
+
commitViewport(target);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
animationId = requestAnimationFrame(tick);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Cancel any in-flight viewport animation (e.g. when user starts dragging). */
|
|
388
|
+
export function cancelViewportAnimation(): void {
|
|
389
|
+
if (animationId !== null) {
|
|
390
|
+
cancelAnimationFrame(animationId);
|
|
391
|
+
animationId = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Persistence ───────────────────────────────────────────────
|
|
396
|
+
const STORAGE_KEY = 'pmx-canvas-layout';
|
|
397
|
+
|
|
398
|
+
export function persistLayout(): void {
|
|
399
|
+
try {
|
|
400
|
+
const allNodes = Array.from(nodes.value.values());
|
|
401
|
+
const nodeUpdates = allNodes.map((n) => ({
|
|
402
|
+
id: n.id,
|
|
403
|
+
position: n.position,
|
|
404
|
+
size: n.size,
|
|
405
|
+
collapsed: n.collapsed,
|
|
406
|
+
dockPosition: n.dockPosition,
|
|
407
|
+
}));
|
|
408
|
+
const layout = {
|
|
409
|
+
viewport: viewport.value,
|
|
410
|
+
nodes: allNodes.map((n) => ({
|
|
411
|
+
id: n.id,
|
|
412
|
+
type: n.type,
|
|
413
|
+
position: n.position,
|
|
414
|
+
size: n.size,
|
|
415
|
+
collapsed: n.collapsed,
|
|
416
|
+
pinned: n.pinned,
|
|
417
|
+
dockPosition: n.dockPosition,
|
|
418
|
+
})),
|
|
419
|
+
edges: Array.from(edges.value.values()).map((e) => ({
|
|
420
|
+
id: e.id,
|
|
421
|
+
from: e.from,
|
|
422
|
+
to: e.to,
|
|
423
|
+
type: e.type,
|
|
424
|
+
label: e.label,
|
|
425
|
+
style: e.style,
|
|
426
|
+
animated: e.animated,
|
|
427
|
+
})),
|
|
428
|
+
contextPinnedNodeIds: Array.from(contextPinnedNodeIds.value),
|
|
429
|
+
};
|
|
430
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
|
|
431
|
+
void pushCanvasUpdate(nodeUpdates);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
logCanvasStoreError('persistLayout', error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function restoreLayout(): Map<string, Partial<CanvasNodeState>> | null {
|
|
438
|
+
try {
|
|
439
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
440
|
+
if (!raw) return null;
|
|
441
|
+
const layout = JSON.parse(raw) as {
|
|
442
|
+
viewport?: ViewportState;
|
|
443
|
+
nodes?: Array<{
|
|
444
|
+
id?: string;
|
|
445
|
+
position?: CanvasNodeState['position'];
|
|
446
|
+
size?: CanvasNodeState['size'];
|
|
447
|
+
collapsed?: boolean;
|
|
448
|
+
pinned?: boolean;
|
|
449
|
+
dockPosition?: CanvasNodeState['dockPosition'];
|
|
450
|
+
}>;
|
|
451
|
+
};
|
|
452
|
+
const savedNodes = Array.isArray(layout.nodes) ? layout.nodes : [];
|
|
453
|
+
if (savedNodes.length === 0) return null;
|
|
454
|
+
|
|
455
|
+
const overrides = new Map<string, Partial<CanvasNodeState>>();
|
|
456
|
+
for (const node of savedNodes) {
|
|
457
|
+
if (typeof node.id !== 'string' || node.id.length === 0) continue;
|
|
458
|
+
overrides.set(node.id, {
|
|
459
|
+
...(node.position ? { position: node.position } : {}),
|
|
460
|
+
...(node.size ? { size: node.size } : {}),
|
|
461
|
+
...(node.collapsed !== undefined ? { collapsed: node.collapsed } : {}),
|
|
462
|
+
...(node.pinned !== undefined ? { pinned: node.pinned } : {}),
|
|
463
|
+
...(node.dockPosition !== undefined ? { dockPosition: node.dockPosition } : {}),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return overrides.size > 0 ? overrides : null;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
logCanvasStoreError('restoreLayout', error);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── Fit all ───────────────────────────────────────────────────
|
|
475
|
+
export function fitAll(containerW: number, containerH: number): void {
|
|
476
|
+
const all = Array.from(nodes.value.values());
|
|
477
|
+
if (all.length === 0) return;
|
|
478
|
+
|
|
479
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
480
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
481
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
482
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
483
|
+
for (const n of all) {
|
|
484
|
+
minX = Math.min(minX, n.position.x);
|
|
485
|
+
minY = Math.min(minY, n.position.y);
|
|
486
|
+
maxX = Math.max(maxX, n.position.x + n.size.width);
|
|
487
|
+
maxY = Math.max(maxY, n.position.y + n.size.height);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const PAD = 60;
|
|
491
|
+
const worldW = maxX - minX + PAD * 2;
|
|
492
|
+
const worldH = maxY - minY + PAD * 2;
|
|
493
|
+
const scale = Math.min(1, Math.min(containerW / worldW, containerH / worldH));
|
|
494
|
+
const cx = (minX + maxX) / 2;
|
|
495
|
+
const cy = (minY + maxY) / 2;
|
|
496
|
+
|
|
497
|
+
animateViewport({
|
|
498
|
+
x: containerW / 2 - cx * scale,
|
|
499
|
+
y: containerH / 2 - cy * scale,
|
|
500
|
+
scale,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Focus node ────────────────────────────────────────────────
|
|
505
|
+
export function focusNode(id: string): void {
|
|
506
|
+
const node = nodes.value.get(id);
|
|
507
|
+
if (!node) return;
|
|
508
|
+
const v = viewport.value;
|
|
509
|
+
const cx = node.position.x + node.size.width / 2;
|
|
510
|
+
const cy = node.position.y + node.size.height / 2;
|
|
511
|
+
animateViewport({
|
|
512
|
+
x: window.innerWidth / 2 - cx * v.scale,
|
|
513
|
+
y: window.innerHeight / 2 - cy * v.scale,
|
|
514
|
+
scale: v.scale,
|
|
515
|
+
});
|
|
516
|
+
bringToFront(id);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Cycle focus ───────────────────────────────────────────────
|
|
520
|
+
export function cycleActiveNode(direction: 1 | -1 = 1): void {
|
|
521
|
+
const all = Array.from(nodes.value.keys());
|
|
522
|
+
if (all.length === 0) return;
|
|
523
|
+
const currentIdx = activeNodeId.value ? all.indexOf(activeNodeId.value) : -1;
|
|
524
|
+
const nextIdx = (currentIdx + direction + all.length) % all.length;
|
|
525
|
+
const nextId = all[nextIdx];
|
|
526
|
+
bringToFront(nextId);
|
|
527
|
+
focusNode(nextId);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── Graph walking (arrow keys) ───────────────────────────────
|
|
531
|
+
export function walkGraph(direction: 'up' | 'down' | 'left' | 'right'): void {
|
|
532
|
+
const current = activeNodeId.value;
|
|
533
|
+
if (!current) return;
|
|
534
|
+
const currentNode = nodes.value.get(current);
|
|
535
|
+
if (!currentNode) return;
|
|
536
|
+
|
|
537
|
+
// Find all connected node IDs
|
|
538
|
+
const neighborIds = getNeighborNodeIds(current, edges.value);
|
|
539
|
+
if (neighborIds.size === 0) return;
|
|
540
|
+
|
|
541
|
+
// Center of current node
|
|
542
|
+
const cx = currentNode.position.x + currentNode.size.width / 2;
|
|
543
|
+
const cy = currentNode.position.y + currentNode.size.height / 2;
|
|
544
|
+
|
|
545
|
+
// Score each neighbor by directional alignment
|
|
546
|
+
let bestId: string | null = null;
|
|
547
|
+
let bestScore = -Infinity;
|
|
548
|
+
|
|
549
|
+
for (const nid of neighborIds) {
|
|
550
|
+
const n = nodes.value.get(nid);
|
|
551
|
+
if (!n) continue;
|
|
552
|
+
const nx = n.position.x + n.size.width / 2;
|
|
553
|
+
const ny = n.position.y + n.size.height / 2;
|
|
554
|
+
const dx = nx - cx;
|
|
555
|
+
const dy = ny - cy;
|
|
556
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
557
|
+
if (dist < 1) continue;
|
|
558
|
+
|
|
559
|
+
// Dot product with direction vector, normalized by distance
|
|
560
|
+
let dot: number;
|
|
561
|
+
switch (direction) {
|
|
562
|
+
case 'up': dot = -dy; break;
|
|
563
|
+
case 'down': dot = dy; break;
|
|
564
|
+
case 'left': dot = -dx; break;
|
|
565
|
+
case 'right': dot = dx; break;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Only consider nodes that are at least somewhat in the right direction
|
|
569
|
+
if (dot <= 0) continue;
|
|
570
|
+
|
|
571
|
+
// Score: favor alignment (dot/dist) with distance penalty
|
|
572
|
+
const score = dot / dist - dist * 0.001;
|
|
573
|
+
if (score > bestScore) {
|
|
574
|
+
bestScore = score;
|
|
575
|
+
bestId = nid;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (bestId) focusNode(bestId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Expand / Collapse (focus mode) ────────────────────────────
|
|
583
|
+
// Uses a fixed overlay (not world-space resize) so the original node
|
|
584
|
+
// position/size is preserved when the user collapses back.
|
|
585
|
+
export function expandNode(id: string): void {
|
|
586
|
+
const node = nodes.value.get(id);
|
|
587
|
+
if (!node) return;
|
|
588
|
+
bringToFront(id);
|
|
589
|
+
expandedNodeId.value = id;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function collapseExpandedNode(): void {
|
|
593
|
+
expandedNodeId.value = null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── Auto-arrange ──────────────────────────────────────────────
|
|
597
|
+
export function autoArrange(): void {
|
|
598
|
+
const result = computeAutoArrange(Array.from(nodes.value.values()), Array.from(edges.value.values()), 'grid');
|
|
599
|
+
if (result.nodePositions.size === 0 && result.groupBounds.size === 0) return;
|
|
600
|
+
|
|
601
|
+
batch(() => {
|
|
602
|
+
for (const [id, position] of result.nodePositions.entries()) {
|
|
603
|
+
updateNode(id, { position });
|
|
604
|
+
}
|
|
605
|
+
for (const [groupId, bounds] of result.groupBounds.entries()) {
|
|
606
|
+
updateNode(groupId, {
|
|
607
|
+
position: { x: bounds.x, y: bounds.y },
|
|
608
|
+
size: { width: bounds.width, height: bounds.height },
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
persistLayout();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function forceDirectedArrange(): void {
|
|
616
|
+
const result = computeAutoArrange(Array.from(nodes.value.values()), Array.from(edges.value.values()), 'graph');
|
|
617
|
+
if (result.nodePositions.size === 0 && result.groupBounds.size === 0) return;
|
|
618
|
+
|
|
619
|
+
batch(() => {
|
|
620
|
+
for (const [id, position] of result.nodePositions.entries()) {
|
|
621
|
+
updateNode(id, { position });
|
|
622
|
+
}
|
|
623
|
+
for (const [groupId, bounds] of result.groupBounds.entries()) {
|
|
624
|
+
updateNode(groupId, {
|
|
625
|
+
position: { x: bounds.x, y: bounds.y },
|
|
626
|
+
size: { width: bounds.width, height: bounds.height },
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
persistLayout();
|
|
631
|
+
}
|