anywidget-graph 0.2.9__tar.gz → 0.3.0__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 (47) hide show
  1. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/CHANGELOG.md +29 -0
  2. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/PKG-INFO +3 -1
  3. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/README.md +2 -0
  4. anywidget_graph-0.3.0/assets/demo.png +0 -0
  5. anywidget_graph-0.3.0/assets/widget.png +0 -0
  6. anywidget_graph-0.3.0/examples/test_hierarchy.py +99 -0
  7. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/pyproject.toml +1 -1
  8. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/__init__.py +1 -0
  9. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/index.js +153 -29
  10. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/schema.js +6 -3
  11. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/settings.js +5 -5
  12. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/styles.css +24 -19
  13. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/widget.py +2 -2
  14. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/tests/test_graph.py +1 -1
  15. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/uv.lock +1 -1
  16. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/.github/workflows/ci.yml +0 -0
  17. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/.github/workflows/pypi.yml +0 -0
  18. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/.gitignore +0 -0
  19. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/.python-version +0 -0
  20. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/LICENSE +0 -0
  21. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/examples/demo.py +0 -0
  22. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/examples/test_large_graph.py +0 -0
  23. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/__init__.py +0 -0
  24. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/backends/__init__.py +0 -0
  25. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/backends/arango.py +0 -0
  26. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/backends/cosmosdb.py +0 -0
  27. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/backends/grafeo.py +0 -0
  28. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/backends/ladybug.py +0 -0
  29. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/backends/neo4j.py +0 -0
  30. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/__init__.py +0 -0
  31. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/base.py +0 -0
  32. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/common.py +0 -0
  33. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/cypher.py +0 -0
  34. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/gql.py +0 -0
  35. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/graphql.py +0 -0
  36. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/gremlin.py +0 -0
  37. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/converters/sparql.py +0 -0
  38. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/demo.py +0 -0
  39. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/py.typed +0 -0
  40. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/grafeo-embed.js +0 -0
  41. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/grafeo.js +0 -0
  42. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/icons.js +0 -0
  43. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/neo4j.js +0 -0
  44. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/properties.js +0 -0
  45. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/results.js +0 -0
  46. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/src/anywidget_graph/ui/toolbar.js +0 -0
  47. {anywidget_graph-0.2.9 → anywidget_graph-0.3.0}/tests/__init__.py +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### New Features
6
+
7
+ - **Automatic dark mode**: Detects host theme (marimo `class="dark"`, `data-theme`, `prefers-color-scheme`) and syncs automatically via MutationObserver
8
+
9
+ ### Improvements
10
+
11
+ - Increased node spread in spring layout: stronger charge repulsion, larger link distance and collision radius for clearer hub-spoke separation
12
+ - Spring layout renamed to "Default" in the layout dropdown
13
+ - Updated color palette: blue accent and slate-based neutrals, replacing the previous indigo tint
14
+ - Theme toggle auto-hidden when running inside a themed host (e.g. marimo)
15
+ - Dark mode label colors update dynamically with theme changes
16
+
17
+ ## 0.2.10
18
+
19
+ ### New Features
20
+
21
+ - **d3-force spring layout**: New default layout using d3-force with charge, link-distance and collision forces; produces clean hub-spoke rings for hierarchical data
22
+ - **Degree-based node sizing**: Nodes auto-sized by connection count when no explicit `size_field` is set; hub nodes appear larger, pushing neighbors into natural rings
23
+ - **Filter-aware layouts**: Switching layout or clicking refresh re-runs the layout using only visible nodes, respecting type filters and search state
24
+
25
+ ### Improvements
26
+
27
+ - Spring layout is the new default
28
+ - Force layout uses `linLogMode` for better community separation
29
+ - Cluster layout filters out hidden node types before packing
30
+ - Filter panel refresh button triggers a layout refresh
31
+
3
32
  ## 0.2.9
4
33
 
5
34
  ### 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.3.0
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
@@ -45,6 +45,8 @@ Interactive graph visualization for Python notebooks.
45
45
 
