anywidget-graph 0.2.9__tar.gz → 0.2.10__tar.gz

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.
Files changed (45) hide show
  1. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/CHANGELOG.md +15 -0
  2. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/PKG-INFO +1 -1
  3. anywidget_graph-0.2.10/examples/test_hierarchy.py +99 -0
  4. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/pyproject.toml +1 -1
  5. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/__init__.py +1 -0
  6. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/index.js +118 -24
  7. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/schema.js +6 -3
  8. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/widget.py +2 -2
  9. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/tests/test_graph.py +1 -1
  10. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/.github/workflows/ci.yml +0 -0
  11. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/.github/workflows/pypi.yml +0 -0
  12. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/.gitignore +0 -0
  13. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/.python-version +0 -0
  14. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/LICENSE +0 -0
  15. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/README.md +0 -0
  16. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/examples/demo.py +0 -0
  17. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/examples/test_large_graph.py +0 -0
  18. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/__init__.py +0 -0
  19. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/backends/__init__.py +0 -0
  20. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/backends/arango.py +0 -0
  21. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/backends/cosmosdb.py +0 -0
  22. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/backends/grafeo.py +0 -0
  23. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/backends/ladybug.py +0 -0
  24. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/backends/neo4j.py +0 -0
  25. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/__init__.py +0 -0
  26. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/base.py +0 -0
  27. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/common.py +0 -0
  28. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/cypher.py +0 -0
  29. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/gql.py +0 -0
  30. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/graphql.py +0 -0
  31. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/gremlin.py +0 -0
  32. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/converters/sparql.py +0 -0
  33. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/demo.py +0 -0
  34. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/py.typed +0 -0
  35. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/grafeo-embed.js +0 -0
  36. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/grafeo.js +0 -0
  37. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/icons.js +0 -0
  38. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/neo4j.js +0 -0
  39. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/properties.js +0 -0
  40. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/results.js +0 -0
  41. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/settings.js +0 -0
  42. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/styles.css +0 -0
  43. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/src/anywidget_graph/ui/toolbar.js +0 -0
  44. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/tests/__init__.py +0 -0
  45. {anywidget_graph-0.2.9 → anywidget_graph-0.2.10}/uv.lock +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.10
4
+
5
+ ### New Features
6
+
7
+ - **Spring layout (d3-force)**: New default layout using d3-force with charge, link-distance and collision forces; produces clean hub-spoke rings for hierarchical data like Neo4j browser
8
+ - **Degree-based node sizing**: When no explicit `size_field` is set, nodes are sized by their connection count; hub nodes appear larger and push neighbors into natural rings
9
+ - **Filter-aware layouts**: Switching layout or clicking refresh now re-runs the layout using only visible nodes, respecting type filters and search state
10
+
11
+ ### Improvements
12
+
13
+ - Spring layout is the new default (renamed to "Default" in the dropdown)
14
+ - Force layout uses `linLogMode` for better community separation
15
+ - Cluster layout filters out hidden node types before packing
16
+ - Filter panel refresh button triggers a layout refresh
17
+
3
18
  ## 0.2.9
4
19
 
5
20
  ### New Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anywidget-graph
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Interactive graph visualization for Python notebooks using anywidget
5
5
  Project-URL: Homepage, https://grafeo.dev/
6
6
  Project-URL: Repository, https://github.com/GrafeoDB/anywidget-graph
