@preact/signals-devtools-ui 0.2.0 → 0.3.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/dist/styles.css CHANGED
@@ -153,7 +153,6 @@ body {
153
153
  border-radius: 8px;
154
154
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
155
155
  z-index: 1000;
156
- max-height: 400px;
157
156
  overflow-y: auto;
158
157
  }
159
158
 
@@ -346,6 +345,10 @@ body {
346
345
  margin-bottom: 4px;
347
346
  }
348
347
 
348
+ .signals-devtools {
349
+ position: relative;
350
+ }
351
+
349
352
  .update-count {
350
353
  display: inline-block;
351
354
  padding: 0 6px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preact/signals-devtools-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "DevTools UI components for @preact/signals",
6
6
  "keywords": [
@@ -44,7 +44,7 @@
44
44
  "README.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@preact/signals-devtools-adapter": "0.2.0"
47
+ "@preact/signals-devtools-adapter": "0.3.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^20.0.0",
@@ -57,6 +57,7 @@
57
57
  "@preact/signals": "2.6.1"
58
58
  },
59
59
  "publishConfig": {
60
+ "access": "public",
60
61
  "provenance": true
61
62
  },
62
63
  "scripts": {
@@ -23,7 +23,7 @@ export function DevToolsPanel({
23
23
  const activeTab = useSignal<"updates" | "graph">(initialTab);
24
24
 
25
25
  return (
26
- <div id="app" className="signals-devtools">
26
+ <div className="signals-devtools">
27
27
  {!hideHeader && <Header />}
28
28
 
29
29
  <SettingsPanel />
@@ -24,7 +24,6 @@ export function GraphVisualization() {
24
24
  const containerRef = useRef<HTMLDivElement>(null);
25
25
  const exportMenuRef = useRef<HTMLDivElement>(null);
26
26
 
27
- // Pan and zoom state using signals
28
27
  const panOffset = useSignal({ x: 0, y: 0 });
29
28
  const zoom = useSignal(1);
30
29
  const isPanning = useSignal(false);
@@ -51,7 +50,150 @@ export function GraphVisualization() {
51
50
  };
52
51
  }, []);
53
52
 
54
- // Build graph data from updates signal using a computed
53
+ // Improved topological sort with proper layering
54
+ const computeNodeLayers = (
55
+ nodes: Map<string, GraphNode>,
56
+ links: GraphLink[]
57
+ ): Map<string, number> => {
58
+ const layers = new Map<string, number>();
59
+ const adjacency = new Map<string, Set<string>>();
60
+ const inDegree = new Map<string, number>();
61
+
62
+ // Initialize adjacency list and in-degrees
63
+ nodes.forEach((_, id) => {
64
+ adjacency.set(id, new Set());
65
+ inDegree.set(id, 0);
66
+ });
67
+
68
+ // Build adjacency list and calculate in-degrees
69
+ links.forEach(link => {
70
+ adjacency.get(link.source)?.add(link.target);
71
+ inDegree.set(link.target, (inDegree.get(link.target) || 0) + 1);
72
+ });
73
+
74
+ // BFS-based layering (Kahn's algorithm with layers)
75
+ const queue: string[] = [];
76
+
77
+ // Start with nodes that have no dependencies (in-degree = 0)
78
+ nodes.forEach((_, id) => {
79
+ if (inDegree.get(id) === 0) {
80
+ queue.push(id);
81
+ layers.set(id, 0);
82
+ }
83
+ });
84
+
85
+ while (queue.length > 0) {
86
+ const nodeId = queue.shift()!;
87
+ const currentLayer = layers.get(nodeId)!;
88
+
89
+ // Process all nodes that depend on this node
90
+ adjacency.get(nodeId)?.forEach(targetId => {
91
+ // Update target's layer to be at least one more than current
92
+ const targetLayer = layers.get(targetId) ?? 0;
93
+ layers.set(targetId, Math.max(targetLayer, currentLayer + 1));
94
+
95
+ // Decrease in-degree and add to queue if all dependencies processed
96
+ const newInDegree = (inDegree.get(targetId) || 0) - 1;
97
+ inDegree.set(targetId, newInDegree);
98
+
99
+ if (newInDegree === 0) {
100
+ queue.push(targetId);
101
+ }
102
+ });
103
+ }
104
+
105
+ return layers;
106
+ };
107
+
108
+ // Reduce edge crossings within layers using barycenter heuristic
109
+ const minimizeCrossings = (
110
+ nodesByLayer: Map<number, GraphNode[]>,
111
+ links: GraphLink[]
112
+ ): void => {
113
+ const layers = Array.from(nodesByLayer.keys()).sort((a, b) => a - b);
114
+
115
+ // Build adjacency maps for quick lookup
116
+ const targets = new Map<string, string[]>();
117
+ const sources = new Map<string, string[]>();
118
+
119
+ links.forEach(link => {
120
+ if (!targets.has(link.source)) targets.set(link.source, []);
121
+ if (!sources.has(link.target)) sources.set(link.target, []);
122
+ targets.get(link.source)!.push(link.target);
123
+ sources.get(link.target)!.push(link.source);
124
+ });
125
+
126
+ // Create position maps for quick lookup
127
+ const nodePositions = new Map<string, number>();
128
+
129
+ // Multiple passes to reduce crossings
130
+ for (let pass = 0; pass < 4; pass++) {
131
+ // Forward pass: order based on predecessors
132
+ for (let i = 0; i < layers.length; i++) {
133
+ const layer = layers[i];
134
+ const nodes = nodesByLayer.get(layer)!;
135
+
136
+ // Update position map for current layer
137
+ nodes.forEach((node, idx) => {
138
+ nodePositions.set(node.id, idx);
139
+ });
140
+
141
+ if (i === 0) continue; // Skip first layer
142
+
143
+ // Calculate barycenter for each node based on predecessors
144
+ const barycenters = nodes.map(node => {
145
+ const preds = sources.get(node.id) || [];
146
+ if (preds.length === 0) return 0;
147
+
148
+ const sum = preds.reduce((acc, predId) => {
149
+ return acc + (nodePositions.get(predId) ?? 0);
150
+ }, 0);
151
+ return sum / preds.length;
152
+ });
153
+
154
+ // Sort nodes by barycenter
155
+ const sorted = nodes
156
+ .map((node, idx) => ({ node, barycenter: barycenters[idx] }))
157
+ .sort((a, b) => a.barycenter - b.barycenter)
158
+ .map(item => item.node);
159
+
160
+ nodesByLayer.set(layer, sorted);
161
+ }
162
+
163
+ // Backward pass: order based on successors
164
+ for (let i = layers.length - 1; i >= 0; i--) {
165
+ const layer = layers[i];
166
+ const nodes = nodesByLayer.get(layer)!;
167
+
168
+ // Update position map for current layer
169
+ nodes.forEach((node, idx) => {
170
+ nodePositions.set(node.id, idx);
171
+ });
172
+
173
+ if (i === layers.length - 1) continue; // Skip last layer
174
+
175
+ // Calculate barycenter for each node based on successors
176
+ const barycenters = nodes.map(node => {
177
+ const succs = targets.get(node.id) || [];
178
+ if (succs.length === 0) return 0;
179
+
180
+ const sum = succs.reduce((acc, succId) => {
181
+ return acc + (nodePositions.get(succId) ?? 0);
182
+ }, 0);
183
+ return sum / succs.length;
184
+ });
185
+
186
+ // Sort nodes by barycenter
187
+ const sorted = nodes
188
+ .map((node, idx) => ({ node, barycenter: barycenters[idx] }))
189
+ .sort((a, b) => a.barycenter - b.barycenter)
190
+ .map(item => item.node);
191
+
192
+ nodesByLayer.set(layer, sorted);
193
+ }
194
+ }
195
+ };
196
+
55
197
  const graphData = useComputed<GraphData>(() => {
56
198
  const rawUpdates = updates.value;
57
199
  const disposed = disposedSignalIds.value;
@@ -62,19 +204,15 @@ export function GraphVisualization() {
62
204
  const nodes = new Map<string, GraphNode>();
63
205
  const links = new Map<string, GraphLink>();
64
206
 
65
- // Process updates to build graph structure
66
207
  const signalUpdates = rawUpdates.filter(
67
208
  update => update.type !== "divider"
68
209
  ) as SignalUpdate[];
69
210
 
70
211
  for (const update of signalUpdates) {
71
212
  if (!update.signalId) continue;
72
-
73
- // Skip disposed signals unless showDisposed is enabled
74
213
  if (!showDisposed && disposed.has(update.signalId)) continue;
75
214
 
76
215
  const type: "signal" | "computed" | "effect" = update.signalType;
77
- const currentDepth = update.depth || 0;
78
216
 
79
217
  if (!nodes.has(update.signalId)) {
80
218
  nodes.set(update.signalId, {
@@ -83,12 +221,35 @@ export function GraphVisualization() {
83
221
  type,
84
222
  x: 0,
85
223
  y: 0,
86
- depth: currentDepth,
224
+ depth: 0, // Will be recalculated
87
225
  });
88
226
  }
89
227
 
90
- if (update.subscribedTo) {
91
- // Also skip links to/from disposed signals
228
+ if (update.allDependencies && update.allDependencies.length > 0) {
229
+ for (const dep of update.allDependencies) {
230
+ const sourceDisposed = !showDisposed && disposed.has(dep.id);
231
+ if (sourceDisposed) continue;
232
+
233
+ if (!nodes.has(dep.id)) {
234
+ nodes.set(dep.id, {
235
+ id: dep.id,
236
+ name: dep.name,
237
+ type: dep.type,
238
+ x: 0,
239
+ y: 0,
240
+ depth: 0,
241
+ });
242
+ }
243
+
244
+ const linkKey = `${dep.id}->${update.signalId}`;
245
+ if (!links.has(linkKey)) {
246
+ links.set(linkKey, {
247
+ source: dep.id,
248
+ target: update.signalId,
249
+ });
250
+ }
251
+ }
252
+ } else if (update.subscribedTo) {
92
253
  const sourceDisposed =
93
254
  !showDisposed && disposed.has(update.subscribedTo);
94
255
  if (sourceDisposed) continue;
@@ -103,41 +264,50 @@ export function GraphVisualization() {
103
264
  }
104
265
  }
105
266
 
106
- // Simple depth-based layout
107
- const allNodes = Array.from(nodes.values());
108
- const nodeSpacing = 120;
109
- const depthSpacing = 250;
110
- const startX = 100;
111
- const startY = 80;
267
+ const allLinks = Array.from(links.values());
268
+
269
+ // Compute proper layers using topological sort
270
+ const nodeLayers = computeNodeLayers(nodes, allLinks);
112
271
 
113
- // Group nodes by depth
114
- const nodesByDepth = new Map<number, GraphNode[]>();
115
- allNodes.forEach(node => {
116
- if (!nodesByDepth.has(node.depth)) {
117
- nodesByDepth.set(node.depth, []);
272
+ // Update node depths based on computed layers
273
+ nodes.forEach((node, id) => {
274
+ node.depth = nodeLayers.get(id) ?? 0;
275
+ });
276
+
277
+ // Group nodes by layer
278
+ const nodesByLayer = new Map<number, GraphNode[]>();
279
+ nodes.forEach(node => {
280
+ if (!nodesByLayer.has(node.depth)) {
281
+ nodesByLayer.set(node.depth, []);
118
282
  }
119
- nodesByDepth.get(node.depth)!.push(node);
283
+ nodesByLayer.get(node.depth)!.push(node);
120
284
  });
121
285
 
122
- // Layout nodes by depth, centering each depth level vertically
123
- const maxDepth = Math.max(...allNodes.map(n => n.depth));
124
- nodesByDepth.forEach((depthNodes, depth) => {
125
- const depthHeight = (depthNodes.length - 1) * nodeSpacing;
126
- const depthStartY = startY + maxDepth * 100 - depthHeight / 2;
286
+ // Minimize edge crossings
287
+ minimizeCrossings(nodesByLayer, allLinks);
288
+
289
+ // Layout nodes with proper spacing
290
+ const nodeSpacing = 120;
291
+ const layerSpacing = 250;
292
+ const startX = 100;
293
+ const startY = 80;
294
+
295
+ nodesByLayer.forEach((layerNodes, layer) => {
296
+ const layerHeight = (layerNodes.length - 1) * nodeSpacing;
297
+ const layerStartY = startY - layerHeight / 2;
127
298
 
128
- depthNodes.forEach((node, index) => {
129
- node.x = startX + depth * depthSpacing;
130
- node.y = depthStartY + index * nodeSpacing;
299
+ layerNodes.forEach((node, index) => {
300
+ node.x = startX + layer * layerSpacing;
301
+ node.y = layerStartY + index * nodeSpacing + nodesByLayer.size * 50;
131
302
  });
132
303
  });
133
304
 
134
305
  return {
135
- nodes: allNodes,
136
- links: Array.from(links.values()),
306
+ nodes: Array.from(nodes.values()),
307
+ links: allLinks,
137
308
  };
138
309
  });
139
310
 
140
- // Mouse event handlers for panning
141
311
  const handleMouseDown = (e: MouseEvent) => {
142
312
  if (e.button !== 0) return;
143
313
  isPanning.value = true;
@@ -169,7 +339,6 @@ export function GraphVisualization() {
169
339
  const mouseX = e.clientX - rect.left;
170
340
  const mouseY = e.clientY - rect.top;
171
341
 
172
- // Smoother zoom: smaller delta for less aggressive scrolling
173
342
  const delta = e.deltaY > 0 ? 0.96 : 1.04;
174
343
  const newZoom = Math.min(Math.max(0.1, zoom.value * delta), 5);
175
344
 
@@ -194,16 +363,14 @@ export function GraphVisualization() {
194
363
  const mermaidIdPattern = /[^a-zA-Z0-9]/g;
195
364
  const computeMermaidId = (id: string) => id.replace(mermaidIdPattern, "_");
196
365
 
197
- // Calculate node radius based on name length
198
366
  const getNodeRadius = (node: GraphNode) => {
199
367
  const baseRadius = 30;
200
- const charWidth = 6.5; // Approximate width per character
368
+ const charWidth = 6.5;
201
369
  const padding = 16;
202
370
  const textWidth = node.name.length * charWidth + padding;
203
371
  return Math.max(baseRadius, Math.min(textWidth / 2, 70));
204
372
  };
205
373
 
206
- // Handle node hover for tooltip
207
374
  const handleNodeMouseEnter = (node: GraphNode, e: MouseEvent) => {
208
375
  hoveredNode.value = node;
209
376
  const container = containerRef.current;
@@ -344,7 +511,7 @@ export function GraphVisualization() {
344
511
  const targetRadius = getNodeRadius(targetNode);
345
512
  const sourceX = sourceNode.x + sourceRadius;
346
513
  const sourceY = sourceNode.y;
347
- const targetX = targetNode.x - targetRadius - 8; // Extra space for arrow
514
+ const targetX = targetNode.x - targetRadius - 8;
348
515
  const targetY = targetNode.y;
349
516
 
350
517
  const midX = sourceX + (targetX - sourceX) * 0.5;
@@ -365,7 +532,6 @@ export function GraphVisualization() {
365
532
  <g className="nodes">
366
533
  {graphData.value.nodes.map(node => {
367
534
  const radius = getNodeRadius(node);
368
- // Calculate max chars based on radius
369
535
  const maxChars = Math.floor((radius * 2 - 16) / 6.5);
370
536
  const displayName =
371
537
  node.name.length > maxChars
package/src/context.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  ConnectionStatusType,
6
6
  Settings,
7
7
  SignalDisposed,
8
+ DependencyInfo,
8
9
  } from "@preact/signals-devtools-adapter";
9
10
 
10
11
  export interface DevToolsContext {
@@ -74,6 +75,8 @@ export interface SignalUpdate {
74
75
  receivedAt: number;
75
76
  depth?: number;
76
77
  subscribedTo?: string;
78
+ /** All dependencies this computed/effect currently depends on (with rich info) */
79
+ allDependencies?: DependencyInfo[];
77
80
  }
78
81
 
79
82
  export type Divider = { type: "divider" };
package/src/styles.css CHANGED
@@ -153,7 +153,6 @@ body {
153
153
  border-radius: 8px;
154
154
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
155
155
  z-index: 1000;
156
- max-height: 400px;
157
156
  overflow-y: auto;
158
157
  }
159
158
 
@@ -346,6 +345,10 @@ body {
346
345
  margin-bottom: 4px;
347
346
  }
348
347
 
348
+ .signals-devtools {
349
+ position: relative;
350
+ }
351
+
349
352
  .update-count {
350
353
  display: inline-block;
351
354
  padding: 0 6px;
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  export type {
5
5
  SignalUpdate,
6
6
  SignalDisposed,
7
+ DependencyInfo,
7
8
  Settings,
8
9
  ConnectionStatus,
9
10
  ConnectionStatusType,