@stephen-netu/design-system 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/README.md +66 -0
- package/package.json +130 -0
- package/src/b0-app-shell/clarification-overlay/ClarificationOverlay.svelte +221 -0
- package/src/b0-app-shell/clarification-overlay/index.ts +1 -0
- package/src/b0-app-shell/command-bar/CommandBar.svelte +565 -0
- package/src/b0-app-shell/index.ts +14 -0
- package/src/base.css +152 -0
- package/src/d0-data-viz/BarChart.svelte +236 -0
- package/src/d0-data-viz/CheckpointBar.svelte +574 -0
- package/src/d0-data-viz/CheckpointPill.svelte +297 -0
- package/src/d0-data-viz/MetricCard.svelte +159 -0
- package/src/d0-data-viz/SparkLine.svelte +66 -0
- package/src/d0-data-viz/StorageMap.svelte +182 -0
- package/src/d0-data-viz/Timeline.svelte +188 -0
- package/src/d0-data-viz/checkpoint-types.ts +162 -0
- package/src/d0-data-viz/d0-tokens.css +32 -0
- package/src/d0-data-viz/index.ts +58 -0
- package/src/d0-data-viz/storage-map-types.ts +142 -0
- package/src/d0-data-viz/theme-integration.ts +134 -0
- package/src/d0-data-viz/types.ts +53 -0
- package/src/demos/BspSizingInfographic.svelte +386 -0
- package/src/demos/BspZoneDemo.svelte +255 -0
- package/src/demos/ControlRoomDemo.svelte +570 -0
- package/src/demos/NodepadBlocksShowcase.svelte +189 -0
- package/src/demos/index.ts +7 -0
- package/src/editor/CodeEditor.svelte +188 -0
- package/src/editor/FindReplaceDialog.svelte +516 -0
- package/src/editor/MarkdownEditor.svelte +660 -0
- package/src/editor/index.ts +2 -0
- package/src/entity-graph-view/WorldView.svelte +171 -0
- package/src/entity-graph-view/index.ts +6 -0
- package/src/entity-graph-view/types.ts +364 -0
- package/src/epistemic/index.ts +2 -0
- package/src/epistemic/types.ts +134 -0
- package/src/epistemic/utils.ts +32 -0
- package/src/f0-forms/checkbox/Checkbox.svelte +158 -0
- package/src/f0-forms/field/FormField.svelte +91 -0
- package/src/f0-forms/index.ts +11 -0
- package/src/f0-forms/radio/RadioGroup.svelte +192 -0
- package/src/f0-forms/search/SearchBar.svelte +99 -0
- package/src/f0-forms/select/Select.svelte +198 -0
- package/src/f0-forms/textarea/TextArea.svelte +170 -0
- package/src/f0-forms/toggle/FormToggle.svelte +147 -0
- package/src/f0-forms/validation/validate.ts +47 -0
- package/src/fabric/ai/ChatPanel.svelte +648 -0
- package/src/fabric/ai/index.ts +11 -0
- package/src/fabric/data/index.ts +21 -0
- package/src/fabric/feedback/StatusBar.svelte +274 -0
- package/src/fabric/feedback/ToastContainer.svelte +122 -0
- package/src/fabric/feedback/index.ts +14 -0
- package/src/fabric/index.ts +37 -0
- package/src/fabric/input/CommandBar.svelte +79 -0
- package/src/fabric/input/KeyboardShortcuts.svelte +388 -0
- package/src/fabric/input/index.ts +12 -0
- package/src/fabric/layout/GlassBox.svelte +193 -0
- package/src/fabric/layout/KanbanBoard.svelte +158 -0
- package/src/fabric/layout/KanbanCard.svelte +226 -0
- package/src/fabric/layout/KanbanColumn.svelte +278 -0
- package/src/fabric/layout/index.ts +17 -0
- package/src/fabric/navigation/ActivityRail.svelte +271 -0
- package/src/fabric/navigation/index.ts +11 -0
- package/src/g0-geo/SovereignMap.svelte +149 -0
- package/src/g0-geo/index.ts +2 -0
- package/src/index.ts +68 -0
- package/src/l0-layout/block-writer/BlockConnectionLines.svelte +98 -0
- package/src/l0-layout/block-writer/BlockContent.svelte +213 -0
- package/src/l0-layout/block-writer/BlockHeader.svelte +230 -0
- package/src/l0-layout/block-writer/BlockItem.svelte +177 -0
- package/src/l0-layout/block-writer/BlockWriter.svelte +219 -0
- package/src/l0-layout/block-writer/block-writer-types.ts +63 -0
- package/src/l0-layout/block-writer/index.ts +24 -0
- package/src/l0-layout/block-writer/markdown.ts +110 -0
- package/src/l0-layout/chat-view/ChatViewLayout.svelte +61 -0
- package/src/l0-layout/epistemic/AllayDetailPanel.svelte +113 -0
- package/src/l0-layout/epistemic/AssayDetailPanel.svelte +164 -0
- package/src/l0-layout/epistemic/AvenueTrace.svelte +253 -0
- package/src/l0-layout/epistemic/KnowledgeDetailPanel.svelte +204 -0
- package/src/l0-layout/epistemic/ParadigmLens.svelte +170 -0
- package/src/l0-layout/epistemic/ProvenanceChain.svelte +164 -0
- package/src/l0-layout/epistemic/TrustView.svelte +195 -0
- package/src/l0-layout/epistemic/index.ts +7 -0
- package/src/l0-layout/grove/AccordCard.svelte +156 -0
- package/src/l0-layout/grove/GroveCard.svelte +197 -0
- package/src/l0-layout/grove/MemberBadge.svelte +96 -0
- package/src/l0-layout/grove/MemberList.svelte +59 -0
- package/src/l0-layout/grove/grove-types.ts +29 -0
- package/src/l0-layout/grove/index.ts +14 -0
- package/src/l0-layout/index.ts +45 -0
- package/src/l0-layout/kanban/KanbanBoard.svelte +71 -0
- package/src/l0-layout/kanban/KanbanCard.svelte +276 -0
- package/src/l0-layout/kanban/KanbanColumn.svelte +198 -0
- package/src/l0-layout/kanban/KanbanViewLayout.svelte +61 -0
- package/src/l0-layout/workspace/WorkspaceLayout.svelte +93 -0
- package/src/n0-node-graph/GraphEditor.svelte +342 -0
- package/src/n0-node-graph/GraphHistory.svelte +122 -0
- package/src/n0-node-graph/Node.svelte +269 -0
- package/src/n0-node-graph/NodeCanvas.svelte +187 -0
- package/src/n0-node-graph/NodeEdge.svelte +152 -0
- package/src/n0-node-graph/NodePalette.svelte +318 -0
- package/src/n0-node-graph/NodePort.svelte +128 -0
- package/src/n0-node-graph/index.ts +33 -0
- package/src/n0-node-graph/n0-tokens.css +29 -0
- package/src/n0-node-graph/node-port-types.ts +19 -0
- package/src/n0-node-graph/types.ts +96 -0
- package/src/n0-node-graph/validate.test.ts +722 -0
- package/src/n0-node-graph/validate.ts +371 -0
- package/src/n1-flow-canvas/FlowCanvas.svelte +202 -0
- package/src/n1-flow-canvas/graph-state.svelte.ts +95 -0
- package/src/n1-flow-canvas/index.ts +21 -0
- package/src/n1-flow-canvas/layout.ts +118 -0
- package/src/n1-flow-canvas/layout.worker.ts +128 -0
- package/src/n1-flow-canvas/types.ts +102 -0
- package/src/n1-flow-canvas/utils.ts +146 -0
- package/src/n2-manifold-disk/ManifoldDisk.svelte +314 -0
- package/src/n2-manifold-disk/index.ts +6 -0
- package/src/n2-manifold-disk/poincare.ts +184 -0
- package/src/n3-force-graph/ForceGraph.svelte +180 -0
- package/src/n3-force-graph/GraphTooltip.svelte +99 -0
- package/src/n3-force-graph/animated-edge.ts +107 -0
- package/src/n3-force-graph/health-node.ts +128 -0
- package/src/n3-force-graph/index.ts +31 -0
- package/src/n3-force-graph/types.ts +94 -0
- package/src/n4-adaptive-manifold/AdaptiveManifold.svelte +1165 -0
- package/src/n4-adaptive-manifold/DetailCard.svelte +212 -0
- package/src/n4-adaptive-manifold/SuggestionIndicator.svelte +91 -0
- package/src/n4-adaptive-manifold/classifier.ts +139 -0
- package/src/n4-adaptive-manifold/index.ts +45 -0
- package/src/n4-adaptive-manifold/n4-tokens.css +155 -0
- package/src/n4-adaptive-manifold/orchestrator.ts +301 -0
- package/src/n4-adaptive-manifold/phase-theme.ts +92 -0
- package/src/n4-adaptive-manifold/pinnacle-events.ts +135 -0
- package/src/n4-adaptive-manifold/providers/gravitational-provider.ts +191 -0
- package/src/n4-adaptive-manifold/providers/manifold-provider.ts +276 -0
- package/src/n4-adaptive-manifold/transition.ts +90 -0
- package/src/n4-adaptive-manifold/types.ts +313 -0
- package/src/p0-primitives/index.ts +6 -0
- package/src/p0-primitives/tokens/control-room.css +309 -0
- package/src/p0-primitives/tokens/index.ts +6 -0
- package/src/p0-primitives/tokens/tokens.css +140 -0
- package/src/p0-primitives/tokens/tokens.json +132 -0
- package/src/p0-primitives/types/index.ts +123 -0
- package/src/p0-primitives/types/plugin-types.ts +74 -0
- package/src/p0-primitives/utils/index.ts +39 -0
- package/src/platform/hud-protocol.ts +20 -0
- package/src/platform/index.ts +3 -0
- package/src/s0-state/create-store.ts +84 -0
- package/src/s0-state/derived.ts +41 -0
- package/src/s0-state/index.ts +6 -0
- package/src/showcase/Showcase.svelte +401 -0
- package/src/spatial/BspTilingCanvas.svelte +232 -0
- package/src/spatial/ForceCanvas.svelte +289 -0
- package/src/spatial/LodRenderer.svelte +334 -0
- package/src/spatial/SnapZoomCamera.svelte +185 -0
- package/src/spatial/ZoneTiler.svelte +311 -0
- package/src/spatial/camera-types.ts +45 -0
- package/src/spatial/content-proportional-bsp.ts +253 -0
- package/src/spatial/force-canvas-types.ts +102 -0
- package/src/spatial/index.ts +15 -0
- package/src/spatial/lod-types.ts +70 -0
- package/src/spatial/zone-tiler-types.ts +177 -0
- package/src/stores/vaultStore.svelte +14 -0
- package/src/t0-transport/connection.ts +34 -0
- package/src/t0-transport/error.ts +37 -0
- package/src/t0-transport/events.ts +50 -0
- package/src/t0-transport/index.ts +6 -0
- package/src/t0-transport/invoke.ts +30 -0
- package/src/tauri/index.ts +13 -0
- package/src/u0-primitives/allay-gauge/AllayGauge.svelte +90 -0
- package/src/u0-primitives/allay-gauge/index.ts +1 -0
- package/src/u0-primitives/animated-icon/AnimatedIcon.svelte +273 -0
- package/src/u0-primitives/animated-icon/README.md +53 -0
- package/src/u0-primitives/animated-icon/index.ts +31 -0
- package/src/u0-primitives/assay-badge/AssayBadge.svelte +121 -0
- package/src/u0-primitives/assay-badge/AssaySignalBar.svelte +83 -0
- package/src/u0-primitives/assay-badge/index.ts +2 -0
- package/src/u0-primitives/avatar/Avatar.svelte +177 -0
- package/src/u0-primitives/badge/Badge.svelte +149 -0
- package/src/u0-primitives/button/Button.svelte +304 -0
- package/src/u0-primitives/card/Card.svelte +182 -0
- package/src/u0-primitives/dropdown/Dropdown.svelte +305 -0
- package/src/u0-primitives/expert-badge/ExpertBadge.svelte +89 -0
- package/src/u0-primitives/expert-badge/index.ts +1 -0
- package/src/u0-primitives/index.ts +35 -0
- package/src/u0-primitives/input/Input.svelte +237 -0
- package/src/u0-primitives/modal/Modal.svelte +331 -0
- package/src/u0-primitives/modal/index.ts +1 -0
- package/src/u0-primitives/spinner/Spinner.svelte +129 -0
- package/src/u0-primitives/tabs/Tabs.svelte +225 -0
- package/src/u0-primitives/tabs/tabs-types.ts +11 -0
- package/src/u0-primitives/toggle/Toggle.svelte +161 -0
- package/src/u0-primitives/tooltip/Tooltip.svelte +234 -0
- package/src/v0-diagrams/DiagramPanel.svelte +68 -0
- package/src/v0-diagrams/MarkmapDiagram.svelte +56 -0
- package/src/v0-diagrams/MermaidDiagram.svelte +53 -0
- package/src/v0-diagrams/index.ts +4 -0
- package/src/v0-diagrams/types.ts +28 -0
- package/src/v0-diagrams/v0-tokens.css +6 -0
- package/src/vite-env.d.ts +7 -0
- package/src/x0-enchanted-blocks/ENCHANTED_BLOCKS_SPEC.md +457 -0
- package/src/x0-enchanted-blocks/components/EnchantedBlock.svelte +485 -0
- package/src/x0-enchanted-blocks/components/NodepadBlock.svelte +262 -0
- package/src/x0-enchanted-blocks/core/block-layout.ts +211 -0
- package/src/x0-enchanted-blocks/core/block-types.ts +227 -0
- package/src/x0-enchanted-blocks/index.ts +46 -0
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// N4 AdaptiveManifold — Unified canvas component driven by LayoutOrchestrator
|
|
3
|
+
// Renders nodes/edges with animated transitions between topologies.
|
|
4
|
+
// C-1 fix: resolves --n4-* tokens via getComputedStyle at mount.
|
|
5
|
+
|
|
6
|
+
import { onMount, onDestroy, untrack } from 'svelte';
|
|
7
|
+
import { fly } from 'svelte/transition';
|
|
8
|
+
import { Canvas, Layer } from 'svelte-canvas';
|
|
9
|
+
import type {
|
|
10
|
+
ManifoldSnapshot,
|
|
11
|
+
ManifoldNode,
|
|
12
|
+
LayoutAlgorithm,
|
|
13
|
+
ClassificationResult,
|
|
14
|
+
LayoutPreferences,
|
|
15
|
+
LayoutSuggestion,
|
|
16
|
+
NodeStyleRegistry,
|
|
17
|
+
NodeStatus,
|
|
18
|
+
ResolvedThemeColors,
|
|
19
|
+
NodeIndicatorState,
|
|
20
|
+
PhaseTheme,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
import { DEFAULT_LAYOUT_PREFERENCES } from './types.js';
|
|
23
|
+
import { resolvePhaseTheme, DEFAULT_PHASE_THEME } from './phase-theme.js';
|
|
24
|
+
import type { NodeLayout } from '../n1-flow-canvas/types.js';
|
|
25
|
+
import { LayoutOrchestrator } from './orchestrator.js';
|
|
26
|
+
import { ManifoldProvider } from './providers/manifold-provider.js';
|
|
27
|
+
import { GravitationalProvider } from './providers/gravitational-provider.js';
|
|
28
|
+
import { EDGE_REVEAL_STAGGER_MS } from '../n1-flow-canvas/types.js';
|
|
29
|
+
import DetailCard from './DetailCard.svelte';
|
|
30
|
+
|
|
31
|
+
// ── Props ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
snapshot: ManifoldSnapshot;
|
|
35
|
+
width?: number;
|
|
36
|
+
height?: number;
|
|
37
|
+
selectedId?: string | null;
|
|
38
|
+
preferences?: LayoutPreferences;
|
|
39
|
+
styleRegistry?: NodeStyleRegistry;
|
|
40
|
+
onalgorithmchange?: (algorithm: LayoutAlgorithm) => void;
|
|
41
|
+
onclassification?: (result: ClassificationResult) => void;
|
|
42
|
+
onsuggestion?: (suggestion: LayoutSuggestion | null) => void;
|
|
43
|
+
onselectnode?: (nodeId: string | null) => void;
|
|
44
|
+
indicatorStates?: Map<string, NodeIndicatorState>;
|
|
45
|
+
flowPhase?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let {
|
|
49
|
+
snapshot,
|
|
50
|
+
width = 800,
|
|
51
|
+
height = 600,
|
|
52
|
+
selectedId = null,
|
|
53
|
+
preferences = DEFAULT_LAYOUT_PREFERENCES,
|
|
54
|
+
styleRegistry = {},
|
|
55
|
+
onalgorithmchange,
|
|
56
|
+
onclassification,
|
|
57
|
+
onsuggestion,
|
|
58
|
+
onselectnode,
|
|
59
|
+
indicatorStates = new Map(),
|
|
60
|
+
flowPhase = 'seed',
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
// ── Orchestrator ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const orchestrator = new LayoutOrchestrator(
|
|
66
|
+
[new ManifoldProvider(), new GravitationalProvider()],
|
|
67
|
+
DEFAULT_LAYOUT_PREFERENCES,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Reactive orchestrator state
|
|
71
|
+
let activeAlgorithm: LayoutAlgorithm = $state('manifold');
|
|
72
|
+
let positions: Map<string, NodeLayout> = $state(new Map());
|
|
73
|
+
let bfsOrder: ReadonlyArray<string> = $state([]);
|
|
74
|
+
let transitioning: boolean = $state(false);
|
|
75
|
+
let lastClassification: ClassificationResult | null = $state(null);
|
|
76
|
+
let suggestion: LayoutSuggestion | null = $state(null);
|
|
77
|
+
let mounted = false;
|
|
78
|
+
|
|
79
|
+
// Edge reveal offsets
|
|
80
|
+
let edgeOffsets = $state<Map<string, number>>(new Map());
|
|
81
|
+
|
|
82
|
+
// Phase theme — resolved from CSS tokens on mount
|
|
83
|
+
let phaseTheme: PhaseTheme = $state(DEFAULT_PHASE_THEME);
|
|
84
|
+
|
|
85
|
+
// Ambient animation loop — tick drives svelte-canvas re-render each frame
|
|
86
|
+
let animationFrameId: number | null = null;
|
|
87
|
+
let animTick = $state(0);
|
|
88
|
+
|
|
89
|
+
// ── Theme color resolution (C-1 fix) ──────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
let containerEl: HTMLDivElement | undefined = $state(undefined);
|
|
92
|
+
let colors: ResolvedThemeColors = $state({
|
|
93
|
+
canvasBg: '#1a1a1a',
|
|
94
|
+
nodeActive: '#b87333',
|
|
95
|
+
nodeIdle: '#a09880',
|
|
96
|
+
nodeBlocked: '#f39c12',
|
|
97
|
+
nodeCompleted: '#a09880',
|
|
98
|
+
nodeCritical: '#c0392b',
|
|
99
|
+
nodeBg: '#222',
|
|
100
|
+
nodeBorder: 'rgba(184, 115, 51, 0.15)',
|
|
101
|
+
nodeText: '#e8e0d0',
|
|
102
|
+
edgeColor: 'rgba(184, 115, 51, 0.25)',
|
|
103
|
+
edgeActive: '#b87333',
|
|
104
|
+
pulseColor: '#b87333',
|
|
105
|
+
pulseCritical: '#c0392b',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function resolveTokens(): void {
|
|
109
|
+
if (!containerEl) return;
|
|
110
|
+
kindAccentCache.clear();
|
|
111
|
+
const cs = getComputedStyle(containerEl);
|
|
112
|
+
const get = (prop: string, fallback: string) => cs.getPropertyValue(prop).trim() || fallback;
|
|
113
|
+
colors = {
|
|
114
|
+
canvasBg: get('--n4-canvas-bg', '#1a1a1a'),
|
|
115
|
+
nodeActive: get('--n4-node-border-selected', '#b87333'),
|
|
116
|
+
nodeIdle: get('--n4-node-text-muted', '#a09880'),
|
|
117
|
+
nodeBlocked: get('--color-status-warning', '#f39c12'),
|
|
118
|
+
nodeCompleted: get('--n4-node-text-muted', '#a09880'),
|
|
119
|
+
nodeCritical: get('--color-status-error', '#c0392b'),
|
|
120
|
+
nodeBg: get('--n4-node-bg', '#222'),
|
|
121
|
+
nodeBorder: get('--n4-node-border', 'rgba(184, 115, 51, 0.15)'),
|
|
122
|
+
nodeText: get('--n4-node-text', '#e8e0d0'),
|
|
123
|
+
edgeColor: get('--n4-edge-color', 'rgba(184, 115, 51, 0.25)'),
|
|
124
|
+
edgeActive: get('--n4-edge-color-active', '#b87333'),
|
|
125
|
+
pulseColor: get('--color-accent', '#b87333'),
|
|
126
|
+
pulseCritical: get('--color-status-error', '#c0392b'),
|
|
127
|
+
};
|
|
128
|
+
phaseTheme = resolvePhaseTheme(containerEl);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Status → color mapping ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
// Alpha compositing helper — handles hex, rgb(), hsl() color formats.
|
|
134
|
+
// Hex suffix appending breaks when CSS tokens return rgb/hsl values.
|
|
135
|
+
function withAlpha(color: string, alpha: number): string {
|
|
136
|
+
const hex = Math.round(alpha * 255).toString(16).padStart(2, '0');
|
|
137
|
+
if (color.startsWith('#') && (color.length === 7 || color.length === 4)) return color + hex;
|
|
138
|
+
if (color.startsWith('rgb(')) return color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`);
|
|
139
|
+
if (color.startsWith('hsl(')) return color.replace('hsl(', 'hsla(').replace(')', `, ${alpha})`);
|
|
140
|
+
return color;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Schematic fills — opaque canvas background so traces don't bleed through node bodies.
|
|
144
|
+
// Looks visually transparent (matches canvas bg) but blocks geometry behind it.
|
|
145
|
+
function statusToFill(status: NodeStatus, phaseBg: string): string {
|
|
146
|
+
void status;
|
|
147
|
+
return phaseBg;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Stroke color by status — thinner and dimmer for idle/completed.
|
|
151
|
+
function statusToBorder(status: NodeStatus): string {
|
|
152
|
+
switch (status) {
|
|
153
|
+
case 'active': return withAlpha(colors.nodeActive, 0.85);
|
|
154
|
+
case 'idle': return withAlpha(colors.nodeActive, 0.22);
|
|
155
|
+
case 'blocked': return withAlpha(colors.nodeBlocked, 0.7);
|
|
156
|
+
case 'completed': return withAlpha(colors.nodeActive, 0.08);
|
|
157
|
+
case 'critical': return withAlpha(colors.nodeCritical, 0.8);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Stroke width: agents always prominent, tasks scaled by activity.
|
|
162
|
+
function strokeWidthFor(kind: string, status: NodeStatus, isSelected: boolean): number {
|
|
163
|
+
if (isSelected) return 1.5;
|
|
164
|
+
if (kind === 'agent') return 1.5;
|
|
165
|
+
if (status === 'active') return 1;
|
|
166
|
+
if (status === 'completed') return 0.5;
|
|
167
|
+
return 0.75;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
const unsubscribe = orchestrator.subscribe((state) => {
|
|
173
|
+
activeAlgorithm = state.activeAlgorithm;
|
|
174
|
+
positions = state.positions;
|
|
175
|
+
bfsOrder = state.bfsOrder;
|
|
176
|
+
transitioning = state.transitioning;
|
|
177
|
+
lastClassification = state.lastClassification;
|
|
178
|
+
suggestion = state.suggestion;
|
|
179
|
+
|
|
180
|
+
if (state.lastClassification) onclassification?.(state.lastClassification);
|
|
181
|
+
onalgorithmchange?.(state.activeAlgorithm);
|
|
182
|
+
onsuggestion?.(state.suggestion);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
onMount(() => {
|
|
186
|
+
mounted = true;
|
|
187
|
+
resolveTokens();
|
|
188
|
+
orchestrator.requestLayout(snapshot, width, height);
|
|
189
|
+
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
190
|
+
startAmbientLoop();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
onDestroy(() => {
|
|
195
|
+
mounted = false;
|
|
196
|
+
stopAmbientLoop();
|
|
197
|
+
unsubscribe();
|
|
198
|
+
orchestrator.destroy();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Deterministic phase offset per agent — avoids metronomic sync across agents. S-02 compliant.
|
|
202
|
+
function agentPulseOffset(agentId: string): number {
|
|
203
|
+
let h = 0;
|
|
204
|
+
for (let i = 0; i < agentId.length; i++) {
|
|
205
|
+
h = (Math.imul(31, h) + agentId.charCodeAt(i)) | 0;
|
|
206
|
+
}
|
|
207
|
+
return Math.abs(h) % 2000;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function startAmbientLoop() {
|
|
211
|
+
function tick() {
|
|
212
|
+
// Drive re-renders when nodes exist (ambient drift) or a layout transition is running.
|
|
213
|
+
// Zero-node canvases and fully idle post-transition states skip the increment.
|
|
214
|
+
if (snapshot.nodes.length > 0 || transitioning) animTick++;
|
|
215
|
+
animationFrameId = requestAnimationFrame(tick);
|
|
216
|
+
}
|
|
217
|
+
animationFrameId = requestAnimationFrame(tick);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stopAmbientLoop() {
|
|
221
|
+
if (animationFrameId !== null) {
|
|
222
|
+
cancelAnimationFrame(animationFrameId);
|
|
223
|
+
animationFrameId = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Re-layout when snapshot or dimensions change (debounced by orchestrator)
|
|
228
|
+
$effect(() => {
|
|
229
|
+
void snapshot;
|
|
230
|
+
void width;
|
|
231
|
+
void height;
|
|
232
|
+
if (mounted) {
|
|
233
|
+
orchestrator.requestLayout(snapshot, width, height);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Update preferences on orchestrator when they change
|
|
238
|
+
$effect(() => {
|
|
239
|
+
orchestrator.setPreferences(preferences);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Re-resolve tokens on theme changes (observe attribute mutations on root)
|
|
243
|
+
$effect(() => {
|
|
244
|
+
if (!containerEl) return;
|
|
245
|
+
const observer = new MutationObserver(() => resolveTokens());
|
|
246
|
+
const root = document.documentElement;
|
|
247
|
+
observer.observe(root, { attributes: true, attributeFilter: ['data-theme', 'class'] });
|
|
248
|
+
return () => observer.disconnect();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Schedule edge reveal after BFS order changes
|
|
252
|
+
$effect(() => {
|
|
253
|
+
void bfsOrder;
|
|
254
|
+
if (bfsOrder.length > 0) scheduleEdgeReveal(bfsOrder);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// On first layout, fit all nodes into view so separation is visible
|
|
258
|
+
let hasFitted = false;
|
|
259
|
+
$effect(() => {
|
|
260
|
+
if (!hasFitted && positions.size > 0 && width > 0 && height > 0) {
|
|
261
|
+
hasFitted = true;
|
|
262
|
+
// Use fit() to show all nodes with proper separation
|
|
263
|
+
fit();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ── Exported action methods (accessible via bind:this) ────────────────────
|
|
268
|
+
|
|
269
|
+
/** Accept the current layout suggestion — locks suggested algorithm. */
|
|
270
|
+
export function acceptSuggestion() { orchestrator.acceptSuggestion(); }
|
|
271
|
+
/** Dismiss the current layout suggestion — keep current algorithm. */
|
|
272
|
+
export function dismissSuggestion() { orchestrator.dismissSuggestion(); }
|
|
273
|
+
/** Pin the current layout (disable auto-classification). */
|
|
274
|
+
export function pinLayout() { orchestrator.pinLayout(); }
|
|
275
|
+
|
|
276
|
+
/** Zoom and pan to fit all current nodes into the viewport. */
|
|
277
|
+
export function fit() {
|
|
278
|
+
if (positions.size === 0) { zoom = 1; panX = 0; panY = 0; return; }
|
|
279
|
+
// Larger padding for horizontal task cards (140×32px)
|
|
280
|
+
const CARD_PAD_X = 100;
|
|
281
|
+
const CARD_PAD_Y = 40;
|
|
282
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
283
|
+
for (const p of positions.values()) {
|
|
284
|
+
const cx = p.x + p.width / 2;
|
|
285
|
+
const cy = p.y + p.height / 2;
|
|
286
|
+
minX = Math.min(minX, cx - CARD_PAD_X);
|
|
287
|
+
maxX = Math.max(maxX, cx + CARD_PAD_X);
|
|
288
|
+
minY = Math.min(minY, cy - CARD_PAD_Y);
|
|
289
|
+
maxY = Math.max(maxY, cy + CARD_PAD_Y);
|
|
290
|
+
}
|
|
291
|
+
const contentW = maxX - minX;
|
|
292
|
+
const contentH = maxY - minY;
|
|
293
|
+
const MARGIN = 60;
|
|
294
|
+
const newZoom = Math.max(ZOOM_MIN, Math.min(1, Math.min(
|
|
295
|
+
(width - MARGIN * 2) / contentW,
|
|
296
|
+
(height - MARGIN * 2) / contentH,
|
|
297
|
+
)));
|
|
298
|
+
panX = width / 2 - (minX + contentW / 2) * newZoom;
|
|
299
|
+
panY = height / 2 - (minY + contentH / 2) * newZoom;
|
|
300
|
+
zoom = newZoom;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Edge reveal ────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function scheduleEdgeReveal(order: ReadonlyArray<string>) {
|
|
306
|
+
// Preserve already-revealed edges — only animate newly added ones.
|
|
307
|
+
// IMPORTANT: read edgeOffsets via untrack() so this function can be called
|
|
308
|
+
// from a $effect without registering edgeOffsets as a dependency. Without
|
|
309
|
+
// untrack, the setTimeout callbacks that write edgeOffsets re-trigger the
|
|
310
|
+
// effect → infinite reactive loop → entire app becomes unresponsive.
|
|
311
|
+
const current = untrack(() => edgeOffsets);
|
|
312
|
+
const next = new Map<string, number>();
|
|
313
|
+
for (const e of snapshot.edges) {
|
|
314
|
+
next.set(e.id, current.get(e.id) ?? 0.0);
|
|
315
|
+
}
|
|
316
|
+
edgeOffsets = next;
|
|
317
|
+
|
|
318
|
+
const nodeIndex = new Map<string, number>();
|
|
319
|
+
order.forEach((id, i) => nodeIndex.set(id, i));
|
|
320
|
+
|
|
321
|
+
for (const edge of snapshot.edges) {
|
|
322
|
+
if ((current.get(edge.id) ?? 0) >= 1.0) continue; // already visible
|
|
323
|
+
const idx = nodeIndex.get(edge.source) ?? 0;
|
|
324
|
+
setTimeout(() => {
|
|
325
|
+
edgeOffsets = new Map(untrack(() => edgeOffsets)).set(edge.id, 1.0);
|
|
326
|
+
}, idx * EDGE_REVEAL_STAGGER_MS);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Zoom / pan state ──────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
let zoom: number = $state(1);
|
|
333
|
+
let panX: number = $state(0);
|
|
334
|
+
let panY: number = $state(0);
|
|
335
|
+
let isDragging = $state(false);
|
|
336
|
+
let dragLast = { x: 0, y: 0 };
|
|
337
|
+
let dragMoved = false; // distinguish click from drag
|
|
338
|
+
|
|
339
|
+
const ZOOM_MIN = 0.05;
|
|
340
|
+
const ZOOM_MAX = 4;
|
|
341
|
+
|
|
342
|
+
/** Convert a canvas-element pixel coordinate to world space. */
|
|
343
|
+
function screenToWorld(sx: number, sy: number): { x: number; y: number } {
|
|
344
|
+
return { x: (sx - panX) / zoom, y: (sy - panY) / zoom };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Convert world coords to canvas-element pixel coords (for overlay positioning). */
|
|
348
|
+
function worldToScreen(wx: number, wy: number): { x: number; y: number } {
|
|
349
|
+
return { x: wx * zoom + panX, y: wy * zoom + panY };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function handleWheel(event: WheelEvent) {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
355
|
+
const scaleX = width / rect.width;
|
|
356
|
+
const scaleY = height / rect.height;
|
|
357
|
+
const mx = (event.clientX - rect.left) * scaleX;
|
|
358
|
+
const my = (event.clientY - rect.top) * scaleY;
|
|
359
|
+
|
|
360
|
+
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
361
|
+
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom * factor));
|
|
362
|
+
|
|
363
|
+
// Zoom toward cursor: keep the world point under cursor fixed
|
|
364
|
+
panX = mx - (mx - panX) * (newZoom / zoom);
|
|
365
|
+
panY = my - (my - panY) * (newZoom / zoom);
|
|
366
|
+
zoom = newZoom;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function handleMouseDown(event: MouseEvent) {
|
|
370
|
+
if (event.button !== 0) return;
|
|
371
|
+
isDragging = true;
|
|
372
|
+
dragMoved = false;
|
|
373
|
+
dragLast = { x: event.clientX, y: event.clientY };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function handleMouseMove(event: MouseEvent) {
|
|
377
|
+
if (!isDragging) {
|
|
378
|
+
// Hover detection when not dragging
|
|
379
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
380
|
+
const scaleX = width / rect.width;
|
|
381
|
+
const scaleY = height / rect.height;
|
|
382
|
+
const sx = (event.clientX - rect.left) * scaleX;
|
|
383
|
+
const sy = (event.clientY - rect.top) * scaleY;
|
|
384
|
+
const prevHover = hoveredId;
|
|
385
|
+
hoveredId = hitNode(event);
|
|
386
|
+
if (hoveredId !== prevHover && hoveredId && !expandedId) {
|
|
387
|
+
// Hover started on a new node
|
|
388
|
+
onselectnode?.(hoveredId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (!isDragging) return;
|
|
392
|
+
const dx = event.clientX - dragLast.x;
|
|
393
|
+
const dy = event.clientY - dragLast.y;
|
|
394
|
+
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) dragMoved = true;
|
|
395
|
+
panX += dx;
|
|
396
|
+
panY += dy;
|
|
397
|
+
dragLast = { x: event.clientX, y: event.clientY };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function handleMouseUp() {
|
|
401
|
+
isDragging = false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function handleMouseLeave() {
|
|
405
|
+
isDragging = false;
|
|
406
|
+
hoveredId = null; // clear hover on mouse leave
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Canvas hit test ────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
function hitNode(event: MouseEvent): string | null {
|
|
412
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
413
|
+
const scaleX = width / rect.width;
|
|
414
|
+
const scaleY = height / rect.height;
|
|
415
|
+
const sx = (event.clientX - rect.left) * scaleX;
|
|
416
|
+
const sy = (event.clientY - rect.top) * scaleY;
|
|
417
|
+
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
|
418
|
+
|
|
419
|
+
// LOD-aware hit areas — match the visual size at current zoom level.
|
|
420
|
+
const LOD_OVERVIEW = 0.3;
|
|
421
|
+
const LOD_MID = 0.8;
|
|
422
|
+
const lodTier = zoom < LOD_OVERVIEW ? 'overview' : zoom < LOD_MID ? 'mid' : 'close';
|
|
423
|
+
|
|
424
|
+
for (const node of snapshot.nodes) {
|
|
425
|
+
const pos = positions.get(node.id);
|
|
426
|
+
if (!pos) continue;
|
|
427
|
+
const ncx = pos.x + pos.width / 2;
|
|
428
|
+
const ncy = pos.y + pos.height / 2;
|
|
429
|
+
|
|
430
|
+
let hitW: number, hitH: number;
|
|
431
|
+
if (lodTier === 'overview') {
|
|
432
|
+
// 8×8 pad + generous click buffer
|
|
433
|
+
hitW = 20; hitH = 20;
|
|
434
|
+
} else if (lodTier === 'mid') {
|
|
435
|
+
// 44×18 chip + buffer
|
|
436
|
+
hitW = 56; hitH = 28;
|
|
437
|
+
} else {
|
|
438
|
+
// Full flag extent. Tip anchors at node centroid; flag extends outward.
|
|
439
|
+
const FLAG_TIP_HIT = 8;
|
|
440
|
+
const FLAG_W_HIT = 110;
|
|
441
|
+
const FLAG_H_HIT = 36;
|
|
442
|
+
const TIP_BUFFER = 8;
|
|
443
|
+
|
|
444
|
+
if (wy < ncy - FLAG_H_HIT / 2 || wy > ncy + FLAG_H_HIT / 2) continue;
|
|
445
|
+
const flipLeft = (ncx * zoom + panX) > width * 0.55;
|
|
446
|
+
if (flipLeft) {
|
|
447
|
+
if (wx >= ncx - FLAG_TIP_HIT - FLAG_W_HIT && wx <= ncx + TIP_BUFFER) return node.id;
|
|
448
|
+
} else {
|
|
449
|
+
if (wx >= ncx - TIP_BUFFER && wx <= ncx + FLAG_TIP_HIT + FLAG_W_HIT) return node.id;
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (wx >= ncx - hitW / 2 && wx <= ncx + hitW / 2 &&
|
|
455
|
+
wy >= ncy - hitH / 2 && wy <= ncy + hitH / 2) {
|
|
456
|
+
return node.id;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Click-to-expand + hover-to-reveal detail cards ────────────────────────
|
|
463
|
+
|
|
464
|
+
let expandedId: string | null = $state(null); // pinned card
|
|
465
|
+
let hoveredId: string | null = $state(null); // hover card (unpinned)
|
|
466
|
+
|
|
467
|
+
let activeCardId = $derived(expandedId ?? hoveredId);
|
|
468
|
+
let activeCardNode = $derived(
|
|
469
|
+
activeCardId ? snapshot.nodes.find((n) => n.id === activeCardId) ?? null : null,
|
|
470
|
+
);
|
|
471
|
+
let isCardPinned = $derived(expandedId !== null);
|
|
472
|
+
|
|
473
|
+
// Card position: world coords → screen coords, clamped to container bounds.
|
|
474
|
+
let cardPosition = $derived.by(() => {
|
|
475
|
+
if (!activeCardId || !containerEl) return null;
|
|
476
|
+
const pos = positions.get(activeCardId);
|
|
477
|
+
if (!pos) return null;
|
|
478
|
+
const W = containerEl.offsetWidth || width;
|
|
479
|
+
const H = containerEl.offsetHeight || height;
|
|
480
|
+
const cardWidth = 220;
|
|
481
|
+
const gap = 12;
|
|
482
|
+
const nodeRight = worldToScreen(pos.x + pos.width, pos.y);
|
|
483
|
+
const nodeLeft = worldToScreen(pos.x, pos.y);
|
|
484
|
+
const rightSpace = W - nodeRight.x;
|
|
485
|
+
const rawX = rightSpace > cardWidth + gap ? nodeRight.x + gap : nodeLeft.x - cardWidth - gap;
|
|
486
|
+
const x = Math.max(8, Math.min(W - cardWidth - 8, rawX));
|
|
487
|
+
const y = Math.max(8, Math.min(H - 300, nodeRight.y - 20));
|
|
488
|
+
return { x, y, nodeCX: nodeRight.x - (nodeRight.x - nodeLeft.x) / 2, nodeCY: nodeRight.y };
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
function handleCanvasClick(event: MouseEvent) {
|
|
492
|
+
if (dragMoved) return; // suppress click after pan
|
|
493
|
+
const id = hitNode(event);
|
|
494
|
+
onselectnode?.(id);
|
|
495
|
+
if (id) {
|
|
496
|
+
// Click pins/unpins the card
|
|
497
|
+
expandedId = expandedId === id ? null : id;
|
|
498
|
+
} else {
|
|
499
|
+
expandedId = null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
const NODE_RADIUS = 4;
|
|
506
|
+
|
|
507
|
+
// ── Per-kind accent color cache (resolved once from CSS vars) ─────────────
|
|
508
|
+
|
|
509
|
+
let kindAccentCache: Map<string, string> = new Map();
|
|
510
|
+
|
|
511
|
+
function resolveKindAccent(accentVar: string): string {
|
|
512
|
+
const cached = kindAccentCache.get(accentVar);
|
|
513
|
+
if (cached) return cached;
|
|
514
|
+
if (!containerEl) return colors.nodeBorder;
|
|
515
|
+
const cs = getComputedStyle(containerEl);
|
|
516
|
+
const val = cs.getPropertyValue(accentVar).trim() || colors.nodeBorder;
|
|
517
|
+
kindAccentCache.set(accentVar, val);
|
|
518
|
+
return val;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── Shape drawing ─────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
function beginShape(
|
|
524
|
+
context: CanvasRenderingContext2D,
|
|
525
|
+
shape: string,
|
|
526
|
+
x: number,
|
|
527
|
+
y: number,
|
|
528
|
+
w: number,
|
|
529
|
+
h: number,
|
|
530
|
+
): void {
|
|
531
|
+
context.beginPath();
|
|
532
|
+
switch (shape) {
|
|
533
|
+
case 'rect':
|
|
534
|
+
context.rect(x, y, w, h);
|
|
535
|
+
break;
|
|
536
|
+
case 'circle': {
|
|
537
|
+
const r = Math.min(w, h) / 2;
|
|
538
|
+
context.arc(x + w / 2, y + h / 2, r, 0, Math.PI * 2);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case 'diamond': {
|
|
542
|
+
const cx = x + w / 2, cy = y + h / 2;
|
|
543
|
+
context.moveTo(cx, y);
|
|
544
|
+
context.lineTo(x + w, cy);
|
|
545
|
+
context.lineTo(cx, y + h);
|
|
546
|
+
context.lineTo(x, cy);
|
|
547
|
+
context.closePath();
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'hexagon': {
|
|
551
|
+
const cx = x + w / 2, cy = y + h / 2;
|
|
552
|
+
const rx = w / 2, ry = h / 2;
|
|
553
|
+
for (let i = 0; i < 6; i++) {
|
|
554
|
+
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
|
555
|
+
const px = cx + rx * Math.cos(angle);
|
|
556
|
+
const py = cy + ry * Math.sin(angle);
|
|
557
|
+
if (i === 0) context.moveTo(px, py);
|
|
558
|
+
else context.lineTo(px, py);
|
|
559
|
+
}
|
|
560
|
+
context.closePath();
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case 'pill': {
|
|
564
|
+
const r = h / 2;
|
|
565
|
+
if (w <= h) {
|
|
566
|
+
context.arc(x + w / 2, y + h / 2, r, 0, Math.PI * 2);
|
|
567
|
+
} else {
|
|
568
|
+
context.moveTo(x + r, y);
|
|
569
|
+
context.lineTo(x + w - r, y);
|
|
570
|
+
context.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2);
|
|
571
|
+
context.lineTo(x + r, y + h);
|
|
572
|
+
context.arc(x + r, y + r, r, Math.PI / 2, -Math.PI / 2);
|
|
573
|
+
context.closePath();
|
|
574
|
+
}
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
default: // 'rounded-rect' and unknown
|
|
578
|
+
context.roundRect(x, y, w, h, NODE_RADIUS);
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── Badge sizes — component body centered at node centroid ──────────────
|
|
584
|
+
// Badge is the drawn type indicator; interlocks with the flag tip at its edge.
|
|
585
|
+
// Indicator layer renders on top and does not affect hit testing.
|
|
586
|
+
function symbolSize(kind: string): { w: number; h: number } {
|
|
587
|
+
switch (kind) {
|
|
588
|
+
case 'task': return { w: 72, h: 48 };
|
|
589
|
+
case 'agent': return { w: 14, h: 14 };
|
|
590
|
+
case 'signal': return { w: 10, h: 10 };
|
|
591
|
+
default: return { w: 12, h: 8 };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function render({ context }: { context: CanvasRenderingContext2D }) {
|
|
596
|
+
void animTick; // subscribe to animation frames — causes re-render on each RAF tick
|
|
597
|
+
const now = performance.now();
|
|
598
|
+
context.clearRect(0, 0, width, height);
|
|
599
|
+
|
|
600
|
+
// ── Phase-driven background (no transform — always fills viewport) ─────
|
|
601
|
+
const phase = flowPhase as keyof typeof phaseTheme;
|
|
602
|
+
const phaseBg = phaseTheme[phase]?.background ?? colors.canvasBg;
|
|
603
|
+
context.fillStyle = phaseBg;
|
|
604
|
+
context.fillRect(0, 0, width, height);
|
|
605
|
+
|
|
606
|
+
// ── Apply zoom/pan transform for all world-space drawing ──────────────
|
|
607
|
+
context.save();
|
|
608
|
+
context.translate(panX, panY);
|
|
609
|
+
context.scale(zoom, zoom);
|
|
610
|
+
|
|
611
|
+
// ── Engineering dot grid — PCB substrate texture ──────────────────────
|
|
612
|
+
// Compute grid bounds in world space so we only draw visible dots
|
|
613
|
+
const worldLeft = -panX / zoom;
|
|
614
|
+
const worldTop = -panY / zoom;
|
|
615
|
+
const worldRight = (width - panX) / zoom;
|
|
616
|
+
const worldBottom = (height - panY) / zoom;
|
|
617
|
+
const GRID = 22;
|
|
618
|
+
const gridStartX = Math.floor(worldLeft / GRID) * GRID;
|
|
619
|
+
const gridStartY = Math.floor(worldTop / GRID) * GRID;
|
|
620
|
+
context.fillStyle = 'rgba(184, 115, 51, 0.055)';
|
|
621
|
+
for (let gx = gridStartX; gx < worldRight; gx += GRID) {
|
|
622
|
+
for (let gy = gridStartY; gy < worldBottom; gy += GRID) {
|
|
623
|
+
context.beginPath();
|
|
624
|
+
context.arc(gx, gy, 0.75, 0, Math.PI * 2);
|
|
625
|
+
context.fill();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── LOD tier selection ────────────────────────────────────────────────────
|
|
630
|
+
const LOD_OVERVIEW = 0.3;
|
|
631
|
+
const LOD_MID = 0.8;
|
|
632
|
+
const lodTier = zoom < LOD_OVERVIEW ? 'overview' : zoom < LOD_MID ? 'mid' : 'close';
|
|
633
|
+
|
|
634
|
+
// ── Edges: two-pass casing then line, straight only, no arrowheads ────
|
|
635
|
+
// Hidden at overview zoom — pads/dots don't need trace clutter.
|
|
636
|
+
if (lodTier !== 'overview') {
|
|
637
|
+
// Pass 1 — casing: wide stroke in background color creates crossing effect
|
|
638
|
+
for (const edge of snapshot.edges) {
|
|
639
|
+
const src = positions.get(edge.source);
|
|
640
|
+
const tgt = positions.get(edge.target);
|
|
641
|
+
if (!src || !tgt) continue;
|
|
642
|
+
if ((edgeOffsets.get(edge.id) ?? 0) <= 0) continue;
|
|
643
|
+
|
|
644
|
+
const sx = src.x + src.width / 2;
|
|
645
|
+
const sy = src.y + src.height / 2;
|
|
646
|
+
const tx = tgt.x + tgt.width / 2;
|
|
647
|
+
const ty = tgt.y + tgt.height / 2;
|
|
648
|
+
|
|
649
|
+
context.save();
|
|
650
|
+
context.strokeStyle = phaseBg;
|
|
651
|
+
context.lineWidth = 4;
|
|
652
|
+
context.setLineDash([]);
|
|
653
|
+
context.beginPath();
|
|
654
|
+
context.moveTo(sx, sy);
|
|
655
|
+
context.lineTo(tx, sy); // horizontal first
|
|
656
|
+
context.lineTo(tx, ty); // then vertical
|
|
657
|
+
context.stroke();
|
|
658
|
+
context.restore();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Pass 2 — actual trace: reveal via dash offset, straight line
|
|
662
|
+
for (const edge of snapshot.edges) {
|
|
663
|
+
const src = positions.get(edge.source);
|
|
664
|
+
const tgt = positions.get(edge.target);
|
|
665
|
+
if (!src || !tgt) continue;
|
|
666
|
+
|
|
667
|
+
const progress = edgeOffsets.get(edge.id) ?? 0;
|
|
668
|
+
if (progress <= 0) continue;
|
|
669
|
+
|
|
670
|
+
const sx = src.x + src.width / 2;
|
|
671
|
+
const sy = src.y + src.height / 2;
|
|
672
|
+
const tx = tgt.x + tgt.width / 2;
|
|
673
|
+
const ty = tgt.y + tgt.height / 2;
|
|
674
|
+
// Manhattan path: horizontal segment + vertical segment
|
|
675
|
+
const segH = Math.abs(tx - sx);
|
|
676
|
+
const segV = Math.abs(ty - sy);
|
|
677
|
+
const lineLen = segH + segV;
|
|
678
|
+
const revealLen = lineLen * progress;
|
|
679
|
+
|
|
680
|
+
// Vary opacity by edge strength so overlapping lines remain distinguishable
|
|
681
|
+
const edgeStrength = (edge.metadata as Record<string, unknown>)?.strength as number | undefined ?? 0.5;
|
|
682
|
+
context.save();
|
|
683
|
+
context.strokeStyle = progress >= 1.0
|
|
684
|
+
? `rgba(184, 115, 51, ${0.45 + edgeStrength * 0.35})`
|
|
685
|
+
: 'rgba(184, 115, 51, 0.30)';
|
|
686
|
+
context.lineWidth = 1.5;
|
|
687
|
+
// Permanently dashed when fully revealed — individual lines are traceable in dense graphs.
|
|
688
|
+
// During reveal animation keep a solid growing stroke, then settle to dashes.
|
|
689
|
+
context.setLineDash(progress >= 1.0 ? [6, 4] : (lineLen > 0 ? [revealLen, lineLen] : []));
|
|
690
|
+
context.lineCap = 'round';
|
|
691
|
+
context.beginPath();
|
|
692
|
+
context.moveTo(sx, sy);
|
|
693
|
+
context.lineTo(tx, sy); // horizontal first
|
|
694
|
+
context.lineTo(tx, ty); // then vertical
|
|
695
|
+
context.stroke();
|
|
696
|
+
context.restore();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
} // end lodTier !== 'overview' for edges
|
|
700
|
+
|
|
701
|
+
// ── Flag + Badge constants (close tier only) ──────────────────────────────
|
|
702
|
+
const FLAG_PAD_X = 8;
|
|
703
|
+
const FLAG_TIP = 7;
|
|
704
|
+
const FLAG_R = 3;
|
|
705
|
+
|
|
706
|
+
const PRIORITY_COLORS: Record<string, string> = {
|
|
707
|
+
p0: '#c0392b', p1: '#b87333', p2: '#c8a84b', p3: '#4a4438',
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// ── Node rendering: LOD-aware ─────────────────────────────────────────────
|
|
711
|
+
// Z-index: agents render last (on top), tasks first (below)
|
|
712
|
+
const nodesByLayer = [...snapshot.nodes].sort((a, b) => {
|
|
713
|
+
if (a.kind === 'agent' && b.kind !== 'agent') return 1;
|
|
714
|
+
if (a.kind !== 'agent' && b.kind === 'agent') return -1;
|
|
715
|
+
return 0;
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
for (const node of nodesByLayer) {
|
|
719
|
+
const pos = positions.get(node.id);
|
|
720
|
+
if (!pos) continue;
|
|
721
|
+
|
|
722
|
+
const isSelected = node.id === selectedId;
|
|
723
|
+
const isActive = node.status === 'active';
|
|
724
|
+
const ncx = pos.x + pos.width / 2;
|
|
725
|
+
const ncy = pos.y + pos.height / 2;
|
|
726
|
+
|
|
727
|
+
// ── Signals: always a small dot ─────────────────────────────────────
|
|
728
|
+
if (node.kind === 'signal') {
|
|
729
|
+
const sr = lodTier === 'overview' ? 3 : 5;
|
|
730
|
+
context.shadowColor = 'rgba(184, 115, 51, 0.4)';
|
|
731
|
+
context.shadowBlur = isSelected ? 10 : 4;
|
|
732
|
+
context.beginPath();
|
|
733
|
+
context.arc(ncx, ncy, sr, 0, Math.PI * 2);
|
|
734
|
+
context.fillStyle = phaseBg;
|
|
735
|
+
context.fill();
|
|
736
|
+
context.strokeStyle = isSelected ? colors.nodeActive : withAlpha(colors.nodeActive, 0.5);
|
|
737
|
+
context.lineWidth = isSelected ? 1.2 : 0.75;
|
|
738
|
+
context.stroke();
|
|
739
|
+
context.shadowBlur = 0;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
context.shadowBlur = 0;
|
|
744
|
+
context.textBaseline = 'middle';
|
|
745
|
+
|
|
746
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
747
|
+
// OVERVIEW TIER (zoom < 0.3): pads + dots
|
|
748
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
749
|
+
if (lodTier === 'overview') {
|
|
750
|
+
if (node.kind === 'agent') {
|
|
751
|
+
// Agent: glowing dot with velocity tail
|
|
752
|
+
const r = 6;
|
|
753
|
+
const indState = indicatorStates.get(node.id);
|
|
754
|
+
const isActiveLoop = indState && indState.loopPhase !== 'idle';
|
|
755
|
+
|
|
756
|
+
// Velocity tail (directional streak)
|
|
757
|
+
if (isActiveLoop) {
|
|
758
|
+
const tailLen = 12;
|
|
759
|
+
const angle = Math.atan2(ncy - (pos.y - 6), ncx - pos.x);
|
|
760
|
+
context.beginPath();
|
|
761
|
+
context.moveTo(ncx, ncy);
|
|
762
|
+
context.lineTo(ncx - Math.cos(angle) * tailLen, ncy - Math.sin(angle) * tailLen);
|
|
763
|
+
context.strokeStyle = `rgba(184, 115, 51, 0.5)`;
|
|
764
|
+
context.lineWidth = 2;
|
|
765
|
+
context.stroke();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
context.beginPath();
|
|
769
|
+
context.arc(ncx, ncy, r, 0, Math.PI * 2);
|
|
770
|
+
context.fillStyle = isActiveLoop ? 'rgba(184, 115, 51, 0.9)' : 'rgba(184, 115, 51, 0.4)';
|
|
771
|
+
context.fill();
|
|
772
|
+
if (isSelected) {
|
|
773
|
+
context.strokeStyle = colors.nodeActive;
|
|
774
|
+
context.lineWidth = 1.5;
|
|
775
|
+
context.stroke();
|
|
776
|
+
}
|
|
777
|
+
} else if (node.kind === 'task') {
|
|
778
|
+
// Task: 8×8 pad, color = priority
|
|
779
|
+
const priority = (node.metadata as Record<string, unknown>)?.priority as string | undefined;
|
|
780
|
+
const padSize = 8;
|
|
781
|
+
const pColor = priority ? PRIORITY_COLORS[priority] : '#a09880';
|
|
782
|
+
|
|
783
|
+
// Status affects alpha
|
|
784
|
+
let alpha = 0.7;
|
|
785
|
+
if (node.status === 'active') alpha = 0.9;
|
|
786
|
+
else if (node.status === 'blocked') alpha = 1.0;
|
|
787
|
+
else if (node.status === 'completed') alpha = 0.25;
|
|
788
|
+
|
|
789
|
+
context.fillStyle = withAlpha(pColor, alpha);
|
|
790
|
+
context.beginPath();
|
|
791
|
+
context.roundRect(ncx - padSize / 2, ncy - padSize / 2, padSize, padSize, 1.5);
|
|
792
|
+
context.fill();
|
|
793
|
+
|
|
794
|
+
// Blocked tasks get a red outline
|
|
795
|
+
if (node.status === 'blocked') {
|
|
796
|
+
context.strokeStyle = withAlpha('#c0392b', 0.8);
|
|
797
|
+
context.lineWidth = 1;
|
|
798
|
+
context.stroke();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (isSelected) {
|
|
802
|
+
context.strokeStyle = colors.nodeActive;
|
|
803
|
+
context.lineWidth = 1.5;
|
|
804
|
+
context.beginPath();
|
|
805
|
+
context.roundRect(ncx - padSize / 2 - 2, ncy - padSize / 2 - 2, padSize + 4, padSize + 4, 2.5);
|
|
806
|
+
context.stroke();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
continue; // done with overview tier
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
813
|
+
// MID TIER (0.3 ≤ zoom < 0.8): chips with wt:NNN
|
|
814
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
815
|
+
if (lodTier === 'mid') {
|
|
816
|
+
if (node.kind === 'agent') {
|
|
817
|
+
// Agent: hexagon symbol (shape distinguishes agents from task chips)
|
|
818
|
+
const hexW = 40, hexH = 36;
|
|
819
|
+
beginShape(context, 'hexagon', ncx - hexW / 2, ncy - hexH / 2, hexW, hexH);
|
|
820
|
+
context.fillStyle = 'rgba(28, 22, 14, 0.9)';
|
|
821
|
+
context.fill();
|
|
822
|
+
context.strokeStyle = withAlpha(colors.nodeActive, 0.6);
|
|
823
|
+
context.lineWidth = 1.5;
|
|
824
|
+
context.stroke();
|
|
825
|
+
} else if (node.kind === 'task') {
|
|
826
|
+
// Task: 40×18 chip showing wt:NNN
|
|
827
|
+
const wtId = (node.metadata as Record<string, unknown>)?.wt_id as string | undefined;
|
|
828
|
+
const priority = (node.metadata as Record<string, unknown>)?.priority as string | undefined;
|
|
829
|
+
const refDes = wtId
|
|
830
|
+
? wtId.replace('wt-sovra-', 'wt:s-').replace('wt-', 'wt:')
|
|
831
|
+
: 'wt:?';
|
|
832
|
+
|
|
833
|
+
const chipW = 44;
|
|
834
|
+
const chipH = 18;
|
|
835
|
+
const chipX = ncx - chipW / 2;
|
|
836
|
+
const chipY = ncy - chipH / 2;
|
|
837
|
+
|
|
838
|
+
// Priority determines border color
|
|
839
|
+
const pColor = priority ? PRIORITY_COLORS[priority] : null;
|
|
840
|
+
const borderColor = pColor ? withAlpha(pColor, 0.6) : withAlpha(colors.nodeActive, 0.2);
|
|
841
|
+
|
|
842
|
+
context.beginPath();
|
|
843
|
+
context.roundRect(chipX, chipY, chipW, chipH, 3);
|
|
844
|
+
context.fillStyle = isActive ? 'rgba(28, 22, 14, 0.85)' : 'rgba(20, 16, 12, 0.7)';
|
|
845
|
+
context.fill();
|
|
846
|
+
context.strokeStyle = borderColor;
|
|
847
|
+
context.lineWidth = node.status === 'blocked' ? 1.5 : 0.75;
|
|
848
|
+
context.stroke();
|
|
849
|
+
|
|
850
|
+
// Chevron accent on left edge (priority color)
|
|
851
|
+
if (pColor) {
|
|
852
|
+
context.beginPath();
|
|
853
|
+
context.moveTo(chipX, chipY);
|
|
854
|
+
context.lineTo(chipX + 3, chipY);
|
|
855
|
+
context.lineTo(chipX + 3, chipY + chipH);
|
|
856
|
+
context.lineTo(chipX, chipY + chipH);
|
|
857
|
+
context.closePath();
|
|
858
|
+
context.fillStyle = withAlpha(pColor, 0.7);
|
|
859
|
+
context.fill();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
context.textAlign = 'center';
|
|
863
|
+
context.font = '7px var(--font-mono, monospace)';
|
|
864
|
+
context.fillStyle = isActive ? 'rgba(232, 224, 208, 0.85)' : 'rgba(160, 152, 128, 0.65)';
|
|
865
|
+
context.fillText(refDes, ncx + 2, ncy + 1);
|
|
866
|
+
}
|
|
867
|
+
continue; // done with mid tier
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
871
|
+
// CLOSE TIER (zoom ≥ 0.8): Redesigned compact visualization
|
|
872
|
+
// Agents: Large hexagons, front and center
|
|
873
|
+
// Tasks: Compact vertical pills, orbit agents
|
|
874
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
875
|
+
const priority = (node.metadata as Record<string, unknown>)?.priority as string | undefined;
|
|
876
|
+
const pColor = priority ? PRIORITY_COLORS[priority] : null;
|
|
877
|
+
|
|
878
|
+
if (node.kind === 'agent') {
|
|
879
|
+
const indState = indicatorStates.get(node.id);
|
|
880
|
+
const phaseLine = (indState && indState.loopPhase !== 'idle')
|
|
881
|
+
? indState.loopPhase.toUpperCase() : null;
|
|
882
|
+
|
|
883
|
+
// Large prominent hexagon - 52×46px, agents are the stars
|
|
884
|
+
const HEX_W = 52, HEX_H = 46;
|
|
885
|
+
beginShape(context, 'hexagon', ncx - HEX_W / 2, ncy - HEX_H / 2, HEX_W, HEX_H);
|
|
886
|
+
context.fillStyle = 'rgba(24, 20, 16, 0.95)';
|
|
887
|
+
context.fill();
|
|
888
|
+
|
|
889
|
+
// Thick brass border
|
|
890
|
+
context.strokeStyle = withAlpha(colors.nodeActive, isSelected ? 1.0 : 0.75);
|
|
891
|
+
context.lineWidth = isSelected ? 2.5 : 1.5;
|
|
892
|
+
context.stroke();
|
|
893
|
+
|
|
894
|
+
// Priority-colored accent if available
|
|
895
|
+
if (pColor) {
|
|
896
|
+
beginShape(context, 'hexagon', ncx - HEX_W / 2 - 3, ncy - HEX_H / 2 - 3, HEX_W + 6, HEX_H + 6);
|
|
897
|
+
context.strokeStyle = withAlpha(pColor, 0.4);
|
|
898
|
+
context.lineWidth = 1;
|
|
899
|
+
context.stroke();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Label: centered in hexagon, small and clean
|
|
903
|
+
context.textAlign = 'center';
|
|
904
|
+
context.font = 'bold 10px var(--font-mono, monospace)';
|
|
905
|
+
context.fillStyle = withAlpha(colors.nodeActive, 0.95);
|
|
906
|
+
// Truncate to fit
|
|
907
|
+
const shortName = node.label.length > 10 ? node.label.slice(0, 9) + '…' : node.label;
|
|
908
|
+
context.fillText(shortName, ncx, ncy + 1);
|
|
909
|
+
|
|
910
|
+
// Phase indicator below the hexagon
|
|
911
|
+
if (phaseLine) {
|
|
912
|
+
context.font = '9px var(--font-mono, monospace)';
|
|
913
|
+
context.fillStyle = withAlpha(pColor ?? colors.nodeActive, 0.8);
|
|
914
|
+
context.fillText(phaseLine, ncx, ncy + HEX_H / 2 + 14);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
} else if (node.kind === 'task') {
|
|
918
|
+
// Compact task badge - ID + status, clean and scannable
|
|
919
|
+
const wtId = (node.metadata as Record<string, unknown>)?.wt_id as string | undefined;
|
|
920
|
+
const shortId = wtId ? wtId.replace('wt-sovra-', 's').replace('wt-', '') : '?';
|
|
921
|
+
const column = (node.metadata as Record<string, unknown>)?.flow_phase as string | undefined;
|
|
922
|
+
|
|
923
|
+
// Badge size: compact but readable
|
|
924
|
+
const badgeW = 72;
|
|
925
|
+
const badgeH = 48;
|
|
926
|
+
const px = ncx - badgeW / 2;
|
|
927
|
+
const py = ncy - badgeH / 2;
|
|
928
|
+
|
|
929
|
+
// Background with priority color subtle tint
|
|
930
|
+
context.beginPath();
|
|
931
|
+
context.roundRect(px, py, badgeW, badgeH, 6);
|
|
932
|
+
const baseColor = isActive ? '32, 28, 22' : '22, 18, 14';
|
|
933
|
+
context.fillStyle = pColor
|
|
934
|
+
? `rgba(${baseColor}, 0.95)`
|
|
935
|
+
: `rgba(${baseColor}, 0.9)`;
|
|
936
|
+
context.fill();
|
|
937
|
+
|
|
938
|
+
// Priority accent: top border
|
|
939
|
+
if (pColor) {
|
|
940
|
+
context.beginPath();
|
|
941
|
+
context.roundRect(px, py, badgeW, 3, [3, 3, 0, 0]);
|
|
942
|
+
context.fillStyle = withAlpha(pColor, 0.9);
|
|
943
|
+
context.fill();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Border
|
|
947
|
+
context.strokeStyle = pColor
|
|
948
|
+
? withAlpha(pColor, isActive ? 0.7 : 0.4)
|
|
949
|
+
: withAlpha(colors.nodeActive, isActive ? 0.5 : 0.25);
|
|
950
|
+
context.lineWidth = isActive ? 1.5 : 1;
|
|
951
|
+
context.stroke();
|
|
952
|
+
|
|
953
|
+
// Worktree ID - large and centered (the key identifier)
|
|
954
|
+
context.textAlign = 'center';
|
|
955
|
+
context.font = 'bold 14px var(--font-mono, monospace)';
|
|
956
|
+
context.fillStyle = withAlpha(pColor ?? colors.nodeActive, 0.95);
|
|
957
|
+
context.fillText(shortId, ncx, ncy + 2);
|
|
958
|
+
|
|
959
|
+
// Status label below
|
|
960
|
+
const statusLabel = column ? column.slice(0, 3) : 'IDLE';
|
|
961
|
+
context.font = '8px var(--font-mono, monospace)';
|
|
962
|
+
context.fillStyle = withAlpha(colors.nodeActive, 0.6);
|
|
963
|
+
context.fillText(statusLabel, ncx, py + badgeH - 6);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ── Indicator Layer: only at close zoom ───────────────────────────────────
|
|
968
|
+
if (lodTier === 'close') {
|
|
969
|
+
const phaseIntensity = phaseTheme[phase]?.indicatorIntensity ?? 1.0;
|
|
970
|
+
|
|
971
|
+
for (const node of snapshot.nodes) {
|
|
972
|
+
const pos = positions.get(node.id);
|
|
973
|
+
if (!pos) continue;
|
|
974
|
+
const indicator = indicatorStates.get(node.id);
|
|
975
|
+
if (!indicator || indicator.loopPhase === 'idle') continue;
|
|
976
|
+
|
|
977
|
+
const size = symbolSize(node.kind);
|
|
978
|
+
const ncx = pos.x + pos.width / 2;
|
|
979
|
+
const ncy = pos.y + pos.height / 2;
|
|
980
|
+
const baseRadius = Math.max(size.w, size.h) / 2;
|
|
981
|
+
|
|
982
|
+
// Hexagon geometry for all agent indicators
|
|
983
|
+
const HEX_IND_W = 44, HEX_IND_H = 40;
|
|
984
|
+
|
|
985
|
+
switch (indicator.loopPhase) {
|
|
986
|
+
case 'think': {
|
|
987
|
+
const offset = agentPulseOffset(node.id);
|
|
988
|
+
const pulsePeriodMs = (phaseTheme[phase]?.ambientPulseSpeed ?? 2) * 1000;
|
|
989
|
+
const pulsePhase = ((now + offset) % pulsePeriodMs) / pulsePeriodMs;
|
|
990
|
+
const alpha = phaseIntensity * (0.3 + 0.4 * Math.sin(pulsePhase * Math.PI * 2));
|
|
991
|
+
const P = 5;
|
|
992
|
+
beginShape(context, 'hexagon',
|
|
993
|
+
ncx - HEX_IND_W / 2 - P, ncy - HEX_IND_H / 2 - P,
|
|
994
|
+
HEX_IND_W + 2 * P, HEX_IND_H + 2 * P);
|
|
995
|
+
context.strokeStyle = `rgba(184, 115, 51, ${alpha})`;
|
|
996
|
+
context.lineWidth = 2;
|
|
997
|
+
context.stroke();
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
case 'perceive': {
|
|
1001
|
+
if (indicator.traceTargetId) {
|
|
1002
|
+
const targetPos = positions.get(indicator.traceTargetId);
|
|
1003
|
+
if (targetPos) {
|
|
1004
|
+
const tx = targetPos.x + targetPos.width / 2;
|
|
1005
|
+
const ty = targetPos.y + targetPos.height / 2;
|
|
1006
|
+
context.beginPath();
|
|
1007
|
+
context.moveTo(ncx, ncy);
|
|
1008
|
+
context.lineTo(tx, ty);
|
|
1009
|
+
context.strokeStyle = `rgba(184, 115, 51, ${0.6 * phaseIntensity})`;
|
|
1010
|
+
context.lineWidth = 1;
|
|
1011
|
+
context.setLineDash([4, 4]);
|
|
1012
|
+
context.stroke();
|
|
1013
|
+
context.setLineDash([]);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
case 'act': {
|
|
1019
|
+
// Emit particles from the hexagon edge facing the target
|
|
1020
|
+
const targetPos = indicator.traceTargetId ? positions.get(indicator.traceTargetId) : null;
|
|
1021
|
+
const targetCX = targetPos ? targetPos.x + targetPos.width / 2 : ncx + 999;
|
|
1022
|
+
const emitX = targetCX > ncx ? ncx + HEX_IND_W / 2 : ncx - HEX_IND_W / 2;
|
|
1023
|
+
for (let i = 0; i < 5; i++) {
|
|
1024
|
+
const t = ((now + i * 200) % 1000) / 1000;
|
|
1025
|
+
const alpha = phaseIntensity * (1 - t);
|
|
1026
|
+
const particleR = Math.max(0.5, 2 * (1 - t * 0.5));
|
|
1027
|
+
let px: number, py: number;
|
|
1028
|
+
if (targetPos) {
|
|
1029
|
+
const tx = targetPos.x + targetPos.width / 2;
|
|
1030
|
+
const ty = targetPos.y + targetPos.height / 2;
|
|
1031
|
+
const edgeDx = tx - emitX;
|
|
1032
|
+
const edgeDy = ty - ncy;
|
|
1033
|
+
const len = Math.sqrt(edgeDx * edgeDx + edgeDy * edgeDy) || 1;
|
|
1034
|
+
const perpX = -edgeDy / len;
|
|
1035
|
+
const perpY = edgeDx / len;
|
|
1036
|
+
const spread = (i - 2) * 4;
|
|
1037
|
+
px = emitX + (edgeDx / len) * t * 50 + perpX * spread;
|
|
1038
|
+
py = ncy + (edgeDy / len) * t * 50 + perpY * spread;
|
|
1039
|
+
} else {
|
|
1040
|
+
const angle = (i / 5) * Math.PI * 2;
|
|
1041
|
+
px = ncx + Math.cos(angle) * (HEX_IND_W / 2 + t * 30);
|
|
1042
|
+
py = ncy + Math.sin(angle) * (HEX_IND_H / 2 + t * 30);
|
|
1043
|
+
}
|
|
1044
|
+
context.beginPath();
|
|
1045
|
+
context.arc(px, py, particleR, 0, Math.PI * 2);
|
|
1046
|
+
context.fillStyle = `rgba(184, 115, 51, ${alpha})`;
|
|
1047
|
+
context.fill();
|
|
1048
|
+
}
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Task-state indicators — close zoom only
|
|
1055
|
+
// Draw subtle glow around claimed tasks and idle pulse
|
|
1056
|
+
const TASK_W = 72;
|
|
1057
|
+
const TASK_H = 48;
|
|
1058
|
+
|
|
1059
|
+
for (const node of snapshot.nodes) {
|
|
1060
|
+
if (node.kind !== 'task') continue;
|
|
1061
|
+
const pos = positions.get(node.id);
|
|
1062
|
+
if (!pos) continue;
|
|
1063
|
+
|
|
1064
|
+
const ncx = pos.x + pos.width / 2;
|
|
1065
|
+
const ncy = pos.y + pos.height / 2;
|
|
1066
|
+
const px = ncx - TASK_W / 2;
|
|
1067
|
+
const py = ncy - TASK_H / 2;
|
|
1068
|
+
|
|
1069
|
+
if (node.metadata?.claiming_agent) {
|
|
1070
|
+
// Claimed: solid brass outline
|
|
1071
|
+
context.beginPath();
|
|
1072
|
+
context.roundRect(px - 3, py - 3, TASK_W + 6, TASK_H + 6, 6);
|
|
1073
|
+
context.strokeStyle = `rgba(184, 115, 51, ${0.6 * phaseIntensity})`;
|
|
1074
|
+
context.lineWidth = 2;
|
|
1075
|
+
context.stroke();
|
|
1076
|
+
} else if (node.status === 'idle') {
|
|
1077
|
+
// Idle: gentle pulse
|
|
1078
|
+
const pulsePhase = (now % 3000) / 3000;
|
|
1079
|
+
const alpha = phaseIntensity * (0.1 + 0.2 * Math.sin(pulsePhase * Math.PI * 2));
|
|
1080
|
+
context.beginPath();
|
|
1081
|
+
context.roundRect(px - 2, py - 2, TASK_W + 4, TASK_H + 4, 5);
|
|
1082
|
+
context.strokeStyle = `rgba(184, 115, 51, ${alpha})`;
|
|
1083
|
+
context.lineWidth = 1.5;
|
|
1084
|
+
context.stroke();
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ── End zoom/pan transform ─────────────────────────────────────────────
|
|
1090
|
+
context.restore();
|
|
1091
|
+
}
|
|
1092
|
+
</script>
|
|
1093
|
+
|
|
1094
|
+
<div
|
|
1095
|
+
class="n4-manifold"
|
|
1096
|
+
bind:this={containerEl}
|
|
1097
|
+
role="button"
|
|
1098
|
+
tabindex="0"
|
|
1099
|
+
style:cursor={isDragging ? 'grabbing' : 'grab'}
|
|
1100
|
+
onclick={handleCanvasClick}
|
|
1101
|
+
onwheel={handleWheel}
|
|
1102
|
+
onmousedown={handleMouseDown}
|
|
1103
|
+
onmousemove={handleMouseMove}
|
|
1104
|
+
onmouseup={handleMouseUp}
|
|
1105
|
+
onmouseleave={handleMouseLeave}
|
|
1106
|
+
onkeydown={(e) => {
|
|
1107
|
+
if (e.key === 'Enter' || e.key === ' ') handleCanvasClick(e as any);
|
|
1108
|
+
if (e.key === 'Escape') expandedId = null;
|
|
1109
|
+
// Reset zoom/pan with 0 key
|
|
1110
|
+
if (e.key === '0') { zoom = 1; panX = 0; panY = 0; }
|
|
1111
|
+
}}
|
|
1112
|
+
>
|
|
1113
|
+
<Canvas {width} {height}>
|
|
1114
|
+
<Layer {render} />
|
|
1115
|
+
</Canvas>
|
|
1116
|
+
|
|
1117
|
+
{#if activeCardNode && cardPosition}
|
|
1118
|
+
<!-- Tether line from node to card -->
|
|
1119
|
+
<svg
|
|
1120
|
+
style:position="absolute"
|
|
1121
|
+
style:left="0"
|
|
1122
|
+
style:top="0"
|
|
1123
|
+
style:width="{width}px"
|
|
1124
|
+
style:height="{height}px"
|
|
1125
|
+
style:pointer-events="none"
|
|
1126
|
+
style:z-index="5"
|
|
1127
|
+
>
|
|
1128
|
+
<line
|
|
1129
|
+
x1={cardPosition.nodeCX}
|
|
1130
|
+
y1={cardPosition.nodeCY}
|
|
1131
|
+
x2={cardPosition.x + (cardPosition.x > cardPosition.nodeCX ? 0 : 220)}
|
|
1132
|
+
y2={cardPosition.y + 30}
|
|
1133
|
+
stroke="rgba(184, 115, 51, 0.4)"
|
|
1134
|
+
stroke-width="1"
|
|
1135
|
+
stroke-dasharray="4 4"
|
|
1136
|
+
/>
|
|
1137
|
+
</svg>
|
|
1138
|
+
<div
|
|
1139
|
+
transition:fly={{ x: 12, y: -4, duration: 200 }}
|
|
1140
|
+
style:position="absolute"
|
|
1141
|
+
style:left="{cardPosition.x}px"
|
|
1142
|
+
style:top="{cardPosition.y}px"
|
|
1143
|
+
style:z-index="10"
|
|
1144
|
+
>
|
|
1145
|
+
<DetailCard
|
|
1146
|
+
node={activeCardNode}
|
|
1147
|
+
pinned={isCardPinned}
|
|
1148
|
+
onclose={() => { expandedId = null; }}
|
|
1149
|
+
/>
|
|
1150
|
+
</div>
|
|
1151
|
+
{/if}
|
|
1152
|
+
</div>
|
|
1153
|
+
|
|
1154
|
+
<style>
|
|
1155
|
+
.n4-manifold {
|
|
1156
|
+
width: 100%;
|
|
1157
|
+
height: 100%;
|
|
1158
|
+
position: relative;
|
|
1159
|
+
outline: none;
|
|
1160
|
+
/* overflow: visible so DetailCard + shadow aren't clipped at canvas edge.
|
|
1161
|
+
The <canvas> element clips its own drawing; no wrapper clip needed. */
|
|
1162
|
+
overflow: visible;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
</style>
|