@@ -0,0 +1,99 @@
1
+ """Marimo demo: hierarchical repo-like graph to test force layout with hub-spoke data."""
2
+
3
+ import marimo
4
+
5
+ __generated_with = "0.19.5"
6
+ app = marimo.App(width="medium")
7
+
8
+
9
+ @app.cell
10
+ def _():
11
+ import marimo as mo
12
+
13
+ return (mo,)
14
+
15
+
16
+ @app.cell
17
+ def _():
18
+ from anywidget_graph import Graph
19
+
20
+ return (Graph,)
21
+
22
+
23
+ @app.cell
24
+ def _(Graph):
25
+ # Simulate a repository structure:
26
+ # 1 Repository -> 4 Directories -> Files -> TypeDefinitions
27
+ nodes = [
28
+ {"id": "repo", "labels": ["Repository"], "name": "my-app"},
29
+ ]
30
+ edges = []
31
+
32
+ dirs = ["src", "lib", "tests", "config"]
33
+ file_id = 0
34
+ type_id = 0
35
+
36
+ for d in dirs:
37
+ did = f"dir_{d}"
38
+ nodes.append({"id": did, "labels": ["Directory"], "name": d})
39
+ edges.append({"source": "repo", "target": did, "label": "CONTAINS", "type": "CONTAINS"})
40
+
41
+ # Each directory has 3-8 files
42
+ import random
43
+
44
+ random.seed(hash(d))
45
+ n_files = random.randint(3, 8)
46
+ for i in range(n_files):
47
+ fid = f"file_{file_id}"
48
+ fname = f"{d}/file_{i}.py"
49
+ nodes.append({"id": fid, "labels": ["File"], "name": fname})
50
+ edges.append({"source": did, "target": fid, "label": "CONTAINS", "type": "CONTAINS"})
51
+ file_id += 1
52
+
53
+ # Each file has 1-5 type definitions
54
+ n_types = random.randint(1, 5)
55
+ for _j in range(n_types):
56
+ tid = f"type_{type_id}"
57
+ tname = f"Class_{type_id}"
58
+ nodes.append({"id": tid, "labels": ["TypeDefinition"], "name": tname})
59
+ edges.append({"source": fid, "target": tid, "label": "DEFINES", "type": "DEFINES"})
60
+ type_id += 1
61
+
62
+ # Some types depend on other types (cross-file references)
63
+ if type_id > 3 and random.random() < 0.3:
64
+ dep = f"type_{random.randint(0, type_id - 2)}"
65
+ edges.append({"source": tid, "target": dep, "label": "DEPENDS_ON", "type": "DEPENDS_ON"})
66
+
67
+ # Some cross-directory imports
68
+ if d != "src":
69
+ edges.append(
70
+ {
71
+ "source": did,
72
+ "target": "dir_src",
73
+ "label": "IMPORTS_FROM",
74
+ "type": "IMPORTS_FROM",
75
+ }
76
+ )
77
+
78
+ graph = Graph.from_dict(
79
+ {"nodes": nodes, "edges": edges},
80
+ width=1000,
81
+ height=700,
82
+ )
83
+
84
+ graph # noqa: B018
85
+ return (graph,)
86
+
87
+
88
+ @app.cell
89
+ def _(graph, mo):
90
+ mo.md(f"""
91
+ **Selected node:** {graph.selected_node}
92
+
93
+ Hierarchical repo graph: Repository -> Directories -> Files -> TypeDefinitions
94
+ """)
95
+ return ()
96
+
97
+
98
+ if __name__ == "__main__":
99
+ app.run()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-graph"
3
- version = "0.2.9"
3
+ version = "0.2.10"
4
4
  description = "Interactive graph visualization for Python notebooks using anywidget"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -151,6 +151,7 @@ import Sigma from "https://esm.sh/sigma@3.0.0";
151
151
  import circular from "https://esm.sh/graphology-layout@0.6.1/circular.js";
152
152
  import random from "https://esm.sh/graphology-layout@0.6.1/random.js";
153
153
  import forceAtlas2 from "https://esm.sh/graphology-layout-forceatlas2@0.10.1";
154
+ import * as d3Force from "https://esm.sh/d3-force@3.0.0";
154
155
  import neo4j from "https://cdn.jsdelivr.net/npm/neo4j-driver@5.28.0/lib/browser/neo4j-web.esm.min.js";
155
156
 
