anywidget-graph 0.2.7__tar.gz → 0.2.8__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 (42) hide show
  1. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/PKG-INFO +2 -1
  2. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/README.md +1 -0
  3. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/pyproject.toml +1 -1
  4. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/__init__.py +1 -1
  5. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/grafeo.py +61 -3
  6. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/__init__.py +4 -0
  7. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/icons.js +2 -0
  8. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/index.js +59 -1
  9. anywidget_graph-0.2.8/src/anywidget_graph/ui/results.js +183 -0
  10. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/styles.css +133 -0
  11. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/toolbar.js +16 -0
  12. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/widget.py +4 -0
  13. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/tests/test_graph.py +148 -0
  14. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/uv.lock +1 -1
  15. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/.github/workflows/ci.yml +0 -0
  16. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/.github/workflows/pypi.yml +0 -0
  17. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/.gitignore +0 -0
  18. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/.python-version +0 -0
  19. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/LICENSE +0 -0
  20. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/examples/demo.py +0 -0
  21. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/__init__.py +0 -0
  22. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/arango.py +0 -0
  23. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/cosmosdb.py +0 -0
  24. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/ladybug.py +0 -0
  25. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/backends/neo4j.py +0 -0
  26. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/__init__.py +0 -0
  27. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/base.py +0 -0
  28. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/common.py +0 -0
  29. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/cypher.py +0 -0
  30. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/gql.py +0 -0
  31. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/graphql.py +0 -0
  32. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/gremlin.py +0 -0
  33. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/converters/sparql.py +0 -0
  34. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/demo.py +0 -0
  35. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/py.typed +0 -0
  36. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/grafeo-embed.js +0 -0
  37. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/grafeo.js +0 -0
  38. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/neo4j.js +0 -0
  39. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/properties.js +0 -0
  40. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/schema.js +0 -0
  41. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/src/anywidget_graph/ui/settings.js +0 -0
  42. {anywidget_graph-0.2.7 → anywidget_graph-0.2.8}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anywidget-graph