46
46
  Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
47
47
 
48
+ ![anywidget-graph demo](assets/demo.png)
49
+
48
50
  ## Features
49
51
 
50
52
  - **Universal**: One widget, every notebook environment
@@ -4,6 +4,8 @@ Interactive graph visualization for Python notebooks.
4
4
 
5
5
  Works with Marimo, Jupyter, VS Code, Colab, anywhere [anywidget](https://anywidget.dev/) runs.
6
6
 
7
+ ![anywidget-graph demo](assets/demo.png)
8
+
7
9
  ## Features
8
10
 
9
11
  - **Universal**: One widget, every notebook environment
Binary file
Binary file
@@ -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.3.0"
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
  }
@@ -266,15 +283,45 @@ function render({ model, el }) {
266
283
  wrapper.style.height = model.get("height") + "px";
267
284
  wrapper.style.maxHeight = model.get("height") + "px";
268
285
 
269
- // Apply dark mode
286
+ // Auto-detect host theme (marimo uses Tailwind class="dark" on <html>)
287
+ function detectHostDark() {
288
+ const html = document.documentElement;
289
+ if (html.classList.contains("dark")) return true;
290
+ if (html.dataset.theme === "dark") return true;
291
+ if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) return true;
292
+ return false;
293
+ }
294
+
270
295
  function updateTheme() {
271
- wrapper.classList.toggle("awg-dark", model.get("dark_mode"));
296
+ const dark = model.get("dark_mode");
297
+ wrapper.classList.toggle("awg-dark", dark);
298
+ rendererRef?.setSetting("labelColor", { color: dark ? "#e0e0e0" : "#333" });
299
+ rendererRef?.refresh();
300
+ }
301
+
302
+ // Detect if inside a themed host (marimo, etc.)
303
+ const hasHostTheme = !!el.getRootNode()?.host?.tagName?.startsWith("MARIMO-");
304
+ if (hasHostTheme) {
305
+ wrapper.classList.add("awg-auto-theme");
306
+ model.set("dark_mode", detectHostDark());
307
+ model.save_changes();
308
+ updateTheme();
309
+ const themeObserver = new MutationObserver(() => {
310
+ const dark = detectHostDark();
311
+ if (model.get("dark_mode") !== dark) {
312
+ model.set("dark_mode", dark);
313
+ model.save_changes();
314
+ updateTheme();
315
+ }
316
+ });
317
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] });
318
+ } else {
319
+ updateTheme();
272
320
  }
273
- updateTheme();
321
+
322
+ // Manual toggle from settings panel still works
274
323
  model.on("change:dark_mode", () => {
275
324
  updateTheme();
276
- rendererRef?.setSetting("labelColor", { color: model.get("dark_mode") ? "#e0e0e0" : "#333" });
277
- rendererRef?.refresh();
278
325
  });
279
326
 
280
327
  // Create query executor callback
@@ -308,7 +355,13 @@ function render({ model, el }) {
308
355
  }
309
356
 
310
357
  // Create panels first (all collapsed by default)
311
- const schema = createSchemaPanel(model, onExecuteQuery, null, onFilterChange, onColorChange);
358
+ function refreshLayout() {
359
+ applyLayout(model.get("layout") || "spring");
360
+ rendererRef?.refresh();
361
+ rendererRef?.getCamera().animatedReset({ duration: 300 });
362
+ }
363
+
364
+ const schema = createSchemaPanel(model, onExecuteQuery, null, onFilterChange, onColorChange, refreshLayout);
312
365
  const properties = createPropertiesPanel(model);
313
366
  const settings = model.get("show_settings")
314
367
  ? createSettingsPanel(model)
@@ -407,7 +460,7 @@ function render({ model, el }) {
407
460
 
408
461
  rebuildGraph();
409
462
 
410
- // Layout application (preserves pinned node positions)
463
+ // Layout application (preserves pinned positions, respects filters)
411
464
  function applyLayout(layoutName) {
412
465
  if (graph.order === 0) return;
413
466
  const pinned = model.get("pinned_nodes") || {};
@@ -420,6 +473,14 @@ function render({ model, el }) {
420
473
  }
421
474
  });