156
157
  // === Icons ===
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import Graph from "https://esm.sh/graphology@0.25.4";
6
6
  import Sigma from "https://esm.sh/sigma@3.0.0";
7
+ import * as d3Force from "https://esm.sh/d3-force@3.0.0";
7
8
 
8
9
  import { createToolbar } from "./toolbar.js";
9
10
  import { createSchemaPanel } from "./schema.js";
@@ -97,12 +98,18 @@ function computeNodeColor(node, colorField, colorScale, colorDomain) {
97
98
  return getCategoricalColor(value);
98
99
  }
99
100
 
100
- function computeNodeSize(node, sizeField, sizeDomain, sizeRange) {
101
+ function computeNodeSize(node, sizeField, sizeDomain, sizeRange, degree) {
101
102
  if (node.size !== undefined) return node.size;
102
- if (!sizeField || node[sizeField] === undefined || !sizeDomain) return 10;
103
- const [min, max] = sizeDomain;
104
- const t = max > min ? (node[sizeField] - min) / (max - min) : 0.5;
105
- return sizeRange[0] + t * (sizeRange[1] - sizeRange[0]);
103
+ if (sizeField && node[sizeField] !== undefined && sizeDomain) {
104
+ const [min, max] = sizeDomain;
105
+ const t = max > min ? (node[sizeField] - min) / (max - min) : 0.5;
106
+ return sizeRange[0] + t * (sizeRange[1] - sizeRange[0]);
107
+ }
108
+ // Auto-size by degree: hub nodes get bigger, leaf nodes smaller
109
+ if (degree !== undefined && degree > 0) {
110
+ return Math.max(4, Math.min(30, 4 + Math.sqrt(degree) * 3));
111
+ }
112
+ return 8;
106
113
  }
107
114
 
108
115
  function autoLabel(node) {
@@ -152,10 +159,20 @@ function getStylingOpts(model, nodes, edges) {
152
159
  if (vals.length > 0) edgeSizeDomain = [Math.min(...vals), Math.max(...vals)];
153
160
  }
154
161
 
162
+ // Compute degree map for auto-sizing when no sizeField is set
163
+ const degreeMap = new Map();
164
+ if (!sizeField) {
165
+ edges.forEach((e) => {
166
+ degreeMap.set(e.source, (degreeMap.get(e.source) || 0) + 1);
167
+ degreeMap.set(e.target, (degreeMap.get(e.target) || 0) + 1);
168
+ });
169
+ }
170
+
155
171
  return {
156
172
  colorField, colorScale, colorDomain, sizeField, sizeDomain, sizeRange,
157
173
  edgeColorField, edgeColorScale, edgeColorDomain,
158
174
  edgeSizeField, edgeSizeDomain, edgeSizeRange,
175
+ degreeMap,
159
176
  };
160
177
  }
161
178
 
@@ -166,7 +183,7 @@ function buildNodeAttrs(node, opts) {
166
183
  nodeType: (node.labels && node.labels.length > 0) ? node.labels[0] : (node.label || "Unlabeled"),
167
184
  x: node.x ?? Math.random() * 100,
168
185
  y: node.y ?? Math.random() * 100,
169
- size: computeNodeSize(node, opts.sizeField, opts.sizeDomain, opts.sizeRange),
186
+ size: computeNodeSize(node, opts.sizeField, opts.sizeDomain, opts.sizeRange, opts.degreeMap.get(node.id)),
170
187
  color: computeNodeColor(node, opts.colorField, opts.colorScale, opts.colorDomain),
171
188
  };
172
189
  }
@@ -308,7 +325,13 @@ function render({ model, el }) {
308
325
  }
309
326
 
310
327
  // Create panels first (all collapsed by default)