3
- Version: 0.2.7
3
+ Version: 0.2.8
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
@@ -326,6 +326,7 @@ graph.save_html("graph.html") # Write HTML to file
326
326
  - [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
327
327
  - [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
328
328
  - [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
329
+ - [Playground](https://grafeo.ai), interactive browser playground for Grafeo
329
330
 
330
331
  ## License
331
332
 
@@ -285,6 +285,7 @@ graph.save_html("graph.html") # Write HTML to file
285
285
  - [anywidget](https://anywidget.dev/), custom Jupyter widgets made easy
286
286
  - [Grafeo](https://github.com/GrafeoDB/grafeo), embeddable graph database
287
287
  - [grafeo-web](https://github.com/GrafeoDB/grafeo-web), Grafeo in the browser
288
+ - [Playground](https://grafeo.ai), interactive browser playground for Grafeo
288
289
 
289
290
  ## License
290
291
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anywidget-graph"
3
- version = "0.2.7"
3
+ version = "0.2.8"
4
4
  description = "Interactive graph visualization for Python notebooks using anywidget"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -3,4 +3,4 @@
3
3
  from anywidget_graph.widget import Graph
4
4
 
5
5
  __all__ = ["Graph"]
6
- __version__ = "0.2.7"
6
+ __version__ = "0.2.8"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING, Any, ClassVar
6
6
 
7
7
  from anywidget_graph.converters import (
8
8
  get_node_id,
@@ -19,6 +19,14 @@ if TYPE_CHECKING:
19
19
  class GrafeoBackend:
20
20
  """Backend for Grafeo database connections."""
21
21
 
22
+ _LANGUAGE_METHODS: ClassVar[dict[str, str]] = {
23
+ "cypher": "execute_cypher",
24
+ "gremlin": "execute_gremlin",
25
+ "graphql": "execute_graphql",
26
+ "sparql": "execute_sparql",
27
+ "sql": "execute_sql",
28
+ }
29
+
22
30
  def __init__(self, db: Any) -> None:
23
31
  """Initialize with a GrafeoDB instance."""
24
32
  self._db = db
@@ -30,7 +38,11 @@ class GrafeoBackend:
30
38
 
31
39
  def execute(self, query: str, *, language: str = "cypher") -> tuple[list[dict], list[dict]]:
32
40
  """Execute a query in the specified language and return (nodes, edges)."""
33
- result = self._db.execute(query)
41
+ method_name = self._LANGUAGE_METHODS.get(language)
42
+ if method_name and hasattr(self._db, method_name):
43
+ result = getattr(self._db, method_name)(query)
44
+ else:
45
+ result = self._db.execute(query)
34
46
  return self._process_result(result)
35
47
 
36
48
  def fetch_schema(self) -> tuple[list[dict], list[dict]]:
@@ -61,7 +73,14 @@ class GrafeoBackend:
61
73
  for record in records:
62
74
  items = self._extract_items(record)
63
75
  for _key, value in items:
64
- if is_node(value):
76
+ if self._is_grafeo_node(value):
77
+ node_dict = self._grafeo_node_to_dict(value)
78
+ node_id = node_dict["id"]
79
+ if node_id not in nodes:
80
+ nodes[node_id] = node_dict
81
+ elif self._is_grafeo_relationship(value):
82
+ edges.append(self._grafeo_relationship_to_dict(value))
83
+ elif is_node(value):
65
84
  node_id = get_node_id(value)
66
85
  if node_id not in nodes:
67
86
  nodes[node_id] = node_to_dict(value)
@@ -70,6 +89,45 @@ class GrafeoBackend:
70
89
 
71
90
  return list(nodes.values()), edges
72
91
 
92
+ @staticmethod
93
+ def _is_grafeo_node(obj: Any) -> bool:
94
+ """Check if a value is a Grafeo node dict (has ``_id`` and ``_labels``)."""
95
+ return isinstance(obj, dict) and "_id" in obj and "_labels" in obj
96
+
97
+ @staticmethod
98
+ def _is_grafeo_relationship(obj: Any) -> bool:
99
+ """Check if a value is a Grafeo relationship dict (has ``_source``, ``_target``, ``_type``)."""
100
+ return isinstance(obj, dict) and "_source" in obj and "_target" in obj and "_type" in obj
101
+
102
+ @staticmethod
103
+ def _grafeo_node_to_dict(node: dict) -> dict:
104
+ """Convert a Grafeo node dict to the widget node format."""
105
+ node_id = str(node["_id"])
106
+ labels = node.get("_labels", [])
107
+ result: dict = {"id": node_id}
108
+ if labels:
109
+ result["label"] = labels[0]
110
+ result["labels"] = list(labels)
111
+ for key, value in node.items():
112
+ if not key.startswith("_"):
113
+ result[key] = value
114
+ if "label" not in result and "name" in result:
115
+ result["label"] = result["name"]
116
+ return result
117
+
118
+ @staticmethod
119
+ def _grafeo_relationship_to_dict(rel: dict) -> dict:
120
+ """Convert a Grafeo relationship dict to the widget edge format."""
121
+ result: dict = {
122
+ "source": str(rel["_source"]),
123
+ "target": str(rel["_target"]),
124
+ "label": str(rel["_type"]),
125
+ }
126
+ for key, value in rel.items():
127
+ if not key.startswith("_"):
128
+ result[key] = value
129
+ return result
130
+
73
131
  def _extract_items(self, record: Any) -> list[tuple[str, Any]]:
74
132
  """Extract key-value items from a record."""
75
133
  if hasattr(record, "items"):
@@ -140,6 +140,7 @@ def get_esm() -> str:
140
140
  schema_js = _read_file(_UI_DIR / "schema.js")
141
141
  settings_js = _read_file(_UI_DIR / "settings.js")
142
142
  properties_js = _read_file(_UI_DIR / "properties.js")
143
+ results_js = _read_file(_UI_DIR / "results.js")
143
144
  toolbar_js = _read_file(_UI_DIR / "toolbar.js")
144
145
  index_js = _read_file(_UI_DIR / "index.js")
145
146
 
@@ -173,6 +174,9 @@ import neo4j from "https://cdn.jsdelivr.net/npm/neo4j-driver@5.28.0/lib/browser/
173
174
  // === Properties Panel ===
174
175
  {_strip_imports_exports(properties_js)}
175
176
 
177
+ // === Results Drawer ===
178
+ {_strip_imports_exports(results_js)}
179
+
176
180
  // === Toolbar ===
177
181
  {_resolve_namespaces(toolbar_js)}
178
182
 
@@ -16,4 +16,6 @@ export const ICONS = {
16
16
  zoomOut: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
17
17
  zoomFit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`,
18
18
  search: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
19
+ chevronUp: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>`,
20
+ table: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></svg>`,
19
21
  };
@@ -9,6 +9,7 @@ import { createToolbar } from "./toolbar.js";
9
9
  import { createSchemaPanel } from "./schema.js";
10
10
  import { createSettingsPanel } from "./settings.js";
11
11
  import { createPropertiesPanel } from "./properties.js";
12
+ import { createResultsDrawer } from "./results.js";
12
13
  import * as neo4jBackend from "./neo4j.js";
13
14
  import * as grafeoBackend from "./grafeo.js";
14
15
  import * as grafeoEmbedBackend from "./grafeo-embed.js";
@@ -168,6 +169,8 @@ async function executeQuery(model) {
168
169
  return;
169
170
  }
170
171
 
172
+ const start = performance.now();
173
+
171
174
  if (backend === "neo4j") {
172
175
  // Browser-side Neo4j driver
173
176
  const result = await neo4jBackend.executeQuery(
@@ -176,6 +179,7 @@ async function executeQuery(model) {
176
179
  model
177
180
  );
178
181
  if (result) {
182
+ model.set("query_time", performance.now() - start);
179
183
  model.set("nodes", result.nodes);
180
184
  model.set("edges", result.edges);
181
185
  model.save_changes();
@@ -189,6 +193,7 @@ async function executeQuery(model) {
189
193
  model
190
194
  );
191
195
  if (result) {
196
+ model.set("query_time", performance.now() - start);
192
197
  model.set("nodes", result.nodes);
193
198
  model.set("edges", result.edges);
194
199
  model.save_changes();
@@ -197,12 +202,14 @@ async function executeQuery(model) {
197
202
  // Browser-side Grafeo WASM
198
203
  const result = await grafeoEmbedBackend.executeQuery(query, language, model);
199
204
  if (result) {
205
+ model.set("query_time", performance.now() - start);
200
206
  model.set("nodes", result.nodes);
201
207
  model.set("edges", result.edges);
202
208
  model.save_changes();
203
209
  }
204
210
  } else {
205
211
  // Python-side backends (grafeo-embedded, ladybug, arango, cosmosdb)
212
+ // Timing is handled on the Python side
206
213
  model.set("_execute_query", model.get("_execute_query") + 1);
207
214
  model.save_changes();
208
215
  }
@@ -283,6 +290,11 @@ function render({ model, el }) {
283
290
  }
284
291
 
285
292
  wrapper.appendChild(content);
293
+
294
+ // Results drawer (bottom, hidden by default with lip toggle)
295
+ const results = createResultsDrawer(model);
296
+ wrapper.appendChild(results.element);
297
+
286
298
  el.appendChild(wrapper);
287
299
 
288
300
  // Initialize Graphology graph
@@ -326,6 +338,52 @@ function render({ model, el }) {
326
338
  case "random":
327
339
  random.assign(graph);
328
340
  break;
341
+ case "cluster": {
342
+ // Group nodes by first label, arrange clusters in a circle
343
+ const nodes = model.get("nodes") || [];
344
+ const labelGroups = new Map();
345
+ nodes.forEach((node) => {
346
+ const label = (node.labels && node.labels[0]) || node.label || "__other";
347
+ if (!labelGroups.has(label)) labelGroups.set(label, []);
348
+ labelGroups.get(label).push(node.id);
349
+ });
350
+
351
+ const clusterLabels = [...labelGroups.entries()].filter(([, g]) => g.length >= 2);
352
+ const useClusters = clusterLabels.length >= 2;
353
+
354
+ if (!useClusters) {
355
+ // Not enough clusters, fall back to force
356
+ applyLayout("force");
357
+ return;
358
+ }
359
+
360
+ // Collect all labels (clusters with 2+ nodes first, then singles)
361
+ const singleNodes = [...labelGroups.entries()]
362
+ .filter(([, g]) => g.length < 2)
363
+ .flatMap(([, g]) => g);
364
+ const allGroups = [...clusterLabels];
365
+ if (singleNodes.length > 0) {
366
+ allGroups.push(["__other", singleNodes]);
367
+ }
368
+
369
+ const cx = 50, cy = 50;
370
+ const clusterRadius = 40;
371
+
372
+ allGroups.forEach(([, nodeIds], clusterIdx) => {
373
+ const clusterAngle = (2 * Math.PI * clusterIdx) / allGroups.length;
374
+ const clusterCx = cx + clusterRadius * Math.cos(clusterAngle);
375
+ const clusterCy = cy + clusterRadius * Math.sin(clusterAngle);
376
+ const innerRadius = Math.max(5, Math.min(20, nodeIds.length * 2));
377
+
378
+ nodeIds.forEach((nodeId, nodeIdx) => {
379
+ if (!graph.hasNode(nodeId)) return;
380
+ const innerAngle = (2 * Math.PI * nodeIdx) / nodeIds.length;
381
+ graph.setNodeAttribute(nodeId, "x", clusterCx + innerRadius * Math.cos(innerAngle));
382
+ graph.setNodeAttribute(nodeId, "y", clusterCy + innerRadius * Math.sin(innerAngle));
383
+ });
384
+ });
385
+ break;
386
+ }
329
387
  case "force": {
330
388
  const n = graph.order;
331
389
  // Scale layout parameters to graph size for good spreading
@@ -469,7 +527,7 @@ function render({ model, el }) {
469
527
  const layoutSelect = document.createElement("select");
470
528
  layoutSelect.className = "awg-layout-select";
471
529
  layoutSelect.title = "Layout algorithm";
472
- [["force", "Force"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
530
+ [["force", "Force"], ["cluster", "Cluster"], ["circular", "Circular"], ["random", "Random"]].forEach(([val, text]) => {
473
531
  const opt = document.createElement("option");
474
532
  opt.value = val;
475
533
  opt.textContent = text;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Results drawer component with collapsible lip toggle.
3
+ */
4
+ import { ICONS } from "./icons.js";
5
+
6
+ /**
7
+ * Create the results drawer (bottom panel with lip toggle).
8
+ */
9
+ export function createResultsDrawer(model) {
10
+ const drawer = document.createElement("div");
11
+ drawer.className = "awg-results-drawer";
12
+
13
+ // Lip (always visible, toggles the drawer)
14
+ const lip = document.createElement("div");
15
+ lip.className = "awg-results-lip";
16
+
17
+ const lipIcon = document.createElement("span");
18
+ lipIcon.className = "awg-results-lip-icon";
19
+ lipIcon.innerHTML = ICONS.chevronUp;
20
+
21
+ const lipLabel = document.createElement("span");
22
+ lipLabel.className = "awg-results-lip-label";
23
+ lipLabel.innerHTML = `${ICONS.table} Results`;
24
+
25
+ const lipTiming = document.createElement("span");
26
+ lipTiming.className = "awg-results-lip-timing";
27
+
28
+ function updateLipTiming() {
29
+ const ms = model.get("query_time") || 0;
30
+ const nodes = model.get("nodes") || [];
31
+ const edges = model.get("edges") || [];
32
+ const parts = [];
33
+ if (nodes.length > 0 || edges.length > 0) {
34
+ parts.push(`${nodes.length}n / ${edges.length}e`);
35
+ }
36
+ if (ms > 0) {
37
+ parts.push(ms < 1 ? "<1 ms" : `${ms.toFixed(1)} ms`);
38
+ }
39
+ lipTiming.textContent = parts.join(" \u00b7 ");
40
+ }
41
+ updateLipTiming();
42
+ model.on("change:query_time", updateLipTiming);
43
+ model.on("change:nodes", updateLipTiming);
44
+ model.on("change:edges", updateLipTiming);
45
+
46
+ lip.appendChild(lipLabel);
47
+ lip.appendChild(lipTiming);
48
+ lip.appendChild(lipIcon);
49
+
50
+ // Body (collapsible)
51
+ const body = document.createElement("div");
52
+ body.className = "awg-results-body";
53
+
54
+ // Tab bar
55
+ const tabBar = document.createElement("div");
56
+ tabBar.className = "awg-results-tabs";
57
+
58
+ const nodesTab = document.createElement("button");
59
+ nodesTab.className = "awg-results-tab awg-results-tab-active";
60
+ nodesTab.textContent = "Nodes";
61
+
62
+ const edgesTab = document.createElement("button");
63
+ edgesTab.className = "awg-results-tab";
64
+ edgesTab.textContent = "Edges";
65
+
66
+ tabBar.appendChild(nodesTab);
67
+ tabBar.appendChild(edgesTab);
68
+ body.appendChild(tabBar);
69
+
70
+ // Table container
71
+ const tableContainer = document.createElement("div");
72
+ tableContainer.className = "awg-results-table-wrap";
73
+ body.appendChild(tableContainer);
74
+
75
+ let activeTab = "nodes";
76
+
77
+ function setActiveTab(tab) {
78
+ activeTab = tab;
79
+ nodesTab.classList.toggle("awg-results-tab-active", tab === "nodes");
80
+ edgesTab.classList.toggle("awg-results-tab-active", tab === "edges");
81
+ renderTable();
82
+ }
83
+
84
+ nodesTab.addEventListener("click", () => setActiveTab("nodes"));
85
+ edgesTab.addEventListener("click", () => setActiveTab("edges"));
86
+
87
+ function renderTable() {
88
+ tableContainer.innerHTML = "";
89
+ const items = model.get(activeTab === "nodes" ? "nodes" : "edges") || [];
90
+
91
+ if (items.length === 0) {
92
+ const empty = document.createElement("div");
93
+ empty.className = "awg-results-empty";
94
+ empty.textContent = activeTab === "nodes" ? "No nodes" : "No edges";
95
+ tableContainer.appendChild(empty);
96
+ return;
97
+ }
98
+
99
+ // Collect all keys across items (skip internal rendering keys)
100
+ const skipKeys = new Set(["x", "y", "size", "color"]);
101
+ const keys = [];
102
+ const seen = new Set();
103
+ items.forEach((item) => {
104
+ Object.keys(item).forEach((k) => {
105
+ if (!seen.has(k) && !skipKeys.has(k)) {
106
+ seen.add(k);
107
+ keys.push(k);
108
+ }
109
+ });
110
+ });
111
+
112
+ const table = document.createElement("table");
113
+ table.className = "awg-results-table";
114
+
115
+ // Header
116
+ const thead = document.createElement("thead");
117
+ const headerRow = document.createElement("tr");
118
+ keys.forEach((k) => {
119
+ const th = document.createElement("th");
120
+ th.textContent = k;
121
+ headerRow.appendChild(th);
122
+ });
123
+ thead.appendChild(headerRow);
124
+ table.appendChild(thead);
125
+
126
+ // Body (limit to 100 rows for performance)
127
+ const tbody = document.createElement("tbody");
128
+ const limit = Math.min(items.length, 100);
129
+ for (let i = 0; i < limit; i++) {
130
+ const row = document.createElement("tr");
131
+ keys.forEach((k) => {
132
+ const td = document.createElement("td");
133
+ const val = items[i][k];
134
+ if (val === undefined || val === null) {
135
+ td.textContent = "";
136
+ } else if (typeof val === "object") {
137
+ td.textContent = JSON.stringify(val);
138
+ } else {
139
+ td.textContent = String(val);
140
+ }
141
+ row.appendChild(td);
142
+ });
143
+ tbody.appendChild(row);
144
+ }
145
+ table.appendChild(tbody);
146
+ tableContainer.appendChild(table);
147
+
148
+ if (items.length > 100) {
149
+ const more = document.createElement("div");
150
+ more.className = "awg-results-empty";
151
+ more.textContent = `Showing 100 of ${items.length} rows`;
152
+ tableContainer.appendChild(more);
153
+ }
154
+ }
155
+
156
+ model.on("change:nodes", () => { if (activeTab === "nodes") renderTable(); });
157
+ model.on("change:edges", () => { if (activeTab === "edges") renderTable(); });
158
+ renderTable();
159
+
160
+ drawer.appendChild(lip);
161
+ drawer.appendChild(body);
162
+
163
+ let isOpen = false;
164
+
165
+ function toggle() {
166
+ isOpen = !isOpen;
167
+ drawer.classList.toggle("awg-results-open", isOpen);
168
+ lipIcon.style.transform = isOpen ? "" : "rotate(180deg)";
169
+ }
170
+
171
+ lip.addEventListener("click", toggle);
172
+
173
+ // Start closed
174
+ lipIcon.style.transform = "rotate(180deg)";
175
+
176
+ return {
177
+ element: drawer,
178
+ open: () => { if (!isOpen) toggle(); },
179
+ close: () => { if (isOpen) toggle(); },
180
+ toggle,
181
+ isOpen: () => isOpen,
182
+ };
183
+ }
@@ -821,6 +821,139 @@
821
821
  }
822
822
  }
823
823
 
824
+ /* === TIMING BADGE === */
825
+ .awg-timing-badge {
826
+ color: var(--awg-accent);
827
+ font-size: 11px;
828
+ }
829
+
830
+ /* === RESULTS DRAWER === */
831
+ .awg-results-drawer {
832
+ border-top: 1px solid var(--awg-border);
833
+ background: var(--awg-bg-secondary);
834
+ }
835
+
836
+ .awg-results-lip {
837
+ display: flex;
838
+ align-items: center;
839
+ justify-content: center;
840
+ gap: 8px;
841
+ height: 28px;
842
+ cursor: pointer;
843
+ font-size: 11px;
844
+ color: var(--awg-text-secondary);
845
+ user-select: none;
846
+ transition: background 0.15s;
847
+ }
848
+
849
+ .awg-results-lip:hover {
850
+ background: var(--awg-bg-tertiary);
851
+ }
852
+
853
+ .awg-results-lip-icon {
854
+ display: flex;
855
+ transition: transform 0.2s;
856
+ }
857
+
858
+ .awg-results-lip-label {
859
+ display: flex;
860
+ align-items: center;
861
+ gap: 4px;
862
+ font-weight: 500;
863
+ text-transform: uppercase;
864
+ letter-spacing: 0.3px;
865
+ }
866
+
867
+ .awg-results-lip-timing {
868
+ font-variant-numeric: tabular-nums;
869
+ color: var(--awg-text-muted);
870
+ }
871
+
872
+ .awg-results-body {
873
+ max-height: 0;
874
+ overflow: hidden;
875
+ transition: max-height 0.25s ease;
876
+ }
877
+
878
+ .awg-results-open .awg-results-body {
879
+ max-height: 220px;
880
+ overflow-y: auto;
881
+ }
882
+
883
+ .awg-results-tabs {
884
+ display: flex;
885
+ gap: 0;
886
+ border-bottom: 1px solid var(--awg-border);
887
+ padding: 0 12px;
888
+ }
889
+
890
+ .awg-results-tab {
891
+ padding: 6px 14px;
892
+ border: none;
893
+ background: none;
894
+ font-size: 12px;
895
+ font-weight: 500;
896
+ color: var(--awg-text-muted);
897
+ cursor: pointer;
898
+ border-bottom: 2px solid transparent;
899
+ transition: color 0.15s, border-color 0.15s;
900
+ }
901
+
902
+ .awg-results-tab:hover {
903
+ color: var(--awg-text);
904
+ }
905
+
906
+ .awg-results-tab-active {
907
+ color: var(--awg-accent);
908
+ border-bottom-color: var(--awg-accent);
909
+ }
910
+
911
+ .awg-results-table-wrap {
912
+ overflow-x: auto;
913
+ }
914
+
915
+ .awg-results-table {
916
+ width: 100%;
917
+ border-collapse: collapse;
918
+ font-size: 12px;
919
+ }
920
+
921
+ .awg-results-table th {
922
+ position: sticky;
923
+ top: 0;
924
+ background: var(--awg-bg-tertiary);
925
+ padding: 6px 10px;
926
+ text-align: left;
927
+ font-weight: 600;
928
+ font-size: 10px;
929
+ text-transform: uppercase;
930
+ letter-spacing: 0.3px;
931
+ color: var(--awg-text-secondary);
932
+ white-space: nowrap;
933
+ border-bottom: 1px solid var(--awg-border);
934
+ }
935
+
936
+ .awg-results-table td {
937
+ padding: 5px 10px;
938
+ color: var(--awg-text);
939
+ border-bottom: 1px solid var(--awg-border);
940
+ max-width: 200px;
941
+ overflow: hidden;
942
+ text-overflow: ellipsis;
943
+ white-space: nowrap;
944
+ }
945
+
946
+ .awg-results-table tbody tr:hover {
947
+ background: var(--awg-bg-tertiary);
948
+ }
949
+
950
+ .awg-results-empty {
951
+ padding: 16px;
952
+ text-align: center;
953
+ color: var(--awg-text-muted);
954
+ font-size: 12px;
955
+ }
956
+
824
957
  /* === Touch-Friendly Targets === */
825
958
  @media (pointer: coarse) {
826
959
  .awg-btn {
@@ -103,6 +103,22 @@ export function createToolbar(model, onExecuteQuery, panels, onSearch) {
103
103
  model.on("change:edges", updateCount);
104
104
  toolbar.appendChild(countBadge);
105
105
 
106
+ // Query timing badge
107
+ const timingBadge = document.createElement("span");
108
+ timingBadge.className = "awg-count-badge awg-timing-badge";
109
+ function updateTiming() {
110
+ const ms = model.get("query_time") || 0;
111
+ if (ms > 0) {
112
+ timingBadge.textContent = ms < 1 ? "<1 ms" : `${ms.toFixed(1)} ms`;
113
+ timingBadge.style.display = "";
114
+ } else {
115
+ timingBadge.style.display = "none";
116
+ }
117
+ }
118
+ updateTiming();
119
+ model.on("change:query_time", updateTiming);
120
+ toolbar.appendChild(timingBadge);
121
+
106
122
  // Settings button (mutual exclusion with properties)
107
123
  if (model.get("show_settings")) {
108
124
  const settingsBtn = document.createElement("button");
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import time
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
8
9
  import anywidget
@@ -115,6 +116,7 @@ class Graph(anywidget.AnyWidget):
115
116
  query = traitlets.Unicode(default_value="").tag(sync=True)
116
117
  query_language = traitlets.Unicode(default_value="cypher").tag(sync=True)
117
118
  query_running = traitlets.Bool(default_value=False).tag(sync=True)
119
+ query_time = traitlets.Float(default_value=0.0).tag(sync=True)
118
120
  query_error = traitlets.Unicode(default_value="").tag(sync=True)
119
121
 
120
122
  # === Connection State ===
@@ -271,7 +273,9 @@ class Graph(anywidget.AnyWidget):
271
273
  try:
272
274
  self.query_running = True
273
275
  self.query_error = ""
276
+ start = time.perf_counter()
274
277
  nodes, edges = self._backend.execute(self.query, language=self.query_language)
278
+ self.query_time = (time.perf_counter() - start) * 1000
275
279
  self.nodes = nodes
276
280
  self.edges = edges
277
281
  except Exception as e:
@@ -55,6 +55,7 @@ def test_default_options():
55
55
  assert graph.selection_mode == "click"
56
56
  assert graph.database_backend == "grafeo"
57
57
  assert graph.max_nodes == 300
58
+ assert graph.query_time == 0.0
58
59
 
59
60
 
60
61
  def test_custom_options():
@@ -587,3 +588,150 @@ def test_build_neighbor_query_aql():
587
588
  q = Graph._build_neighbor_query("doc/1", "aql")
588
589
  assert "doc/1" in q
589
590
  assert "FOR" in q
591
+
592
+
593
+ # ------------------------------------------------------------------ #
594
+ # Grafeo backend: language dispatch #
595
+ # ------------------------------------------------------------------ #
596
+
597
+
598
+ def test_grafeo_backend_language_dispatch():
599
+ """execute() uses the language-specific method when available."""
600
+ from anywidget_graph.backends.grafeo import GrafeoBackend
601
+
602
+ calls: list[tuple[str, str]] = []
603
+
604
+ class FakeDB:
605
+ def execute_cypher(self, query):
606
+ calls.append(("cypher", query))
607
+ return []
608
+
609
+ def execute(self, query):
610
+ calls.append(("generic", query))
611
+ return []
612
+
613
+ backend = GrafeoBackend(FakeDB())
614
+ backend.execute("MATCH (n) RETURN n", language="cypher")
615
+ assert calls[-1] == ("cypher", "MATCH (n) RETURN n")
616
+
617
+
618
+ def test_grafeo_backend_language_fallback():
619
+ """execute() falls back to generic execute for unknown languages."""
620
+ from anywidget_graph.backends.grafeo import GrafeoBackend
621
+
622
+ calls: list[tuple[str, str]] = []
623
+
624
+ class FakeDB:
625
+ def execute(self, query):
626
+ calls.append(("generic", query))
627
+ return []
628
+
629
+ backend = GrafeoBackend(FakeDB())
630
+ backend.execute("SELECT 1", language="sql")
631
+ assert calls[-1] == ("generic", "SELECT 1")
632
+
633
+
634
+ # ------------------------------------------------------------------ #
635
+ # Grafeo backend: native dict processing #
636
+ # ------------------------------------------------------------------ #
637
+
638
+
639
+ def test_grafeo_node_detection():
640
+ """_is_grafeo_node detects dicts with _id and _labels."""
641
+ from anywidget_graph.backends.grafeo import GrafeoBackend
642
+
643
+ assert GrafeoBackend._is_grafeo_node({"_id": 1, "_labels": ["Person"]})
644
+ assert not GrafeoBackend._is_grafeo_node({"id": 1, "label": "Person"})
645
+ assert not GrafeoBackend._is_grafeo_node({"_id": 1})
646
+ assert not GrafeoBackend._is_grafeo_node("not a dict")
647
+
648
+
649
+ def test_grafeo_relationship_detection():
650
+ """_is_grafeo_relationship detects dicts with _source, _target, _type."""
651
+ from anywidget_graph.backends.grafeo import GrafeoBackend
652
+
653
+ assert GrafeoBackend._is_grafeo_relationship({"_source": 1, "_target": 2, "_type": "KNOWS"})
654
+ assert not GrafeoBackend._is_grafeo_relationship({"source": 1, "target": 2, "label": "KNOWS"})
655
+ assert not GrafeoBackend._is_grafeo_relationship({"_source": 1, "_target": 2})
656
+
657
+
658
+ def test_grafeo_node_to_dict():
659
+ """_grafeo_node_to_dict converts Grafeo node format to widget format."""
660
+ from anywidget_graph.backends.grafeo import GrafeoBackend
661
+
662
+ node = {"_id": 42, "_labels": ["Person", "Employee"], "name": "Alice", "age": 30}
663
+ result = GrafeoBackend._grafeo_node_to_dict(node)
664
+
665
+ assert result["id"] == "42"
666
+ assert result["label"] == "Person"
667
+ assert result["labels"] == ["Person", "Employee"]
668
+ assert result["name"] == "Alice"
669
+ assert result["age"] == 30
670
+ assert "_id" not in result
671
+ assert "_labels" not in result
672
+
673
+
674
+ def test_grafeo_node_to_dict_name_fallback():
675
+ """_grafeo_node_to_dict uses name as label when no _labels."""
676
+ from anywidget_graph.backends.grafeo import GrafeoBackend
677
+
678
+ node = {"_id": 1, "_labels": [], "name": "Bob"}
679
+ result = GrafeoBackend._grafeo_node_to_dict(node)
680
+ assert result["label"] == "Bob"
681
+
682
+
683
+ def test_grafeo_relationship_to_dict():
684
+ """_grafeo_relationship_to_dict converts Grafeo edge format to widget format."""
685
+ from anywidget_graph.backends.grafeo import GrafeoBackend
686
+
687
+ rel = {"_source": 1, "_target": 2, "_type": "KNOWS", "since": 2020}
688
+ result = GrafeoBackend._grafeo_relationship_to_dict(rel)
689
+
690
+ assert result["source"] == "1"
691
+ assert result["target"] == "2"
692
+ assert result["label"] == "KNOWS"
693
+ assert result["since"] == 2020
694
+ assert "_source" not in result
695
+ assert "_type" not in result
696
+
697
+
698
+ def test_grafeo_backend_process_native_dicts():
699
+ """Full round-trip: backend processes Grafeo native dict results."""
700
+ from anywidget_graph.backends.grafeo import GrafeoBackend
701
+
702
+ class FakeDB:
703
+ def execute(self, query):
704
+ return [
705
+ {
706
+ "n": {"_id": 1, "_labels": ["Person"], "name": "Alice"},
707
+ "r": {"_source": 1, "_target": 2, "_type": "KNOWS", "since": 2020},
708
+ "m": {"_id": 2, "_labels": ["Person"], "name": "Bob"},
709
+ },
710
+ ]
711
+
712
+ backend = GrafeoBackend(FakeDB())
713
+ nodes, edges = backend.execute("MATCH (n)-[r]->(m) RETURN n, r, m")
714
+
715
+ assert len(nodes) == 2
716
+ assert len(edges) == 1
717
+
718
+ node_ids = {n["id"] for n in nodes}
719
+ assert node_ids == {"1", "2"}
720
+
721
+ assert edges[0]["source"] == "1"
722
+ assert edges[0]["target"] == "2"
723
+ assert edges[0]["label"] == "KNOWS"
724
+ assert edges[0]["since"] == 2020
725
+
726
+
727
+ # ------------------------------------------------------------------ #
728
+ # Query timing #
729
+ # ------------------------------------------------------------------ #
730
+
731
+
732
+ def test_query_time_traitlet():
733
+ """query_time traitlet defaults to 0.0 and can be set."""
734
+ graph = Graph()
735
+ assert graph.query_time == 0.0
736
+ graph.query_time = 42.5
737
+ assert graph.query_time == 42.5
@@ -154,7 +154,7 @@ wheels = [
154
154
 
155
155
  [[package]]
156
156
  name = "anywidget-graph"
157
- version = "0.2.7"
157
+ version = "0.2.8"
158
158
  source = { editable = "." }
159
159
  dependencies = [
160
160
  { name = "anywidget" },
File without changes