422
475
 
476
+ // Build a set of visible nodes (respecting type filters and search)
477
+ const visibleNodes = new Set();
478
+ graph.forEachNode((id, attrs) => {
479
+ if (hiddenNodeTypes.size > 0 && hiddenNodeTypes.has(attrs.nodeType)) return;
480
+ if (searchTerm && !searchVisibleNodes.has(id)) return;
481
+ visibleNodes.add(id);
482
+ });
483
+
423
484
  switch (layoutName) {
424
485
  case "circular":
425
486
  circular.assign(graph);
@@ -428,10 +489,11 @@ function render({ model, el }) {
428
489
  random.assign(graph);
429
490
  break;
430
491
  case "cluster": {
431
- // Step 1: Group nodes by type
492
+ // Step 1: Group visible nodes by type
432
493
  const nodes = model.get("nodes") || [];
433
494
  const labelGroups = new Map();
434
495
  nodes.forEach((node) => {
496
+ if (!visibleNodes.has(node.id)) return;
435
497
  const label = (node.labels && node.labels[0]) || node.label || "__other";
436
498
  if (!labelGroups.has(label)) labelGroups.set(label, []);
437
499
  labelGroups.get(label).push(node.id);
@@ -598,22 +660,88 @@ function render({ model, el }) {
598
660
  break;
599
661
  }
600
662
  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, {
663
+ // Build temp subgraph with visible nodes only
664
+ const tempGraph = new Graph({ multi: true });
665
+ graph.forEachNode((id, attrs) => {
666
+ if (visibleNodes.has(id)) tempGraph.addNode(id, { ...attrs });
667
+ });
668
+ graph.forEachEdge((edgeId, attrs, source, target) => {
669
+ if (visibleNodes.has(source) && visibleNodes.has(target)) {
670
+ tempGraph.addEdge(source, target, { ...attrs });
671
+ }
672
+ });
673
+
674
+ const n = tempGraph.order;
675
+ if (n === 0) break;
676
+ const iterations = Math.min(400, Math.max(100, n * 3));
677
+ const gravity = n < 20 ? 0.3 : n < 100 ? 0.1 : 0.05;
678
+ const scalingRatio = n < 20 ? 20 : n < 100 ? 15 : 10;
679
+ forceAtlas2.assign(tempGraph, {
607
680
  iterations,
608
681
  settings: {
609
682
  gravity,
610
683
  scalingRatio,
611
684
  barnesHutOptimize: n > 50,
612
685
  strongGravityMode: false,
686
+ linLogMode: true,
613
687
  adjustSizes: true,
614
688
  slowDown: 1,
615
689
  },
616
690
  });
691
+
692
+ // Copy positions back
693
+ tempGraph.forEachNode((id, attrs) => {
694
+ if (graph.hasNode(id)) {
695
+ graph.setNodeAttribute(id, "x", attrs.x);
696
+ graph.setNodeAttribute(id, "y", attrs.y);
697
+ }
698
+ });
699
+ break;
700
+ }
701
+ case "spring": {
702
+ // d3-force spring layout: produces clean hub-spoke rings for hierarchical data
703
+ const n = visibleNodes.size || graph.order;
704
+
705
+ // Build node and link arrays for d3 (visible nodes only)
706
+ const d3Nodes = [];
707
+ const nodeIndexMap = new Map();
708
+ let idx = 0;
709
+ graph.forEachNode((id, attrs) => {
710
+ if (!visibleNodes.has(id)) return;
711
+ nodeIndexMap.set(id, idx);
712
+ d3Nodes.push({ id, x: attrs.x, y: attrs.y, size: attrs.size || 8 });
713
+ idx++;
714
+ });
715
+
716
+ const d3Links = [];
717
+ graph.forEachEdge((edgeId, attrs, source, target) => {
718
+ if (nodeIndexMap.has(source) && nodeIndexMap.has(target)) {
719
+ d3Links.push({ source: nodeIndexMap.get(source), target: nodeIndexMap.get(target) });
720
+ }
721
+ });
722
+
723
+ // Configure forces: generous spacing so hub-spoke rings are clear
724
+ const linkDistance = n < 30 ? 80 : n < 100 ? 60 : 45;
725
+ const chargeStrength = n < 30 ? -400 : n < 100 ? -300 : -200;
726
+
727
+ const simulation = d3Force.forceSimulation(d3Nodes)
728
+ .force("link", d3Force.forceLink(d3Links).distance(linkDistance).strength(0.4))
729
+ .force("charge", d3Force.forceManyBody().strength(chargeStrength))
730
+ .force("center", d3Force.forceCenter(0, 0))
731
+ .force("collide", d3Force.forceCollide().radius((d) => d.size * 2 + 4).strength(0.8))
732
+ .stop();
733
+
734
+ // Run simulation synchronously
735
+ const ticks = Math.min(300, Math.max(100, n * 2));
736
+ for (let i = 0; i < ticks; i++) simulation.tick();
737
+
738
+ // Write positions back to graphology
739
+ d3Nodes.forEach((d) => {
740
+ if (graph.hasNode(d.id)) {
741
+ graph.setNodeAttribute(d.id, "x", d.x);
742
+ graph.setNodeAttribute(d.id, "y", d.y);
743
+ }
744
+ });
617
745
  break;
618
746
  }
619
747
  }
@@ -628,7 +756,7 @@ function render({ model, el }) {
628
756
  }
629
757
 
630
758
  // Apply initial layout
631
- applyLayout(model.get("layout") || "force");
759
+ applyLayout(model.get("layout") || "spring");
632
760
 
633
761
  // Initialize Sigma renderer with LOD settings
634
762
  const nodeCount = graph.order;
@@ -790,11 +918,11 @@ function render({ model, el }) {
790
918
  const layoutSelect = document.createElement("select");
791
919
  layoutSelect.className = "awg-layout-select";
792
920
  layoutSelect.title = "Layout algorithm";
793
- [["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
921
+ [["spring", "Default"], ["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
794
922
  const opt = document.createElement("option");
795
923
  opt.value = val;
796
924
  opt.textContent = text;
797
- if (val === (model.get("layout") || "force")) opt.selected = true;
925
+ if (val === (model.get("layout") || "spring")) opt.selected = true;
798
926
  layoutSelect.appendChild(opt);
799
927
  });
800
928
  layoutSelect.addEventListener("change", () => {
@@ -1073,7 +1201,7 @@ function render({ model, el }) {
1073
1201
  // Update graph when data or styling changes
1074
1202
  function onDataOrStyleChange() {
1075
1203
  rebuildGraph();
1076
- applyLayout(model.get("layout") || "force");
1204
+ applyLayout(model.get("layout") || "spring");
1077
1205
 
1078
1206
  // Update LOD thresholds based on new graph size
1079
1207
  const n = graph.order;
@@ -1098,11 +1226,7 @@ function render({ model, el }) {
1098
1226
  model.on("change:edge_size_range", onDataOrStyleChange);
1099
1227
 
1100
1228
  // Layout change handler
1101
- model.on("change:layout", () => {
1102
- applyLayout(model.get("layout"));
1103
- renderer.refresh();
1104
- renderer.getCamera().animatedReset({ duration: 300 });
1105
- });
1229
+ model.on("change:layout", refreshLayout);
1106
1230
 
1107
1231
  // === Demo Mode: auto-init WASM and populate data ===
1108
1232
  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");
@@ -58,9 +58,8 @@ export function createSettingsPanel(model, callbacks) {
58
58
  const form = document.createElement("div");
59
59
  form.className = "awg-panel-form";
60
60
 
61
- // Dark mode toggle
62
- form.appendChild(
63
- createFormGroup("Theme", () => {
61
+ // Dark mode toggle (hidden when host provides theme)
62
+ const themeGroup = createFormGroup("Theme", () => {
64
63
  const toggle = document.createElement("label");
65
64
  toggle.className = "awg-toggle";
66
65
  const checkbox = document.createElement("input");
@@ -82,8 +81,9 @@ export function createSettingsPanel(model, callbacks) {
82
81
  toggle.appendChild(slider);
83
82
  toggle.appendChild(label);
84
83
  return toggle;
85
- })
86
- );
84
+ });
85
+ themeGroup.classList.add("awg-theme-toggle");
86
+ form.appendChild(themeGroup);
87
87
 
88
88
  // Backend selector
89
89
  const backendSelect = document.createElement("select");
@@ -2,35 +2,35 @@
2
2
  * Styles for anywidget-graph UI components.
3
3
  */
4
4
 
5
- /* === CSS VARIABLES === */
5
+ /* === CSS VARIABLES (matches marimo theme) === */
6
6
  .awg-wrapper {
7
7
  --awg-bg: #ffffff;
8
8
  --awg-bg-secondary: #f8f9fa;
9
- --awg-bg-tertiary: #f3f4f6;
10
- --awg-border: #e5e7eb;
11
- --awg-text: #111827;
9
+ --awg-bg-tertiary: #f1f5f9;
10
+ --awg-border: #e2e8f0;
11
+ --awg-text: #0f172a;
12
12
  --awg-text-secondary: #6b7280;
13
13
  --awg-text-muted: #9ca3af;
14
- --awg-accent: #6366f1;
15
- --awg-accent-hover: #4f46e5;
16
- --awg-graph-bg: #fafafa;
14
+ --awg-accent: #0880ea;
15
+ --awg-accent-hover: #0670d0;
16
+ --awg-graph-bg: #ffffff;
17
17
  --awg-input-bg: #ffffff;
18
- --awg-input-border: #d1d5db;
18
+ --awg-input-border: #e2e8f0;
19
19
  }
20
20
 
21
21
  .awg-wrapper.awg-dark {
22
- --awg-bg: #1a1a2e;
23
- --awg-bg-secondary: #252542;
24
- --awg-bg-tertiary: #3a3a5c;
25
- --awg-border: #3a3a5c;
26
- --awg-text: #e0e0e0;
22
+ --awg-bg: #181c1a;
23
+ --awg-bg-secondary: #252927;
24
+ --awg-bg-tertiary: #2f3533;
25
+ --awg-border: #3b403e;
26
+ --awg-text: #eceeed;
27
27
  --awg-text-secondary: #a0aec0;
28
- --awg-text-muted: #888;
29
- --awg-accent: #6366f1;
30
- --awg-accent-hover: #818cf8;
31
- --awg-graph-bg: #1a1a2e;
32
- --awg-input-bg: #1a1a2e;
33
- --awg-input-border: #3a3a5c;
28
+ --awg-text-muted: #9ca3af;
29
+ --awg-accent: #28879f;
30
+ --awg-accent-hover: #1d5b6a;
31
+ --awg-graph-bg: #181c1a;
32
+ --awg-input-bg: #181c1a;
33
+ --awg-input-border: #3b403e;
34
34
  }
35
35
 
36
36
  /* === BASE === */
@@ -515,6 +515,11 @@
515
515
  color: var(--awg-text);
516
516
  }
517
517
 
518
+ /* Hide dark mode toggle when host provides theme */
519
+ .awg-wrapper.awg-auto-theme .awg-theme-toggle {
520
+ display: none;
521
+ }
522
+
518
523
  /* === SCHEMA PANEL (LEFT) === */
519
524
  .awg-schema-panel {
520
525
  width: 0;
@@ -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
@@ -154,7 +154,7 @@ wheels = [
154
154
 
155
155
  [[package]]
156
156
  name = "anywidget-graph"
157
- version = "0.2.9"
157
+ version = "0.3.0"
158
158
  source = { editable = "." }
159
159
  dependencies = [
160
160
  { name = "anywidget" },
File without changes