311
- const schema = createSchemaPanel(model, onExecuteQuery, null, onFilterChange, onColorChange);
328
+ function refreshLayout() {
329
+ applyLayout(model.get("layout") || "spring");
330
+ rendererRef?.refresh();
331
+ rendererRef?.getCamera().animatedReset({ duration: 300 });
332
+ }
333
+
334
+ const schema = createSchemaPanel(model, onExecuteQuery, null, onFilterChange, onColorChange, refreshLayout);
312
335
  const properties = createPropertiesPanel(model);
313
336
  const settings = model.get("show_settings")
314
337
  ? createSettingsPanel(model)
@@ -407,7 +430,7 @@ function render({ model, el }) {
407
430
 
408
431
  rebuildGraph();
409
432
 
410
- // Layout application (preserves pinned node positions)
433
+ // Layout application (preserves pinned positions, respects filters)
411
434
  function applyLayout(layoutName) {
412
435
  if (graph.order === 0) return;
413
436
  const pinned = model.get("pinned_nodes") || {};
@@ -420,6 +443,14 @@ function render({ model, el }) {
420
443
  }
421
444
  });
422
445
 
446
+ // Build a set of visible nodes (respecting type filters and search)
447
+ const visibleNodes = new Set();
448
+ graph.forEachNode((id, attrs) => {
449
+ if (hiddenNodeTypes.size > 0 && hiddenNodeTypes.has(attrs.nodeType)) return;
450
+ if (searchTerm && !searchVisibleNodes.has(id)) return;
451
+ visibleNodes.add(id);
452
+ });
453
+
423
454
  switch (layoutName) {
424
455
  case "circular":
425
456
  circular.assign(graph);
@@ -428,10 +459,11 @@ function render({ model, el }) {
428
459
  random.assign(graph);
429
460
  break;
430
461
  case "cluster": {
431
- // Step 1: Group nodes by type
462
+ // Step 1: Group visible nodes by type
432
463
  const nodes = model.get("nodes") || [];
433
464
  const labelGroups = new Map();
434
465
  nodes.forEach((node) => {
466
+ if (!visibleNodes.has(node.id)) return;
435
467
  const label = (node.labels && node.labels[0]) || node.label || "__other";
436
468
  if (!labelGroups.has(label)) labelGroups.set(label, []);
437
469
  labelGroups.get(label).push(node.id);
@@ -598,22 +630,88 @@ function render({ model, el }) {
598
630
  break;
599
631
  }
600
632
  case "force": {
601
- const n = graph.order;
602
- // Scale layout parameters to graph size for good spreading
603
- const iterations = Math.min(300, Math.max(100, n * 3));
604
- const gravity = n < 20 ? 0.3 : n < 100 ? 0.5 : 1;
605
- const scalingRatio = n < 20 ? 20 : n < 100 ? 10 : 5;
606
- forceAtlas2.assign(graph, {
633
+ // Build temp subgraph with visible nodes only
634
+ const tempGraph = new Graph({ multi: true });
635
+ graph.forEachNode((id, attrs) => {
636
+ if (visibleNodes.has(id)) tempGraph.addNode(id, { ...attrs });
637
+ });
638
+ graph.forEachEdge((edgeId, attrs, source, target) => {
639
+ if (visibleNodes.has(source) && visibleNodes.has(target)) {
640
+ tempGraph.addEdge(source, target, { ...attrs });
641
+ }
642
+ });
643
+
644
+ const n = tempGraph.order;
645
+ if (n === 0) break;
646
+ const iterations = Math.min(400, Math.max(100, n * 3));
647
+ const gravity = n < 20 ? 0.3 : n < 100 ? 0.1 : 0.05;
648
+ const scalingRatio = n < 20 ? 20 : n < 100 ? 15 : 10;
649
+ forceAtlas2.assign(tempGraph, {
607
650
  iterations,
608
651
  settings: {
609
652
  gravity,
610
653
  scalingRatio,
611
654
  barnesHutOptimize: n > 50,
612
655
  strongGravityMode: false,
656
+ linLogMode: true,
613
657
  adjustSizes: true,
614
658
  slowDown: 1,
615
659
  },
616
660
  });
661
+
662
+ // Copy positions back
663
+ tempGraph.forEachNode((id, attrs) => {
664
+ if (graph.hasNode(id)) {
665
+ graph.setNodeAttribute(id, "x", attrs.x);
666
+ graph.setNodeAttribute(id, "y", attrs.y);
667
+ }
668
+ });
669
+ break;
670
+ }
671
+ case "spring": {
672
+ // d3-force spring layout: produces clean hub-spoke rings for hierarchical data
673
+ const n = visibleNodes.size || graph.order;
674
+
675
+ // Build node and link arrays for d3 (visible nodes only)
676
+ const d3Nodes = [];
677
+ const nodeIndexMap = new Map();
678
+ let idx = 0;
679
+ graph.forEachNode((id, attrs) => {
680
+ if (!visibleNodes.has(id)) return;
681
+ nodeIndexMap.set(id, idx);
682
+ d3Nodes.push({ id, x: attrs.x, y: attrs.y, size: attrs.size || 8 });
683
+ idx++;
684
+ });
685
+
686
+ const d3Links = [];
687
+ graph.forEachEdge((edgeId, attrs, source, target) => {
688
+ if (nodeIndexMap.has(source) && nodeIndexMap.has(target)) {
689
+ d3Links.push({ source: nodeIndexMap.get(source), target: nodeIndexMap.get(target) });
690
+ }
691
+ });
692
+
693
+ // Configure forces
694
+ const linkDistance = n < 30 ? 50 : n < 100 ? 40 : 30;
695
+ const chargeStrength = n < 30 ? -200 : n < 100 ? -150 : -80;
696
+
697
+ const simulation = d3Force.forceSimulation(d3Nodes)
698
+ .force("link", d3Force.forceLink(d3Links).distance(linkDistance).strength(0.5))
699
+ .force("charge", d3Force.forceManyBody().strength(chargeStrength))
700
+ .force("center", d3Force.forceCenter(0, 0))
701
+ .force("collide", d3Force.forceCollide().radius((d) => d.size * 1.5 + 2).strength(0.7))
702
+ .stop();
703
+
704
+ // Run simulation synchronously
705
+ const ticks = Math.min(300, Math.max(100, n * 2));
706
+ for (let i = 0; i < ticks; i++) simulation.tick();
707
+
708
+ // Write positions back to graphology
709
+ d3Nodes.forEach((d) => {
710
+ if (graph.hasNode(d.id)) {
711
+ graph.setNodeAttribute(d.id, "x", d.x);
712
+ graph.setNodeAttribute(d.id, "y", d.y);
713
+ }
714
+ });
617
715
  break;
618
716
  }
619
717
  }
@@ -628,7 +726,7 @@ function render({ model, el }) {
628
726
  }
629
727
 
630
728
  // Apply initial layout
631
- applyLayout(model.get("layout") || "force");
729
+ applyLayout(model.get("layout") || "spring");
632
730
 
633
731
  // Initialize Sigma renderer with LOD settings
634
732
  const nodeCount = graph.order;
@@ -790,11 +888,11 @@ function render({ model, el }) {
790
888
  const layoutSelect = document.createElement("select");
791
889
  layoutSelect.className = "awg-layout-select";
792
890
  layoutSelect.title = "Layout algorithm";
793
- [["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
891
+ [["spring", "Default"], ["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
794
892
  const opt = document.createElement("option");
795
893
  opt.value = val;
796
894
  opt.textContent = text;
797
- if (val === (model.get("layout") || "force")) opt.selected = true;
895
+ if (val === (model.get("layout") || "spring")) opt.selected = true;
798
896
  layoutSelect.appendChild(opt);
799
897
  });
800
898
  layoutSelect.addEventListener("change", () => {
@@ -1073,7 +1171,7 @@ function render({ model, el }) {
1073
1171
  // Update graph when data or styling changes
1074
1172
  function onDataOrStyleChange() {
1075
1173
  rebuildGraph();
1076
- applyLayout(model.get("layout") || "force");
1174
+ applyLayout(model.get("layout") || "spring");
1077
1175
 
1078
1176
  // Update LOD thresholds based on new graph size
1079
1177
  const n = graph.order;
@@ -1098,11 +1196,7 @@ function render({ model, el }) {
1098
1196
  model.on("change:edge_size_range", onDataOrStyleChange);
1099
1197
 
1100
1198
  // Layout change handler
1101
- model.on("change:layout", () => {
1102
- applyLayout(model.get("layout"));
1103
- renderer.refresh();
1104
- renderer.getCamera().animatedReset({ duration: 300 });
1105
- });
1199
+ model.on("change:layout", refreshLayout);
1106
1200
 
1107
1201
  // === Demo Mode: auto-init WASM and populate data ===
1108
1202
  if (model.get("_demo_mode")) {
@@ -62,7 +62,7 @@ function extractTypesFromData(model) {
62
62
  * @param {function} onFilterChange - callback(hiddenNodeTypes: Set, hiddenEdgeTypes: Set)
63
63
  * @param {function} onColorChange - callback(typeColorMap: Map<string, string>)
64
64
  */
65
- export function createSchemaPanel(model, onExecuteQuery, callbacks, onFilterChange, onColorChange) {
65
+ export function createSchemaPanel(model, onExecuteQuery, callbacks, onFilterChange, onColorChange, onRefreshLayout) {
66
66
  const panel = document.createElement("div");
67
67
  panel.className = "awg-schema-panel";
68
68
 
@@ -85,8 +85,11 @@ export function createSchemaPanel(model, onExecuteQuery, callbacks, onFilterChan
85
85
  const refreshBtn = document.createElement("button");
86
86
  refreshBtn.className = "awg-btn awg-btn-icon awg-btn-sm";
87
87
  refreshBtn.innerHTML = ICONS.refresh;
88
- refreshBtn.title = "Refresh schema";
89
- refreshBtn.addEventListener("click", () => refreshSchema(model));
88
+ refreshBtn.title = "Refresh layout";
89
+ refreshBtn.addEventListener("click", () => {
90
+ refreshSchema(model);
91
+ onRefreshLayout?.();
92
+ });
90
93
  headerBtns.appendChild(refreshBtn);
91
94
 
92
95
  const closeBtn = document.createElement("button");
@@ -76,7 +76,7 @@ class Graph(anywidget.AnyWidget):
76
76
  edge_size_range = traitlets.List(default_value=[1, 8]).tag(sync=True)
77
77
 
78
78
  # === Layout ===
79
- layout = traitlets.Unicode(default_value="force").tag(sync=True)
79
+ layout = traitlets.Unicode(default_value="spring").tag(sync=True)
80
80
 
81
81
  # === Hover / Tooltip ===
82
82
  show_tooltip = traitlets.Bool(default_value=True).tag(sync=True)
@@ -929,7 +929,7 @@ edges.forEach(edge => {{
929
929
  }});
930
930
 
931
931
  // --- Apply layout ---
932
- const layoutName = OPTIONS.layout || "force";
932
+ const layoutName = OPTIONS.layout || "spring";
933
933
  if (graph.order > 0) {{
934
934
  switch (layoutName) {{
935
935
  case "circular":
@@ -51,7 +51,7 @@ def test_default_options():
51
51
  assert graph.show_labels is True
52
52
  assert graph.show_edge_labels is False
53
53
  assert graph.dark_mode is True
54
- assert graph.layout == "force"
54
+ assert graph.layout == "spring"
55
55
  assert graph.selection_mode == "click"
56
56
  assert graph.database_backend == "grafeo"
57
57
  assert graph.max_nodes == 300